chore: update project files
This commit is contained in:
1
web/.gitignore
vendored
Normal file
1
web/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
150
web/app.js
150
web/app.js
@@ -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
1
web/dist/assets/LoginView-8H94ytcZ.js
vendored
Normal 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};
|
||||
1
web/dist/assets/TeamSettingsView-D1JkSdqf.js
vendored
Normal file
1
web/dist/assets/TeamSettingsView-D1JkSdqf.js
vendored
Normal 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
1
web/dist/assets/TeamView-CPjxYYz8.js
vendored
Normal 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
1
web/dist/assets/TodoView-BN3VasB8.css
vendored
Normal 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
1
web/dist/assets/TodoView-scAhY5bP.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/UserSettingsView-CDfYoYne.js
vendored
Normal file
1
web/dist/assets/UserSettingsView-CDfYoYne.js
vendored
Normal 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
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
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
13
web/dist/index.html
vendored
Normal 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
1
web/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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
1545
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
web/package.json
Normal file
22
web/package.json
Normal 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
50
web/src/App.vue
Normal 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>
|
||||
22
web/src/components/PaginationControl.vue
Normal file
22
web/src/components/PaginationControl.vue
Normal 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>
|
||||
72
web/src/components/ToastContainer.vue
Normal file
72
web/src/components/ToastContainer.vue
Normal 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>
|
||||
111
web/src/components/TodoEditorModal.vue
Normal file
111
web/src/components/TodoEditorModal.vue
Normal 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>
|
||||
48
web/src/composables/useToast.ts
Normal file
48
web/src/composables/useToast.ts
Normal 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
181
web/src/i18n.ts
Normal 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
6
web/src/main.ts
Normal 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
42
web/src/router.ts
Normal 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
76
web/src/services/api.ts
Normal 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
22
web/src/stores/session.ts
Normal 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
437
web/src/style.css
Normal 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
16
web/src/types.ts
Normal 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
|
||||
}
|
||||
65
web/src/views/LoginView.vue
Normal file
65
web/src/views/LoginView.vue
Normal 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>
|
||||
40
web/src/views/TeamSettingsView.vue
Normal file
40
web/src/views/TeamSettingsView.vue
Normal 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>
|
||||
25
web/src/views/TeamView.vue
Normal file
25
web/src/views/TeamView.vue
Normal 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
214
web/src/views/TodoView.vue
Normal 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>
|
||||
41
web/src/views/UserSettingsView.vue
Normal file
41
web/src/views/UserSettingsView.vue
Normal 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>
|
||||
267
web/styles.css
267
web/styles.css
@@ -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
17
web/tsconfig.json
Normal 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
9
web/tsconfig.node.json
Normal 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
9
web/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user