chore: update project files

This commit is contained in:
2026-02-13 23:23:36 +08:00
parent 66e438978e
commit 6a2d2c9724
1361 changed files with 4298 additions and 5117 deletions

1
web/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

View File

@@ -1,150 +0,0 @@
const API_BASE = 'http://localhost:8080/api/v1';
let token = '';
const sessionState = document.getElementById('sessionState');
const loginBtn = document.getElementById('loginBtn');
const createForm = document.getElementById('createForm');
const taskList = document.getElementById('taskList');
const refreshBtn = document.getElementById('refreshBtn');
const clearBtn = document.getElementById('clearBtn');
const template = document.getElementById('taskTemplate');
function setSessionState(text) {
sessionState.textContent = text;
}
function getHeaders() {
return {
'Content-Type': 'application/json',
Authorization: token ? `Bearer ${token}` : '',
};
}
async function login() {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
});
if (!response.ok) {
setSessionState('Login failed');
return;
}
const data = await response.json();
token = data.token || 'demo-token';
setSessionState('Connected');
await loadTasks();
}
function buildMeta(task) {
const parts = [];
if (task.status) parts.push(task.status.toUpperCase());
if (task.due_at) parts.push(`Due: ${task.due_at}`);
if (task.priority) parts.push(`P${task.priority}`);
if (task.tags && task.tags.length > 0) parts.push(task.tags.join(', '));
return parts.join(' • ');
}
function renderTasks(tasks) {
taskList.innerHTML = '';
if (!tasks || tasks.length === 0) {
taskList.innerHTML = '<div class="empty">No tasks yet.</div>';
return;
}
tasks.forEach((task) => {
const node = template.content.cloneNode(true);
node.querySelector('h3').textContent = task.title || 'Untitled task';
node.querySelector('.meta').textContent = buildMeta(task);
node.querySelector('.desc').textContent = task.description || '';
node.querySelector('.badge').textContent = task.status || 'todo';
node.querySelector('.toggle').addEventListener('click', () => toggleStatus(task));
node.querySelector('.delete').addEventListener('click', () => deleteTask(task));
taskList.appendChild(node);
});
}
async function loadTasks() {
const response = await fetch(`${API_BASE}/tasks`, {
headers: getHeaders(),
});
if (!response.ok) {
setSessionState('Auth required');
renderTasks([]);
return;
}
const data = await response.json();
renderTasks(data);
}
async function createTask(event) {
event.preventDefault();
const form = new FormData(createForm);
const payload = {
title: form.get('title'),
description: form.get('description'),
due_at: toISO(form.get('due_at')),
priority: Number(form.get('priority') || 0),
tags: String(form.get('tags') || '')
.split(',')
.map((tag) => tag.trim())
.filter(Boolean),
};
const response = await fetch(`${API_BASE}/tasks`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload),
});
if (!response.ok) {
setSessionState('Failed to create task');
return;
}
createForm.reset();
await loadTasks();
}
async function toggleStatus(task) {
const nextStatus = task.status === 'done' ? 'todo' : 'done';
await fetch(`${API_BASE}/tasks/${task.id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ status: nextStatus }),
});
await loadTasks();
}
async function deleteTask(task) {
await fetch(`${API_BASE}/tasks/${task.id}`, {
method: 'DELETE',
headers: getHeaders(),
});
await loadTasks();
}
async function clearCompleted() {
const response = await fetch(`${API_BASE}/api/tasks`, {
headers: getHeaders(),
});
if (!response.ok) {
return;
}
const tasks = await response.json();
const completed = tasks.filter((task) => task.status === 'done');
for (const task of completed) {
await deleteTask(task);
}
}
loginBtn.addEventListener('click', login);
createForm.addEventListener('submit', createTask);
refreshBtn.addEventListener('click', loadTasks);
clearBtn.addEventListener('click', clearCompleted);
setSessionState('Not connected');
function toISO(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toISOString();
}

1
web/dist/assets/LoginView-8H94ytcZ.js vendored Normal file
View File

@@ -0,0 +1 @@
import{d as y,c,a as e,t as s,u as l,b as a,n as g,e as m,w as v,v as _,f as w,r as p,g as k,h as C,l as V,s as x,i as B,o as b}from"./index-BJLwFBib.js";const N={class:"page login-page"},D={class:"page-head"},S={class:"auth-card"},T={class:"segmented"},U=["disabled"],$={key:0,class:"error"},L=y({__name:"LoginView",setup(z){const f=B(),n=p("login"),r=p(!1),u=p(""),o=k({email:"",password:""});async function h(){r.value=!0,u.value="";try{n.value==="register"&&await C(o);const d=await V(o);x(d.token,o.email),f.push("/todos")}catch{u.value=a("auth_failed")}finally{r.value=!1}}return(d,t)=>(b(),c("section",N,[e("header",D,[e("div",null,[e("h2",null,s(l(a)("login_title")),1),e("p",null,s(l(a)("login_subtitle")),1)])]),e("div",S,[e("h1",null,s(l(a)("brand_name")),1),e("p",null,s(l(a)("login_hint")),1),e("div",T,[e("button",{class:g(["btn",{primary:n.value==="login"}]),onClick:t[0]||(t[0]=i=>n.value="login")},s(l(a)("login_tab")),3),e("button",{class:g(["btn",{primary:n.value==="register"}]),onClick:t[1]||(t[1]=i=>n.value="register")},s(l(a)("register_tab")),3)]),e("label",null,[m(s(l(a)("email"))+" ",1),v(e("input",{"onUpdate:modelValue":t[2]||(t[2]=i=>o.email=i),type:"email",placeholder:"you@company.com"},null,512),[[_,o.email]])]),e("label",null,[m(s(l(a)("password"))+" ",1),v(e("input",{"onUpdate:modelValue":t[3]||(t[3]=i=>o.password=i),type:"password",placeholder:"******"},null,512),[[_,o.password]])]),e("button",{class:"btn primary full",disabled:r.value,onClick:h},s(r.value?l(a)("loading_wait"):n.value==="login"?l(a)("login_tab"):l(a)("register_and_login")),9,U),u.value?(b(),c("p",$,s(u.value),1)):w("",!0)])]))}});export{L as default};

View File

@@ -0,0 +1 @@
import{d,c as p,a as e,t as l,u as o,b as n,e as u,w as r,v as i,k as m,g,o as _}from"./index-BJLwFBib.js";const f={class:"page"},b={class:"page-head"},c={class:"btn primary",type:"submit"},v=d({__name:"TeamSettingsView",setup(w){const s=g({teamName:"Core Product Team",workspace:"supertodo",defaultAssignee:"Product Operator",doneRule:"At least 1 review before done"});return(x,t)=>(_(),p("section",f,[e("header",b,[e("h2",null,l(o(n)("team_settings_title")),1),e("p",null,l(o(n)("team_settings_subtitle")),1)]),e("form",{class:"card settings-form",onSubmit:t[4]||(t[4]=m(()=>{},["prevent"]))},[e("label",null,[u(l(o(n)("team_name"))+" ",1),r(e("input",{"onUpdate:modelValue":t[0]||(t[0]=a=>s.teamName=a),type:"text"},null,512),[[i,s.teamName]])]),e("label",null,[u(l(o(n)("workspace_slug"))+" ",1),r(e("input",{"onUpdate:modelValue":t[1]||(t[1]=a=>s.workspace=a),type:"text"},null,512),[[i,s.workspace]])]),e("label",null,[u(l(o(n)("default_assignee"))+" ",1),r(e("input",{"onUpdate:modelValue":t[2]||(t[2]=a=>s.defaultAssignee=a),type:"text"},null,512),[[i,s.defaultAssignee]])]),e("label",null,[u(l(o(n)("completion_rule"))+" ",1),r(e("textarea",{"onUpdate:modelValue":t[3]||(t[3]=a=>s.doneRule=a),rows:"4"},null,512),[[i,s.doneRule]])]),e("button",c,l(o(n)("save_team_settings")),1)],32)]))}});export{v as default};

1
web/dist/assets/TeamView-CPjxYYz8.js vendored Normal file
View File

@@ -0,0 +1 @@
import{d as l,c as o,a as e,t as s,u as a,b as n,F as m,q as _,o as r}from"./index-BJLwFBib.js";const d={class:"page"},i={class:"page-head"},p={class:"cards two-col"},g=l({__name:"TeamView",setup(u){const c=[{id:"core",name:"Core Product Team",members:7,open:12},{id:"growth",name:"Growth Team",members:5,open:8}];return(h,b)=>(r(),o("section",d,[e("header",i,[e("h2",null,s(a(n)("team_entry")),1),e("p",null,s(a(n)("team_subtitle")),1)]),e("div",p,[(r(),o(m,null,_(c,t=>e("article",{key:t.id,class:"card"},[e("h3",null,s(t.name),1),e("p",null,s(a(n)("team_members",{count:t.members})),1),e("p",null,s(a(n)("team_open_todos",{count:t.open})),1)])),64))])]))}});export{g as default};

1
web/dist/assets/TodoView-BN3VasB8.css vendored Normal file
View File

@@ -0,0 +1 @@
.filter-group[data-v-23b5de78]{display:flex;gap:.5rem;margin-right:1rem}.empty-state[data-v-23b5de78]{text-align:center;padding:3rem;color:#666;background:#ffffff80;border-radius:8px;grid-column:1 / -1}.btn.sm[data-v-23b5de78]{padding:.25rem .75rem;font-size:.85rem}

1
web/dist/assets/TodoView-scAhY5bP.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{d as p,g as u,E as d,c as b,a as e,t as i,u as n,b as l,e as o,w as r,v as m,G as c,k as f,o as _}from"./index-BJLwFBib.js";const y={class:"page"},g={class:"page-head"},x={class:"inline"},v={class:"btn primary",type:"submit"},U=p({__name:"UserSettingsView",setup(V){const s=u({displayName:"Product Operator",email:d.email,timezone:"Asia/Shanghai",notifications:!0});return(k,t)=>(_(),b("section",y,[e("header",g,[e("h2",null,i(n(l)("user_settings_title")),1),e("p",null,i(n(l)("user_settings_subtitle")),1)]),e("form",{class:"card settings-form",onSubmit:t[4]||(t[4]=f(()=>{},["prevent"]))},[e("label",null,[o(i(n(l)("display_name"))+" ",1),r(e("input",{"onUpdate:modelValue":t[0]||(t[0]=a=>s.displayName=a),type:"text"},null,512),[[m,s.displayName]])]),e("label",null,[o(i(n(l)("email"))+" ",1),r(e("input",{"onUpdate:modelValue":t[1]||(t[1]=a=>s.email=a),type:"email"},null,512),[[m,s.email]])]),e("label",null,[o(i(n(l)("timezone"))+" ",1),r(e("input",{"onUpdate:modelValue":t[2]||(t[2]=a=>s.timezone=a),type:"text"},null,512),[[m,s.timezone]])]),e("label",x,[r(e("input",{"onUpdate:modelValue":t[3]||(t[3]=a=>s.notifications=a),type:"checkbox"},null,512),[[c,s.notifications]]),o(" "+i(n(l)("reminders")),1)]),e("button",v,i(n(l)("save_settings")),1)],32)]))}});export{U as default};

26
web/dist/assets/index-BJLwFBib.js vendored Normal file

File diff suppressed because one or more lines are too long

1
web/dist/assets/index-th0r845L.css vendored Normal file

File diff suppressed because one or more lines are too long

13
web/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SuperTodo</title>
<script type="module" crossorigin src="/assets/index-BJLwFBib.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-th0r845L.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

1
web/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -3,90 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Todo Control Room</title>
<link rel="stylesheet" href="styles.css" />
<title>SuperTodo</title>
</head>
<body>
<div class="bg-orbit"></div>
<main class="shell">
<header class="hero">
<div>
<p class="eyebrow">Distributed todo playground</p>
<h1>Todo Control Room</h1>
<p class="subhead">A bold, focused UI to drive your task workflow from a single cockpit.</p>
</div>
<div class="status-card">
<h2>Session</h2>
<p id="sessionState">Not connected</p>
<button id="loginBtn" class="btn primary">Login (demo)</button>
</div>
</header>
<section class="panel">
<div class="panel-head">
<h2>New Task</h2>
<p>Create tasks quickly with priority and due date.</p>
</div>
<form id="createForm" class="form-grid">
<label>
<span>Title</span>
<input type="text" name="title" placeholder="Plan service split" required />
</label>
<label>
<span>Due</span>
<input type="datetime-local" name="due_at" />
</label>
<label>
<span>Priority</span>
<select name="priority">
<option value="1">High</option>
<option value="2">Medium</option>
<option value="3">Low</option>
</select>
</label>
<label>
<span>Tags (comma)</span>
<input type="text" name="tags" placeholder="backend, microservices" />
</label>
<label class="full">
<span>Description</span>
<textarea name="description" placeholder="Outline scope, list risks..."></textarea>
</label>
<button class="btn primary" type="submit">Create Task</button>
</form>
</section>
<section class="panel">
<div class="panel-head">
<h2>Tasks</h2>
<div class="actions">
<button id="refreshBtn" class="btn ghost">Refresh</button>
<button id="clearBtn" class="btn ghost">Clear Completed</button>
</div>
</div>
<div id="taskList" class="task-list">
<div class="empty">No tasks yet.</div>
</div>
</section>
</main>
<template id="taskTemplate">
<article class="task-card">
<div class="task-main">
<div>
<h3></h3>
<p class="meta"></p>
<p class="desc"></p>
</div>
<div class="badge"></div>
</div>
<div class="task-actions">
<button class="btn ghost toggle">Toggle Status</button>
<button class="btn ghost delete">Delete</button>
</div>
</article>
</template>
<script src="app.js"></script>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1545
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
web/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "supertodo-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.2",
"vite": "^6.0.5",
"vue-tsc": "^2.1.10"
}
}

50
web/src/App.vue Normal file
View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { clearSession, session } from './stores/session'
import * as api from './services/api'
import { t, toggleLocale } from './i18n'
import ToastContainer from './components/ToastContainer.vue'
const router = useRouter()
const isAuthed = computed(() => Boolean(session.token))
async function onLogout() {
try {
await api.logout()
} catch {
// Ignore remote logout failure and clear local session anyway.
}
clearSession()
router.push('/auth/login')
}
</script>
<template>
<div class="app-bg"></div>
<div class="layout">
<aside class="sidebar">
<h1>{{ t('brand_name') }}</h1>
<p class="caption">{{ t('brand_caption') }}</p>
<nav>
<template v-if="isAuthed">
<RouterLink to="/todos">{{ t('nav_todo') }}</RouterLink>
<RouterLink to="/team">{{ t('nav_team') }}</RouterLink>
<RouterLink to="/settings/user">{{ t('nav_user_settings') }}</RouterLink>
<RouterLink to="/settings/team">{{ t('nav_team_settings') }}</RouterLink>
</template>
<template v-else>
<RouterLink to="/auth/login">{{ t('nav_login') }}</RouterLink>
</template>
</nav>
<div class="sidebar-foot">
<span>{{ session.email || t('guest_mode') }}</span>
<button class="btn" @click="toggleLocale">{{ t('language') }}: {{ t('lang_switch') }}</button>
<button v-if="isAuthed" class="btn danger" @click="onLogout">{{ t('logout') }}</button>
</div>
</aside>
<main class="content"><RouterView /></main>
<ToastContainer />
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { t } from '../i18n'
defineProps<{
page: number
pageSize: number
total: number
}>()
const emit = defineEmits<{
next: []
prev: []
}>()
</script>
<template>
<div class="pagination">
<button class="btn" :disabled="page <= 1" @click="emit('prev')">{{ t('previous') }}</button>
<span>{{ t('page') }} {{ page }} / {{ Math.max(Math.ceil(total / pageSize), 1) }}</span>
<button class="btn" :disabled="page >= Math.ceil(total / pageSize)" @click="emit('next')">{{ t('next') }}</button>
</div>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { useToast } from '../composables/useToast'
const { toasts, remove } = useToast()
</script>
<template>
<div class="toast-container">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
class="toast"
:class="['toast-' + toast.type]"
@click="remove(toast.id)"
>
{{ toast.message }}
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.toast-container {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 9999;
pointer-events: none; /* Allow clicking through container */
}
.toast {
padding: 12px 24px;
border-radius: 8px;
color: white;
font-weight: 500;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
cursor: pointer;
pointer-events: auto; /* Re-enable pointer events for toasts */
min-width: 250px;
}
.toast-success {
background-color: #10b981; /* Emerald 500 */
}
.toast-error {
background-color: #ef4444; /* Red 500 */
}
.toast-info {
background-color: #3b82f6; /* Blue 500 */
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(30px);
}
.toast-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import type { Task } from '../types'
import { t } from '../i18n'
const props = defineProps<{
modelValue: boolean
editingTask?: Task | null
}>()
const emit = defineEmits<{
'update:modelValue': [boolean]
submit: [payload: Partial<Task>]
}>()
const form = reactive({
title: '',
description: '',
due_at: '',
priority: 2,
tags: '',
})
watch(
() => props.editingTask,
(task) => {
form.title = task?.title ?? ''
form.description = task?.description ?? ''
form.due_at = toLocal(task?.due_at ?? '')
form.priority = task?.priority ?? 2
form.tags = task?.tags?.join(', ') ?? ''
},
{ immediate: true },
)
watch(
() => props.modelValue,
(open) => {
if (!open && !props.editingTask) {
form.title = ''
form.description = ''
form.due_at = ''
form.priority = 2
form.tags = ''
}
},
)
function close() {
emit('update:modelValue', false)
}
function save() {
emit('submit', {
title: form.title,
description: form.description,
due_at: form.due_at ? new Date(form.due_at).toISOString() : '',
priority: Number(form.priority),
tags: form.tags
.split(',')
.map((v) => v.trim())
.filter(Boolean),
})
}
function toLocal(v: string) {
if (!v) return ''
const d = new Date(v)
if (Number.isNaN(d.getTime())) return ''
const offset = d.getTimezoneOffset() * 60000
return new Date(d.getTime() - offset).toISOString().slice(0, 16)
}
</script>
<template>
<div v-if="modelValue" class="modal-mask" @click.self="close">
<section class="modal">
<header>
<h3>{{ editingTask ? t('edit_todo') : t('create_todo') }}</h3>
</header>
<label>
{{ t('title') }}
<input v-model="form.title" type="text" required />
</label>
<label>
{{ t('due_at') }}
<input v-model="form.due_at" type="datetime-local" />
</label>
<label>
{{ t('priority') }}
<select v-model="form.priority">
<option :value="1">{{ t('high') }}</option>
<option :value="2">{{ t('medium') }}</option>
<option :value="3">{{ t('low') }}</option>
</select>
</label>
<label>
{{ t('tags') }}
<input v-model="form.tags" type="text" :placeholder="t('tags_placeholder')" />
</label>
<label>
{{ t('description') }}
<textarea v-model="form.description" rows="4"></textarea>
</label>
<div class="modal-actions">
<button class="btn" @click="close">{{ t('cancel') }}</button>
<button class="btn primary" @click="save">{{ t('save') }}</button>
</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,48 @@
import { ref } from 'vue'
export interface Toast {
id: number
message: string
type: 'success' | 'error' | 'info'
duration?: number
}
const toasts = ref<Toast[]>([])
let nextId = 0
export function useToast() {
function add(message: string, type: 'success' | 'error' | 'info' = 'info', duration = 3000) {
const id = nextId++
const toast: Toast = { id, message, type, duration }
toasts.value.push(toast)
if (duration > 0) {
setTimeout(() => {
remove(id)
}, duration)
}
}
function remove(id: number) {
const index = toasts.value.findIndex((t) => t.id === id)
if (index !== -1) {
toasts.value.splice(index, 1)
}
}
function success(message: string, duration = 3000) {
add(message, 'success', duration)
}
function error(message: string, duration = 3000) {
add(message, 'error', duration)
}
return {
toasts,
add,
remove,
success,
error,
}
}

181
web/src/i18n.ts Normal file
View File

@@ -0,0 +1,181 @@
import { computed, reactive } from 'vue'
const localeKey = 'supertodo_locale'
export type Locale = 'zh' | 'en'
const messages = {
en: {
brand_name: 'SuperTodo',
brand_caption: 'Simple team execution board',
nav_todo: 'Todo',
nav_team: 'Team',
nav_user_settings: 'User Settings',
nav_team_settings: 'Team Settings',
nav_login: 'Login',
logout: 'Logout',
guest_mode: 'Guest mode',
language: 'Language',
lang_switch: '中文',
login_title: 'Account Access',
login_subtitle: 'Use your account to enter the workspace.',
login_hint: 'Minimal Todo workspace for personal and team execution.',
login_tab: 'Login',
register_tab: 'Register',
email: 'Email',
password: 'Password',
auth_failed: 'Authentication failed, please check credentials.',
loading_wait: 'Please wait...',
register_and_login: 'Register and Login',
todo_title: 'Todo Board',
todo_subtitle: 'Simple execution list with team-ready structure.',
refresh: 'Refresh',
new_todo: 'New Todo',
no_description: 'No description',
priority_due: 'Priority P{priority} · Due {due}',
due_empty: '-',
edit: 'Edit',
delete: 'Delete',
loading_tasks: 'Loading tasks...',
load_tasks_failed: 'Failed to load tasks. Please login again.',
save_task_failed: 'Failed to save task.',
update_task_failed: 'Failed to update task.',
delete_task_failed: 'Failed to delete task.',
page: 'Page',
previous: 'Previous',
next: 'Next',
edit_todo: 'Edit Todo',
create_todo: 'New Todo',
title: 'Title',
due_at: 'Due At',
priority: 'Priority',
high: 'High',
medium: 'Medium',
low: 'Low',
tags: 'Tags',
tags_placeholder: 'backend, planning',
description: 'Description',
cancel: 'Cancel',
save: 'Save',
team_entry: 'Team Entry',
team_subtitle: 'Switch between teams and check active workload.',
team_members: '{count} members',
team_open_todos: '{count} open todos',
user_settings_title: 'User Settings',
user_settings_subtitle: 'Personal profile and preference controls.',
display_name: 'Display Name',
timezone: 'Timezone',
reminders: 'Receive task reminders',
save_settings: 'Save Settings',
team_settings_title: 'Team Settings',
team_settings_subtitle: 'Global conventions for team collaboration.',
team_name: 'Team Name',
workspace_slug: 'Workspace Slug',
default_assignee: 'Default Assignee',
completion_rule: 'Completion Rule',
save_team_settings: 'Save Team Settings',
task_updated_success: 'Task updated successfully',
task_created_success: 'Task created successfully',
task_deleted_success: 'Task deleted successfully',
confirm_delete: 'Are you sure you want to delete this task?',
},
zh: {
brand_name: 'SuperTodo',
brand_caption: '简约的团队任务执行面板',
nav_todo: '待办',
nav_team: '团队入口',
nav_user_settings: '用户设置',
nav_team_settings: '团队设置',
nav_login: '登录',
logout: '退出登录',
guest_mode: '访客模式',
language: '语言',
lang_switch: 'EN',
login_title: '账号入口',
login_subtitle: '使用账号登录后进入工作区。',
login_hint: '一个简洁的个人与团队任务协作空间。',
login_tab: '登录',
register_tab: '注册',
email: '邮箱',
password: '密码',
auth_failed: '认证失败,请检查邮箱和密码。',
loading_wait: '请稍候...',
register_and_login: '注册并登录',
todo_title: '待办看板',
todo_subtitle: '轻量执行清单,支持团队协作结构。',
refresh: '刷新',
new_todo: '新建待办',
no_description: '暂无描述',
priority_due: '优先级 P{priority} · 截止 {due}',
due_empty: '-',
edit: '编辑',
delete: '删除',
loading_tasks: '任务加载中...',
load_tasks_failed: '加载任务失败,请重新登录。',
save_task_failed: '保存任务失败。',
update_task_failed: '更新任务失败。',
delete_task_failed: '删除任务失败。',
page: '第',
previous: '上一页',
next: '下一页',
edit_todo: '编辑待办',
create_todo: '新建待办',
title: '标题',
due_at: '截止时间',
priority: '优先级',
high: '高',
medium: '中',
low: '低',
tags: '标签',
tags_placeholder: '后端, 规划',
description: '描述',
cancel: '取消',
save: '保存',
team_entry: '团队入口',
team_subtitle: '在团队之间切换并查看当前工作量。',
team_members: '{count} 位成员',
team_open_todos: '{count} 个未完成任务',
user_settings_title: '用户设置',
user_settings_subtitle: '管理个人资料和偏好设置。',
display_name: '显示名称',
timezone: '时区',
reminders: '接收任务提醒',
save_settings: '保存设置',
team_settings_title: '团队设置',
team_settings_subtitle: '管理团队协作的默认规则。',
team_name: '团队名称',
workspace_slug: '工作区标识',
default_assignee: '默认负责人',
completion_rule: '完成规则',
save_team_settings: '保存团队设置',
task_updated_success: '任务更新成功',
task_created_success: '任务创建成功',
task_deleted_success: '任务删除成功',
confirm_delete: '确定要删除这个任务吗?',
},
} as const
type MessageKey = keyof typeof messages.en
const initialLocale = (localStorage.getItem(localeKey) as Locale | null) ?? 'zh'
export const i18n = reactive({
locale: initialLocale === 'en' ? 'en' : 'zh',
})
export const currentLocale = computed(() => i18n.locale)
export function toggleLocale() {
i18n.locale = i18n.locale === 'zh' ? 'en' : 'zh'
localStorage.setItem(localeKey, i18n.locale)
}
export function t(key: MessageKey, params?: Record<string, string | number>) {
const locale = i18n.locale as Locale
let text: string = messages[locale][key] ?? messages.en[key]
if (params) {
Object.entries(params).forEach(([k, v]) => {
text = text.replace(new RegExp(`{${k}}`, 'g'), String(v))
})
}
return text
}

6
web/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'
createApp(App).use(router).mount('#app')

42
web/src/router.ts Normal file
View File

@@ -0,0 +1,42 @@
import { createRouter, createWebHistory } from 'vue-router'
import { session } from './stores/session'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/auth/login', name: 'login', component: () => import('./views/LoginView.vue') },
{ path: '/', redirect: '/todos' },
{
path: '/todos',
name: 'todos',
component: () => import('./views/TodoView.vue'),
meta: { requiresAuth: true },
},
{
path: '/team',
name: 'team',
component: () => import('./views/TeamView.vue'),
meta: { requiresAuth: true },
},
{
path: '/settings/user',
name: 'user-settings',
component: () => import('./views/UserSettingsView.vue'),
meta: { requiresAuth: true },
},
{
path: '/settings/team',
name: 'team-settings',
component: () => import('./views/TeamSettingsView.vue'),
meta: { requiresAuth: true },
},
],
})
router.beforeEach((to) => {
if (to.meta.requiresAuth && !session.token) return '/auth/login'
if (to.path === '/auth/login' && session.token) return '/todos'
return true
})
export default router

76
web/src/services/api.ts Normal file
View File

@@ -0,0 +1,76 @@
import type { Credentials, Task } from '../types'
import { session } from '../stores/session'
const API_BASE = 'http://localhost:8080/api/v1'
function headers() {
const h: Record<string, string> = { 'Content-Type': 'application/json' }
if (session.token) h.Authorization = `Bearer ${session.token}`
return h
}
async function handle<T>(response: Response): Promise<T> {
if (!response.ok) {
const body = await response.text()
throw new Error(body || `Request failed: ${response.status}`)
}
if (response.status === 204) return undefined as T
return (await response.json()) as T
}
export async function register(credentials: Credentials) {
const response = await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
headers: headers(),
body: JSON.stringify(credentials),
})
return handle<{ id: number; email: string }>(response)
}
export async function login(credentials: Credentials) {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: headers(),
body: JSON.stringify(credentials),
})
return handle<{ token: string }>(response)
}
export async function logout() {
const response = await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
headers: headers(),
})
return handle<void>(response)
}
export async function listTasks() {
const response = await fetch(`${API_BASE}/tasks`, { headers: headers() })
return handle<Task[]>(response)
}
export async function createTask(payload: Partial<Task>) {
const response = await fetch(`${API_BASE}/tasks`, {
method: 'POST',
headers: headers(),
body: JSON.stringify(payload),
})
return handle<Task>(response)
}
export async function updateTask(id: number, payload: Partial<Task>) {
const response = await fetch(`${API_BASE}/tasks/${id}`, {
method: 'PUT',
headers: headers(),
body: JSON.stringify(payload),
})
return handle<Task>(response)
}
export async function deleteTask(id: number) {
const response = await fetch(`${API_BASE}/tasks/${id}`, {
method: 'DELETE',
headers: headers(),
})
return handle<void>(response)
}

22
web/src/stores/session.ts Normal file
View File

@@ -0,0 +1,22 @@
import { reactive } from 'vue'
const tokenKey = 'supertodo_token'
export const session = reactive({
token: localStorage.getItem(tokenKey) ?? '',
email: localStorage.getItem('supertodo_email') ?? '',
})
export function setSession(token: string, email: string) {
session.token = token
session.email = email
localStorage.setItem(tokenKey, token)
localStorage.setItem('supertodo_email', email)
}
export function clearSession() {
session.token = ''
session.email = ''
localStorage.removeItem(tokenKey)
localStorage.removeItem('supertodo_email')
}

437
web/src/style.css Normal file
View File

@@ -0,0 +1,437 @@
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap');
:root {
--primary: #dc2626;
--secondary: #ef4444;
--accent: #fbbf24;
--bg: #fef2f2;
--surface: #ffffff;
--text: #7f1d1d;
--text-strong: #450a0a;
--border: #fecaca;
--danger: #991b1b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--text);
font-family: 'Plus Jakarta Sans', sans-serif;
background: radial-gradient(circle at 0% 0%, #fee2e2 0, #fef2f2 35%, #fff 100%);
}
a {
color: inherit;
text-decoration: none;
}
.app-bg {
position: fixed;
inset: 0;
pointer-events: none;
background-image: linear-gradient(transparent 96%, #fee2e2 100%),
linear-gradient(90deg, transparent 96%, #fee2e2 100%);
background-size: 32px 32px;
opacity: 0.5;
}
.layout {
position: relative;
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
border-right: 1px solid var(--border);
padding: 28px 20px;
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(4px);
}
.sidebar h1 {
margin: 0;
font-size: 24px;
color: var(--text-strong);
}
.caption {
margin: 10px 0 22px;
font-size: 12px;
}
.sidebar nav {
display: grid;
gap: 8px;
}
.sidebar nav a {
border: 1px solid transparent;
border-radius: 10px;
padding: 10px 12px;
transition: background-color 180ms ease, border-color 180ms ease;
}
.sidebar nav a.router-link-active,
.sidebar nav a:hover {
border-color: var(--border);
background: #fff5f5;
}
.sidebar-foot {
position: absolute;
bottom: 24px;
left: 20px;
right: 20px;
display: grid;
gap: 12px;
}
.content {
padding: 24px;
}
.page {
display: grid;
gap: 18px;
}
.page-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
}
.page-head h2 {
margin: 0;
font-size: 30px;
color: var(--text-strong);
}
.page-head p {
margin: 6px 0 0;
}
.page-actions {
display: flex;
gap: 10px;
}
.cards {
display: grid;
gap: 12px;
}
.two-col {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card,
.task-row,
.auth-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
}
.task-row {
display: flex;
align-items: center;
gap: 24px;
padding: 24px 26px;
}
.task-row-main {
display: flex;
align-items: center;
gap: 24px;
flex: 0 1 48%;
min-width: 0;
padding-left: 14px;
}
.task-row-content {
display: grid;
gap: 14px;
min-width: 0;
}
.task-row h3 {
margin: 0;
color: var(--text-strong);
}
.task-row p {
margin: 0;
min-width: 0;
line-height: 1.5;
}
.task-meta {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
margin-right: 0;
white-space: nowrap;
}
.task-row-meta {
flex: 0 0 auto;
min-width: 320px;
display: flex;
justify-content: center;
}
.task-row.done {
opacity: 0.72;
}
.task-row-actions {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: wrap;
flex: 0 0 auto;
margin-left: auto;
}
.task-check {
width: 18px;
height: 18px;
margin: 0;
appearance: none;
border: 1.6px solid #f2b8b8;
border-radius: 5px;
background: #fff;
cursor: pointer;
position: relative;
flex: 0 0 auto;
transition: background-color 180ms ease, border-color 180ms ease;
}
.task-check:hover {
border-color: #e88f8f;
}
.task-check:checked {
border-color: var(--primary);
background: var(--primary);
}
.task-check:checked::after {
content: '';
position: absolute;
left: 5px;
top: 1px;
width: 5px;
height: 9px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.settings-form {
max-width: 700px;
}
label {
display: grid;
gap: 8px;
margin-bottom: 14px;
}
.inline {
display: flex;
align-items: center;
gap: 8px;
}
input:not(.task-check),
select,
textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
font: inherit;
color: var(--text-strong);
background: #fff;
}
input:not(.task-check):focus,
select:focus,
textarea:focus,
button:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.btn {
border: 1px solid var(--border);
background: #fff;
color: var(--text-strong);
border-radius: 10px;
padding: 8px 14px;
cursor: pointer;
transition: background-color 180ms ease, color 180ms ease, border-color 180ms ease;
}
.btn:hover {
background: #fff5f5;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn.primary {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.btn.primary:hover {
background: var(--secondary);
}
.btn.danger {
border-color: #fecaca;
color: var(--danger);
}
.btn.full {
width: 100%;
}
.pagination {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
}
.auth-wrap {
min-height: 100vh;
display: grid;
place-items: center;
padding: 16px;
}
.auth-card {
width: min(460px, 100%);
}
.login-page .auth-card {
margin: 0 auto;
}
.auth-card h1 {
margin: 0;
color: var(--text-strong);
}
.auth-card p {
margin: 10px 0 18px;
}
.segmented {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 14px;
}
.error {
color: var(--danger);
font-size: 14px;
}
.modal-mask {
position: fixed;
inset: 0;
background: rgba(127, 29, 29, 0.26);
display: grid;
place-items: center;
padding: 16px;
}
.modal {
width: min(560px, 100%);
background: #fff;
border: 1px solid var(--border);
border-radius: 14px;
padding: 18px;
}
.modal h3 {
margin: 0 0 12px;
color: var(--text-strong);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.sidebar-foot {
position: static;
margin-top: 12px;
}
.two-col,
.task-row {
grid-template-columns: 1fr;
display: grid;
gap: 14px;
padding: 16px;
}
.task-row-main {
gap: 14px;
padding-left: 4px;
flex: initial;
}
.task-row-meta {
min-width: 0;
}
.task-row-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}

16
web/src/types.ts Normal file
View File

@@ -0,0 +1,16 @@
export type Task = {
id: number
title: string
description: string
status: 'todo' | 'done'
due_at: string
priority: number
tags: string[]
created_at: string
updated_at: string
}
export type Credentials = {
email: string
password: string
}

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import * as api from '../services/api'
import { setSession } from '../stores/session'
import { t } from '../i18n'
const router = useRouter()
const mode = ref<'login' | 'register'>('login')
const loading = ref(false)
const error = ref('')
const form = reactive({ email: '', password: '' })
async function submit() {
loading.value = true
error.value = ''
try {
if (mode.value === 'register') {
await api.register(form)
}
const data = await api.login(form)
setSession(data.token, form.email)
router.push('/todos')
} catch (e) {
error.value = t('auth_failed')
} finally {
loading.value = false
}
}
</script>
<template>
<section class="page login-page">
<header class="page-head">
<div>
<h2>{{ t('login_title') }}</h2>
<p>{{ t('login_subtitle') }}</p>
</div>
</header>
<div class="auth-card">
<h1>{{ t('brand_name') }}</h1>
<p>{{ t('login_hint') }}</p>
<div class="segmented">
<button class="btn" :class="{ primary: mode === 'login' }" @click="mode = 'login'">
{{ t('login_tab') }}
</button>
<button class="btn" :class="{ primary: mode === 'register' }" @click="mode = 'register'">
{{ t('register_tab') }}
</button>
</div>
<label>
{{ t('email') }}
<input v-model="form.email" type="email" placeholder="you@company.com" />
</label>
<label>
{{ t('password') }}
<input v-model="form.password" type="password" placeholder="******" />
</label>
<button class="btn primary full" :disabled="loading" @click="submit">
{{ loading ? t('loading_wait') : mode === 'login' ? t('login_tab') : t('register_and_login') }}
</button>
<p v-if="error" class="error">{{ error }}</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { reactive } from 'vue'
import { t } from '../i18n'
const team = reactive({
teamName: 'Core Product Team',
workspace: 'supertodo',
defaultAssignee: 'Product Operator',
doneRule: 'At least 1 review before done',
})
</script>
<template>
<section class="page">
<header class="page-head">
<h2>{{ t('team_settings_title') }}</h2>
<p>{{ t('team_settings_subtitle') }}</p>
</header>
<form class="card settings-form" @submit.prevent>
<label>
{{ t('team_name') }}
<input v-model="team.teamName" type="text" />
</label>
<label>
{{ t('workspace_slug') }}
<input v-model="team.workspace" type="text" />
</label>
<label>
{{ t('default_assignee') }}
<input v-model="team.defaultAssignee" type="text" />
</label>
<label>
{{ t('completion_rule') }}
<textarea v-model="team.doneRule" rows="4"></textarea>
</label>
<button class="btn primary" type="submit">{{ t('save_team_settings') }}</button>
</form>
</section>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { t } from '../i18n'
const teams = [
{ id: 'core', name: 'Core Product Team', members: 7, open: 12 },
{ id: 'growth', name: 'Growth Team', members: 5, open: 8 },
]
</script>
<template>
<section class="page">
<header class="page-head">
<h2>{{ t('team_entry') }}</h2>
<p>{{ t('team_subtitle') }}</p>
</header>
<div class="cards two-col">
<article v-for="team in teams" :key="team.id" class="card">
<h3>{{ team.name }}</h3>
<p>{{ t('team_members', { count: team.members }) }}</p>
<p>{{ t('team_open_todos', { count: team.open }) }}</p>
</article>
</div>
</section>
</template>

214
web/src/views/TodoView.vue Normal file
View File

@@ -0,0 +1,214 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Task } from '../types'
import * as api from '../services/api'
import PaginationControl from '../components/PaginationControl.vue'
import TodoEditorModal from '../components/TodoEditorModal.vue'
import { t } from '../i18n'
import { useToast } from '../composables/useToast'
const { success, error: showError } = useToast()
const tasks = ref<Task[]>([])
const loading = ref(false)
const modalOpen = ref(false)
const editingTask = ref<Task | null>(null)
const page = ref(1)
const pageSize = ref(6)
const filter = ref<'all' | 'todo' | 'done'>('all')
const filteredTasks = computed(() => {
if (filter.value === 'all') return tasks.value
return tasks.value.filter((t) => t.status === filter.value)
})
const total = computed(() => filteredTasks.value.length)
const pages = computed(() => Math.max(Math.ceil(total.value / pageSize.value), 1))
const pagedTasks = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredTasks.value.slice(start, start + pageSize.value)
})
async function loadTasks() {
loading.value = true
try {
const list = await api.listTasks()
tasks.value = list.sort((a, b) => Number(new Date(b.updated_at)) - Number(new Date(a.updated_at)))
if (page.value > pages.value) page.value = pages.value
} catch {
showError(t('load_tasks_failed'))
} finally {
loading.value = false
}
}
function openCreate() {
editingTask.value = null
modalOpen.value = true
}
function openEdit(task: Task) {
editingTask.value = task
modalOpen.value = true
}
async function saveTask(payload: Partial<Task>) {
try {
if (editingTask.value?.id) {
await api.updateTask(editingTask.value.id, payload)
success(t('task_updated_success') || 'Task updated')
} else {
await api.createTask({ ...payload, status: 'todo' })
success(t('task_created_success') || 'Task created')
}
modalOpen.value = false
editingTask.value = null
await loadTasks()
} catch {
showError(t('save_task_failed'))
}
}
async function toggle(task: Task) {
const originalStatus = task.status
const newStatus = task.status === 'done' ? 'todo' : 'done'
// Optimistic update
task.status = newStatus
try {
await api.updateTask(task.id, { status: newStatus })
// Background refresh to ensure consistency
// await loadTasks() // Optional: might cause jumping if list sort changes
} catch {
// Revert on failure
task.status = originalStatus
showError(t('update_task_failed'))
}
}
async function remove(id: number) {
if (!confirm(t('confirm_delete') || 'Are you sure?')) return
const originalTasks = [...tasks.value]
tasks.value = tasks.value.filter(t => t.id !== id)
try {
await api.deleteTask(id)
success(t('task_deleted_success') || 'Task deleted')
} catch {
tasks.value = originalTasks
showError(t('delete_task_failed'))
}
}
function nextPage() {
if (page.value < pages.value) page.value += 1
}
function prevPage() {
if (page.value > 1) page.value -= 1
}
function formatDue(value: string) {
return value ? new Date(value).toLocaleString() : t('due_empty')
}
function setFilter(f: 'all' | 'todo' | 'done') {
filter.value = f
page.value = 1
}
onMounted(loadTasks)
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<h2>{{ t('todo_title') }}</h2>
<p>{{ t('todo_subtitle') }}</p>
</div>
<div class="page-actions">
<div class="filter-group">
<button
class="btn sm"
:class="{ primary: filter === 'all' }"
@click="setFilter('all')"
>All</button>
<button
class="btn sm"
:class="{ primary: filter === 'todo' }"
@click="setFilter('todo')"
>Active</button>
<button
class="btn sm"
:class="{ primary: filter === 'done' }"
@click="setFilter('done')"
>Done</button>
</div>
<button class="btn" @click="loadTasks">{{ t('refresh') }}</button>
<button class="btn primary" @click="openCreate">{{ t('new_todo') }}</button>
</div>
</header>
<div class="cards" v-if="!loading">
<div v-if="pagedTasks.length === 0" class="empty-state">
<p>No tasks found.</p>
</div>
<article v-for="task in pagedTasks" :key="task.id" class="task-row" :class="{ done: task.status === 'done' }">
<div class="task-row-main">
<input
class="task-check"
type="checkbox"
:checked="task.status === 'done'"
@change="toggle(task)"
:aria-label="task.title"
/>
<div class="task-row-content">
<h3>{{ task.title }}</h3>
<p>{{ task.description || t('no_description') }}</p>
</div>
</div>
<div class="task-row-meta">
<div class="task-meta">
<small>{{ t('priority') }} P{{ task.priority || 2 }}</small>
<small>{{ t('due_at') }} {{ formatDue(task.due_at) }}</small>
</div>
</div>
<div class="task-row-actions">
<button class="btn" @click="openEdit(task)">{{ t('edit') }}</button>
<button class="btn danger" @click="remove(task.id)">{{ t('delete') }}</button>
</div>
</article>
</div>
<p v-else>{{ t('loading_tasks') }}</p>
<PaginationControl :page="page" :page-size="pageSize" :total="total" @next="nextPage" @prev="prevPage" />
<TodoEditorModal v-model="modalOpen" :editing-task="editingTask" @submit="saveTask" />
</section>
</template>
<style scoped>
.filter-group {
display: flex;
gap: 0.5rem;
margin-right: 1rem;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
grid-column: 1 / -1;
}
.btn.sm {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
}
</style>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { reactive } from 'vue'
import { session } from '../stores/session'
import { t } from '../i18n'
const form = reactive({
displayName: 'Product Operator',
email: session.email,
timezone: 'Asia/Shanghai',
notifications: true,
})
</script>
<template>
<section class="page">
<header class="page-head">
<h2>{{ t('user_settings_title') }}</h2>
<p>{{ t('user_settings_subtitle') }}</p>
</header>
<form class="card settings-form" @submit.prevent>
<label>
{{ t('display_name') }}
<input v-model="form.displayName" type="text" />
</label>
<label>
{{ t('email') }}
<input v-model="form.email" type="email" />
</label>
<label>
{{ t('timezone') }}
<input v-model="form.timezone" type="text" />
</label>
<label class="inline">
<input v-model="form.notifications" type="checkbox" />
{{ t('reminders') }}
</label>
<button class="btn primary" type="submit">{{ t('save_settings') }}</button>
</form>
</section>
</template>

View File

@@ -1,267 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Syne:wght@600;700&display=swap');
:root {
color-scheme: light;
--bg: #f6f3ef;
--bg-alt: #efe7de;
--ink: #1b1a17;
--muted: #6c5f57;
--accent: #d97706;
--accent-dark: #a35503;
--card: #fff7ed;
--stroke: rgba(27, 26, 23, 0.1);
--shadow: 0 24px 60px rgba(27, 26, 23, 0.15);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Space Grotesk', sans-serif;
background: radial-gradient(circle at top, #fff2d9 0%, var(--bg) 40%, var(--bg-alt) 100%);
color: var(--ink);
min-height: 100vh;
overflow-x: hidden;
}
h1, h2, h3 {
font-family: 'Syne', sans-serif;
margin: 0;
}
.bg-orbit {
position: fixed;
inset: 0;
background: radial-gradient(circle at 20% 20%, rgba(217, 119, 6, 0.2), transparent 50%),
radial-gradient(circle at 80% 10%, rgba(30, 64, 175, 0.15), transparent 45%),
radial-gradient(circle at 70% 80%, rgba(190, 24, 93, 0.12), transparent 60%);
z-index: -1;
}
.shell {
max-width: 1080px;
margin: 0 auto;
padding: 48px 24px 80px;
display: flex;
flex-direction: column;
gap: 32px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 24px;
align-items: stretch;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.72rem;
color: var(--muted);
margin: 0 0 12px;
}
.subhead {
font-size: 1rem;
color: var(--muted);
margin-top: 12px;
max-width: 42ch;
}
.status-card {
background: var(--card);
border: 1px solid var(--stroke);
border-radius: 18px;
padding: 20px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 12px;
}
.status-card p {
margin: 0;
color: var(--muted);
}
.panel {
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--stroke);
border-radius: 28px;
padding: 28px;
backdrop-filter: blur(6px);
box-shadow: var(--shadow);
animation: floatIn 0.6s ease both;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 18px;
}
.panel-head p {
color: var(--muted);
margin: 4px 0 0;
}
.actions {
display: flex;
gap: 12px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 20px;
}
.form-grid label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 0.85rem;
color: var(--muted);
}
.form-grid input,
.form-grid select,
.form-grid textarea {
border-radius: 12px;
border: 1px solid var(--stroke);
padding: 12px 14px;
font-size: 0.95rem;
font-family: inherit;
background: #fff;
}
.form-grid textarea {
min-height: 90px;
resize: vertical;
}
.form-grid .full {
grid-column: 1 / -1;
}
.btn {
border-radius: 999px;
padding: 10px 18px;
border: 1px solid transparent;
cursor: pointer;
font-weight: 600;
font-family: inherit;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn.primary {
background: var(--accent);
color: #fff;
box-shadow: 0 12px 24px rgba(217, 119, 6, 0.3);
}
.btn.ghost {
background: transparent;
border: 1px solid var(--stroke);
color: var(--ink);
}
.btn:hover {
transform: translateY(-2px);
}
.task-list {
display: grid;
gap: 16px;
}
.task-card {
background: var(--card);
border-radius: 20px;
padding: 18px;
border: 1px solid var(--stroke);
display: flex;
flex-direction: column;
gap: 14px;
animation: riseIn 0.4s ease both;
}
.task-main {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.task-card h3 {
margin: 0 0 6px;
}
.task-card .meta {
font-size: 0.85rem;
color: var(--muted);
}
.task-card .desc {
margin: 6px 0 0;
}
.badge {
padding: 6px 12px;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
background: rgba(217, 119, 6, 0.15);
color: var(--accent-dark);
}
.task-actions {
display: flex;
gap: 12px;
}
.empty {
padding: 20px;
border: 1px dashed var(--stroke);
border-radius: 16px;
text-align: center;
color: var(--muted);
}
@keyframes floatIn {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes riseIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 900px) {
.hero {
grid-template-columns: 1fr;
}
.form-grid {
grid-template-columns: 1fr;
}
}

17
web/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

9
web/vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
},
})