feat: midterm shit done
This commit is contained in:
+1
-1
@@ -16,4 +16,4 @@ import Footer from './components/Footer.vue';
|
||||
</RouterView>
|
||||
</div>
|
||||
<Footer />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const emit = defineEmits(['new-message']);
|
||||
const props = defineProps(['locked']);
|
||||
|
||||
const name = ref('');
|
||||
const message = ref('');
|
||||
const text = ref('');
|
||||
const maxLength = 1024;
|
||||
|
||||
const remainingCharacters = computed(() => {
|
||||
return maxLength - text.value.length;
|
||||
});
|
||||
|
||||
function submit() {
|
||||
if (!name || !message) {
|
||||
if (!text.value || props.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('new-message', { name: name.value, message: message.value });
|
||||
name.value = '';
|
||||
message.value = '';
|
||||
emit('new-message', { text: text.value });
|
||||
text.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -22,26 +26,22 @@ function submit() {
|
||||
<fieldset class="ts-fieldset">
|
||||
<legend class="ts-legend">New Message</legend>
|
||||
<div class="ts-wrap is-vertical">
|
||||
<div class="ts-control">
|
||||
<div class="label">Name</div>
|
||||
<div class="content is-fluid">
|
||||
<div class="ts-input">
|
||||
<input name="name" type="text" placeholder="Name" v-model="name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-control">
|
||||
<div class="label">Message</div>
|
||||
<div class="content is-fluid">
|
||||
<div class="ts-input">
|
||||
<textarea name="message" placeholder="Message" v-model="message"></textarea>
|
||||
<textarea v-if="!locked" name="message" placeholder="Message" v-model="text" :maxlength="maxLength" rows="5"></textarea>
|
||||
<textarea v-else placeholder="Please log in to leave message" :maxlength="maxLength" rows="5" disabled></textarea>
|
||||
</div>
|
||||
<div class="ts-text is-small is-secondary is-end-aligned">
|
||||
{{ remainingCharacters }} / {{ maxLength }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-wrap has-top-spaced is-end-aligned">
|
||||
<button class="ts-button" type="submit" :class="{'is-disabled': name === '' || message === '' }">Submit</button>
|
||||
<button class="ts-button" type="submit" :class="{'is-disabled': text === '' || props.locked }">Submit</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
<script setup>
|
||||
defineProps(['message']);
|
||||
import { defineProps, defineEmits, computed } from 'vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const props = defineProps(['message']);
|
||||
const emit = defineEmits(['delete-message']);
|
||||
|
||||
const userId = authStore.id;
|
||||
|
||||
const onDelete = () => {
|
||||
emit('delete-message', props.message.id);
|
||||
};
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
return props.message?.avatar ? import.meta.env.VITE_R2_BASE_URL + props.message?.avatar : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ts-conversation">
|
||||
<div class="avatar">
|
||||
<img src="../../assets/user.png" alt="Avatar" />
|
||||
<img :src="avatarUrl" alt="Avatar" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="bubble">
|
||||
<div class="author">{{ message.name }}</div>
|
||||
<div class="text">{{ message.message }}</div>
|
||||
<div class="ts-grid">
|
||||
<div class="column is-fluid">
|
||||
<div class="author">{{ message.username }}</div>
|
||||
<div class="text">{{ message.message }}</div>
|
||||
<div class="meta">
|
||||
<div class="item">{{ new Date(message.timestamp).toLocaleString('zh-TW', {timeZone: "-08:00"}) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column" v-if="userId === message.userId">
|
||||
<button class="ts-button is-icon is-negative is-outlined" @click="onDelete">
|
||||
<span class="ts-icon is-trash-icon"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,4 +48,8 @@ defineProps(['message']);
|
||||
.ts-conversation > .content {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
.ts-conversation .bubble .text {
|
||||
white-space: pre-line;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const emit = defineEmits(['new-user']);
|
||||
|
||||
const name = defineModel();
|
||||
|
||||
function submit() {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('new-user', { name: name.value });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<fieldset class="ts-fieldset">
|
||||
<legend class="ts-legend">New User</legend>
|
||||
<div class="ts-wrap is-vertical">
|
||||
<div class="ts-control">
|
||||
<div class="label">Name</div>
|
||||
<div class="content is-fluid">
|
||||
<div class="ts-input">
|
||||
<input name="name" type="text" placeholder="Name" v-model="name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-wrap has-top-spaced is-end-aligned">
|
||||
<button class="ts-button" type="submit" :class="{'is-disabled': name === '' }">Submit</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { login } from '../../lib/api';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const router = useRouter();
|
||||
|
||||
const usernameError = ref('');
|
||||
const passwordError = ref('');
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
function validateUsername() {
|
||||
if (!username.value) {
|
||||
usernameError.value = 'Username is required.';
|
||||
} else if (username.value.length < 3) {
|
||||
usernameError.value = 'Username must be at least 3 characters.';
|
||||
} else {
|
||||
usernameError.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function validatePassword() {
|
||||
if (!password.value) {
|
||||
passwordError.value = 'Password is required.';
|
||||
} else if (password.value.length < 8) {
|
||||
passwordError.value = 'Password must be at least 8 characters.';
|
||||
} else {
|
||||
passwordError.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => username.value,
|
||||
() => {
|
||||
validateUsername();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => password.value,
|
||||
() => {
|
||||
validatePassword();
|
||||
}
|
||||
);
|
||||
|
||||
const onSubmit = async () => {
|
||||
validateUsername();
|
||||
validatePassword();
|
||||
|
||||
if (usernameError.value || passwordError.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await login(username.value, password.value);
|
||||
const { jwt } = response;
|
||||
authStore.setJwt(jwt);
|
||||
alert('Login successful!');
|
||||
router.push('/profile');
|
||||
} catch (error) {
|
||||
alert("Login failed: " + error.message);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<fieldset class="ts-fieldset">
|
||||
<legend class="ts-legend">Login</legend>
|
||||
<div class="ts-wrap is-vertical">
|
||||
<div class="ts-control">
|
||||
<div class="label">Username</div>
|
||||
<div class="content is-fluid">
|
||||
<div class="ts-input" :class="{'is-negative': usernameError}">
|
||||
<input name="username" type="text" placeholder="Username" v-model="username" />
|
||||
</div>
|
||||
<div class="ts-text is-small is-negative" v-if="usernameError">{{ usernameError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-control">
|
||||
<div class="label">Password</div>
|
||||
<div class="content is-fluid">
|
||||
<div class="ts-input" :class="{'is-negative': passwordError}">
|
||||
<input name="password" type="password" placeholder="Password" v-model="password" />
|
||||
</div>
|
||||
<div class="ts-text is-small is-negative" v-if="passwordError">{{ passwordError }}</div>
|
||||
<div class="ts-text is-small is-negative">Warning: This is for demonstration purposes only. Passwords are stored in plaintext!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-wrap has-top-spaced is-end-aligned">
|
||||
<button class="ts-button" type="submit" :class="{
|
||||
'is-disabled': username === '' || password === '' || usernameError !== '' || passwordError !== ''
|
||||
}">Submit</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ts-error {
|
||||
color: red;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,25 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { useRouter, RouterLink } from 'vue-router';
|
||||
import { computed } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const isLoggedIn = computed(() => {
|
||||
return authStore.isLoggedIn;
|
||||
});
|
||||
|
||||
const username = computed(() => {
|
||||
return authStore.username;
|
||||
});
|
||||
|
||||
function logout() {
|
||||
authStore.clearJwt();
|
||||
alert('登出成功!');
|
||||
// Redirect to the login page
|
||||
router.push('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -8,11 +28,19 @@ import { RouterLink } from 'vue-router';
|
||||
<div class="ts-wrap">
|
||||
<RouterLink class="ts-header is-brand" to="/">網路攻防實習</RouterLink>
|
||||
<div class="ts-tab is-tall">
|
||||
<RouterLink class="item" :to="route.path" :class="{'is-active': $route.path == route.path}" v-for="route in $router.options.routes.filter(route => route.meta.showInNav)">{{ route.meta.navName }}</RouterLink>
|
||||
<RouterLink class="item" :to="route.path" :class="{'is-active': route.path == $route.path}" v-for="route in $router.options.routes.filter(route => route.meta.showInNav)">{{ route.meta.navName }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ts-button is-disabled" disabled>登入</button>
|
||||
<div class="actions ts-wrap">
|
||||
<template v-if="isLoggedIn">
|
||||
<span class="username">Hi, {{ username }}</span>
|
||||
<RouterLink class="ts-button" to="/profile">個人資料</RouterLink>
|
||||
<div class="ts-button" @click="logout">登出</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink class="ts-button" to="/login">登入</RouterLink>
|
||||
<RouterLink class="ts-button" to="/register">註冊</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -33,4 +61,9 @@ nav {
|
||||
.ts-header.is-brand {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
.username {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { ref, computed, defineProps } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { uploadAvatar } from '../../lib/api';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const jwt = authStore.jwt;
|
||||
|
||||
const avatarFile = ref(null);
|
||||
const avatarError = ref('');
|
||||
|
||||
const onFileChange = (event) => {
|
||||
avatarFile.value = event.target.files[0];
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
avatarError.value = '';
|
||||
|
||||
if (!avatarFile.value) {
|
||||
avatarError.value = 'Avatar is required.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (avatarFile.value.size > 2 * 1024 * 1024) {
|
||||
avatarError.value = 'Avatar must be less than 2MB.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (avatarFile.value.type !== 'image/jpeg' && avatarFile.value.type !== 'image/png') {
|
||||
avatarError.value = 'Avatar must be a JPG or PNG image.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadAvatar(avatarFile.value, jwt);
|
||||
alert('Avatar uploaded successfully!');
|
||||
// After successful upload, reload this page
|
||||
router.go(0);
|
||||
} catch (error) {
|
||||
alert("Avatar upload failed: " + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
if (avatarFile.value) {
|
||||
return URL.createObjectURL(avatarFile.value);
|
||||
}
|
||||
|
||||
return props.profile?.avatar ? import.meta.env.VITE_R2_BASE_URL + props.profile.avatar : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<fieldset class="ts-fieldset">
|
||||
<legend class="ts-legend">Update Profile</legend>
|
||||
<div class="ts-wrap is-vertical">
|
||||
<div class="ts-control">
|
||||
<div class="label">Username</div>
|
||||
<div class="content is-fluid">
|
||||
<div class="ts-input">
|
||||
<input type="text" name="username" placeholder="Username" :value="props.profile?.username" readonly disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-control">
|
||||
<div class="label">Avatar</div>
|
||||
<div class="content is-fluid">
|
||||
<div v-if="avatarUrl">
|
||||
<img :src="avatarUrl" alt="Avatar" style="max-width: 100px; max-height: 100px;" />
|
||||
</div>
|
||||
<div class="ts-file" :class="{'is-negative': avatarError}">
|
||||
<input type="file" accept="image/jpeg, image/png" @change="onFileChange" />
|
||||
</div>
|
||||
<div class="ts-text is-small is-negative" v-if="avatarError">{{ avatarError }}</div>
|
||||
<div class="ts-text is-small">Avatar must be a JPG or PNG image less than 2MB.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-wrap has-top-spaced is-end-aligned">
|
||||
<button class="ts-button" type="submit">Update</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ts-error {
|
||||
color: red;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const emit = defineEmits(['new-user']);
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const usernameError = ref('');
|
||||
const passwordError = ref('');
|
||||
|
||||
function validateUsername() {
|
||||
if (!username.value) {
|
||||
usernameError.value = 'Username is required.';
|
||||
} else if (username.value.length < 3) {
|
||||
usernameError.value = 'Username must be at least 3 characters.';
|
||||
} else {
|
||||
usernameError.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function validatePassword() {
|
||||
if (!password.value) {
|
||||
passwordError.value = 'Password is required.';
|
||||
} else if (password.value.length < 8) {
|
||||
passwordError.value = 'Password must be at least 8 characters.';
|
||||
} else {
|
||||
passwordError.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => username.value,
|
||||
() => {
|
||||
validateUsername();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => password.value,
|
||||
() => {
|
||||
validatePassword();
|
||||
}
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
validateUsername();
|
||||
validatePassword();
|
||||
|
||||
if (usernameError.value || passwordError.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('new-user', { username: username.value, password: password.value });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<fieldset class="ts-fieldset">
|
||||
<legend class="ts-legend">New User</legend>
|
||||
<div class="ts-wrap is-vertical">
|
||||
<div class="ts-control">
|
||||
<div class="label">Username</div>
|
||||
<div class="content is-fluid">
|
||||
<div class="ts-input" :class="{'is-negative': usernameError}">
|
||||
<input name="username" type="text" placeholder="Username" v-model="username" @input="validateUsername" />
|
||||
</div>
|
||||
<div class="ts-text is-small is-negative" v-if="usernameError">{{ usernameError }}</div>
|
||||
<div class="ts-text is-small">Username must be alphanumeric and at least 3 characters.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-control">
|
||||
<div class="label">Password</div>
|
||||
<div class="content is-fluid">
|
||||
<div class="ts-input" :class="{'is-negative': passwordError}">
|
||||
<input name="password" type="password" placeholder="Password" v-model="password" @input="validatePassword" />
|
||||
</div>
|
||||
<div class="ts-text is-small is-negative" v-if="passwordError">{{ passwordError }}</div>
|
||||
<div class="ts-text is-small">Password must be at least 8 characters.</div>
|
||||
<div class="ts-text is-small is-negative">Warning: This is for demonstration purposes only. Passwords are stored in plaintext!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-wrap has-top-spaced is-end-aligned">
|
||||
<button class="ts-button" type="submit" :class="{'is-disabled': username === '' || password === '' }">Submit</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ts-error {
|
||||
color: red;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
</style>
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
import { unauthRedirectToLogin } from '../router';
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
|
||||
export async function register(username, password) {
|
||||
const response = await fetch(API_BASE_URL + "/register", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Registration failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function login(username, password) {
|
||||
const response = await fetch(API_BASE_URL + "/login", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Login failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function postMessage(message, jwt) {
|
||||
const response = await fetch(API_BASE_URL + "/messages", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': "Bearer " + jwt,
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
unauthRedirectToLogin();
|
||||
return;
|
||||
}
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Posting message failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteMessage(messageId, jwt) {
|
||||
const response = await fetch(API_BASE_URL + "/messages", {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': "Bearer " + jwt,
|
||||
},
|
||||
body: JSON.stringify({ messageId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
unauthRedirectToLogin();
|
||||
return;
|
||||
}
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Deleting message failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getMessages(jwt) {
|
||||
const response = await fetch(API_BASE_URL + "/messages", {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
unauthRedirectToLogin();
|
||||
return;
|
||||
}
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Getting messages failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getProfile(jwt) {
|
||||
const response = await fetch(API_BASE_URL + "/me", {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': "Bearer " + jwt,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
unauthRedirectToLogin();
|
||||
return;
|
||||
}
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Getting profile failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function me(jwt) {
|
||||
const response = await fetch(API_BASE_URL + "/me", {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': "Bearer " + jwt,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
unauthRedirectToLogin();
|
||||
return;
|
||||
}
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'User operation failed');
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
const avatarFilename = userData.avatar;
|
||||
|
||||
if (avatarFilename) {
|
||||
return import.meta.env.VITE_R2_BASE_URL + "/" + avatarFilename;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function uploadAvatar(avatar, jwt) {
|
||||
const body = new FormData();
|
||||
body.append('avatar', avatar);
|
||||
|
||||
const response = await fetch(API_BASE_URL + "/avatars", {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': "Bearer " + jwt,
|
||||
},
|
||||
body: body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
unauthRedirectToLogin();
|
||||
return;
|
||||
}
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Avatar upload failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
@@ -2,8 +2,11 @@ import { createApp } from 'vue';
|
||||
import './style.css';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
const pinia = createPinia();
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
|
||||
+31
-14
@@ -1,9 +1,11 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import HomeView from '../views/HomeView.vue';
|
||||
import BoardView from '../views/BoardView.vue';
|
||||
import AboutView from '../views/AboutView.vue';
|
||||
import UsersView from '../views/UsersView.vue';
|
||||
import CreateUserView from '../views/CreateUserView.vue';
|
||||
import LoginView from '../views/LoginView.vue';
|
||||
import ProfileView from '../views/ProfileView.vue';
|
||||
import RegisterView from '../views/RegisterView.vue';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -35,22 +37,30 @@ const routes = [
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'Users',
|
||||
component: UsersView,
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: LoginView,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
showInNav: true,
|
||||
navName: '使用者列表'
|
||||
showInNav: false,
|
||||
navName: '登入'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/users/create',
|
||||
name: 'CreateUser',
|
||||
component: CreateUserView,
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: RegisterView,
|
||||
meta: {
|
||||
showInNav: true,
|
||||
navName: '新增使用者'
|
||||
showInNav: false,
|
||||
navName: '註冊'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
component: ProfileView,
|
||||
meta: {
|
||||
showInNav: false,
|
||||
navName: '個人資料'
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -60,4 +70,11 @@ const router = createRouter({
|
||||
routes
|
||||
});
|
||||
|
||||
export default router;
|
||||
export function unauthRedirectToLogin() {
|
||||
const auth = useAuthStore();
|
||||
auth.clearJwt();
|
||||
alert('Your session has expired. Redirecting to login.');
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
jwt: localStorage.getItem('jwt') || null,
|
||||
}),
|
||||
getters: {
|
||||
isLoggedIn: (state) => !!state.jwt,
|
||||
id: (state) => {
|
||||
if (state.jwt) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(state.jwt.split('.')[1]));
|
||||
return payload.id;
|
||||
} catch (error) {
|
||||
console.error("Failed to decode JWT:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
username: (state) => {
|
||||
if (state.jwt) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(state.jwt.split('.')[1]));
|
||||
return payload.username;
|
||||
} catch (error) {
|
||||
console.error("Failed to decode JWT:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
avatar: (state) => {
|
||||
if (state.jwt) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(state.jwt.split('.')[1]));
|
||||
return payload.avatar;
|
||||
} catch (error) {
|
||||
console.error("Failed to decode JWT:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
setJwt(newJwt) {
|
||||
this.jwt = newJwt;
|
||||
localStorage.setItem('jwt', newJwt);
|
||||
},
|
||||
clearJwt() {
|
||||
this.jwt = null;
|
||||
localStorage.removeItem('jwt');
|
||||
},
|
||||
},
|
||||
});
|
||||
+41
-6
@@ -1,22 +1,57 @@
|
||||
<script setup>
|
||||
import BoardForm from '../components/Board/BoardForm.vue';
|
||||
import BoardMessage from '../components/Board/BoardMessage.vue';
|
||||
import { ref } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { postMessage, deleteMessage, getMessages } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const messages = ref([]);
|
||||
|
||||
const onSubmit = (message) => {
|
||||
messages.value.push(message);
|
||||
const authStore = useAuthStore();
|
||||
const jwt = authStore.jwt;
|
||||
const isLoggedIn = authStore.isLoggedIn;
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await getMessages(jwt);
|
||||
messages.value = response.messages;
|
||||
} catch (error) {
|
||||
alert("Failed to get messages: " + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = async (message) => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
try {
|
||||
await postMessage(message.text, jwt);
|
||||
|
||||
const response = await getMessages();
|
||||
messages.value = response.messages;
|
||||
} catch (error) {
|
||||
alert("Failed to post message: " + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (messageId) => {
|
||||
try {
|
||||
await deleteMessage(messageId, jwt);
|
||||
|
||||
const response = await getMessages();
|
||||
messages.value = response.messages;
|
||||
} catch (error) {
|
||||
alert("Failed to delete message: " + error.message);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ts-container">
|
||||
<BoardForm @new-message="onSubmit" />
|
||||
<BoardForm :locked="!isLoggedIn" @new-message="onSubmit" />
|
||||
<div class="ts-content is-horizontally-fitted is-vertically-padded">
|
||||
<div class="ts-wrap is-vertical">
|
||||
<BoardMessage :message="message" v-for="message in messages" :key="message.id" />
|
||||
<BoardMessage :message="message" v-for="message in messages" :key="message.id" @delete-message="onDelete(message.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<script setup>
|
||||
import CreateForm from '../components/CreateUser/CreateForm.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const name = ref('');
|
||||
|
||||
const onSubmit = async () => {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.value,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
alert('Failed to create user');
|
||||
return;
|
||||
}
|
||||
|
||||
alert('User created');
|
||||
name.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ts-container">
|
||||
<CreateForm v-model="name" @new-user="onSubmit" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="ts-container">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LoginForm from '../components/Login/LoginForm.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
const response = await login(username.value, password.value);
|
||||
const { jwt } = response;
|
||||
localStorage.setItem('jwt', jwt);
|
||||
alert('Login successful!');
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
alert("Login failed: " + error.message);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="ts-container">
|
||||
<ProfileForm :profile="profile" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProfileForm from '../components/Profile/ProfileForm.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { getProfile } from '../lib/api';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const profile = ref(null);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await getProfile(authStore.jwt);
|
||||
profile.value = data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch profile:", error);
|
||||
alert("Failed to fetch profile: " + error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import RegisterForm from '../components/Register/RegisterForm.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { register } from '../lib/api';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleNewUser = async ({ username, password }) => {
|
||||
try {
|
||||
const response = await register(username, password);
|
||||
|
||||
alert(response.message || 'Registration successful! Please log in.');
|
||||
// Redirect to login page
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
alert("Registration error: " + error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ts-container">
|
||||
<RegisterForm @new-user="handleNewUser" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup>
|
||||
import { onActivated, ref } from 'vue';
|
||||
|
||||
const users = ref([]);
|
||||
|
||||
onActivated(async () => {
|
||||
const response = await fetch('/api/users');
|
||||
|
||||
if (!response.ok) {
|
||||
users.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
users.value = data;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ts-container">
|
||||
<pre>{{ users }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user