feat: midterm shit done

This commit is contained in:
Tony Yang
2025-04-15 03:59:33 +08:00
parent f093df29a1
commit f7ee02586b
34 changed files with 1460 additions and 197 deletions
+1 -1
View File
@@ -16,4 +16,4 @@ import Footer from './components/Footer.vue';
</RouterView>
</div>
<Footer />
</template>
</template>
+18 -18
View File
@@ -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>
+37 -5
View File
@@ -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>
-36
View File
@@ -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>
+109
View File
@@ -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>
+38 -5
View File
@@ -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>
+102
View File
@@ -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>
+97
View File
@@ -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
View File
@@ -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;
}
+3
View File
@@ -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
View File
@@ -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;
+56
View File
@@ -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
View File
@@ -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>
-32
View File
@@ -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>
+24
View File
@@ -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>
+25
View File
@@ -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>
+26
View File
@@ -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>
-23
View File
@@ -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>