feat(frontend): bell, chat, uploads, glass, teams UI, fixes
- Notification bell with invites (accept/decline refreshes workspaces) and unread badge (polling + socket push); team chat view realtime - Avatar/background uploads in settings + glassmorphism widgets toggle; background layer resolves /uploads URLs; full-bleed blurred backdrop - Sidebar: visible creation errors, working sub-pages (parent_id), TEAM badge workspaces, refresh button, chat nav; admin creates users - Tasks: per-team boards, assignee select with avatars; admin tabs for users/teams/groups (team workspaces, permission checkboxes) - Register view removed (admin-only accounts); login localized; prettier emoji set; scaling/overflow + mobile fixes
This commit is contained in:
@@ -15,7 +15,14 @@ light/dark mode. Self-hostable via Node.js or Docker Compose.
|
||||
- **Pages** — Nested pages (`parent_id`) in workspaces or **books**, icon + cover,
|
||||
ordering, TipTap rich-text with **tables + checklists**, stored as JSON + HTML.
|
||||
- **Books** 📚 — Independent containers for pages, collapsible in the sidebar.
|
||||
- **Tasks** ✅ — Kanban (todo/doing/done), priorities, deadlines, links to books/pages.
|
||||
- **Tasks** ✅ — Kanban (todo/doing/doing→done), priorities, deadlines, **assignees**,
|
||||
links to books/pages, **per-team boards**.
|
||||
- **Teams** 👥 — Teams with managers/members, **own workspaces** (`TEAM_"name"` badge),
|
||||
permission **groups** (e.g. Team Creators: teams.create/edit/invite, workspace.invite).
|
||||
- **Invites + notifications** 🔔 — Pending invites with accept/decline, bell with unread
|
||||
badge (polling + realtime push), notifications for invites and chat messages.
|
||||
- **Team chat** 💬 — Realtime workspace channel via Socket.IO.
|
||||
- **Uploads** 📤 — Avatar/background images uploadable (served from `/uploads`).
|
||||
- **Calendar** 🗓️ — Deadlines from tasks appear automatically.
|
||||
- **Time tracking** ⏱️ — Top-right timer, entries linkable to books, history list.
|
||||
- **Settings window** ⚙️ — Profile (name, email, avatar, password), appearance
|
||||
|
||||
+19
-1
@@ -84,7 +84,25 @@ Content fields: `content_json` (TipTap JSON), `content_html` (rendered HTML).
|
||||
## Admin (requires `is_admin`)
|
||||
|
||||
- `GET /admin/overview` → counts + recent users
|
||||
- `GET /admin/users`, `PATCH /admin/users/:id {is_admin}`, `DELETE /admin/users/:id`
|
||||
- `GET /admin/users`, `POST /admin/users {name,email,password,is_admin?}` (admins create
|
||||
accounts; public registration is disabled once installed), `PATCH /admin/users/:id`,
|
||||
`DELETE /admin/users/:id`
|
||||
|
||||
## Invites & notifications
|
||||
|
||||
- `POST /workspaces/:id/members {email,role}` → pending invite + notification
|
||||
(workspace admin+ or `workspace.invite` permission)
|
||||
- `GET /invites/mine`, `POST /invites/:id/accept`, `POST /invites/:id/decline`
|
||||
- `GET /notifications/mine?unread=1` → `{ notifications, unread }`
|
||||
- `PATCH /notifications/:id/read`, `POST /notifications/read-all`
|
||||
|
||||
## Chat / teams / groups / upload
|
||||
|
||||
- `GET /chat/:workspaceId`, `POST /chat/:workspaceId {body}`, `DELETE /chat/message/:id`
|
||||
- `GET /teams/by-workspace/:id`, `POST /teams`, `PATCH/DELETE /teams/:id`,
|
||||
team members CRUD, `POST /teams/:id/workspace {name}` (team workspace + badge)
|
||||
- `GET /groups/by-workspace/:id` (seeds `Team Creators`), groups CRUD + members
|
||||
- `POST /upload` (multipart image ≤5MB) → `{ url: /uploads/... }`
|
||||
|
||||
## Health
|
||||
|
||||
|
||||
+15
-6
@@ -4,21 +4,29 @@ import { useAuthStore } from './stores/auth';
|
||||
import { useThemeStore } from './stores/theme';
|
||||
import { useLocaleStore } from './stores/locale';
|
||||
import { useUiStore } from './stores/ui';
|
||||
import { fileUrl } from './files';
|
||||
import SettingsWindow from './components/SettingsWindow.vue';
|
||||
import TimeTrackerButton from './components/TimeTrackerButton.vue';
|
||||
import Bell from './components/Bell.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const theme = useThemeStore();
|
||||
const locale = useLocaleStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const bgRaw = computed(() => auth.user?.bgImage || '');
|
||||
const bgIsImage = computed(() => /^(https?:\/\/|\/uploads|data:)/.test(bgRaw.value));
|
||||
const bgStyle = computed(() => {
|
||||
const bg = auth.user?.bgImage || '';
|
||||
if (!bg) return {};
|
||||
const style = { backgroundImage: bg.startsWith('http') ? `url("${bg}")` : bg };
|
||||
if (auth.user?.bgBlur !== false) style.filter = 'blur(14px) brightness(0.85)';
|
||||
if (!bgRaw.value) return {};
|
||||
const style = {
|
||||
backgroundImage: bgIsImage.value ? `url("${fileUrl(bgRaw.value)}")` : bgRaw.value,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
};
|
||||
if (auth.user?.bgBlur !== false) style.filter = 'blur(16px) brightness(0.9)';
|
||||
return style;
|
||||
});
|
||||
const glassOn = computed(() => !!bgRaw.value && auth.user?.bgGlass !== false);
|
||||
|
||||
onMounted(() => {
|
||||
locale.set(locale.locale);
|
||||
@@ -28,9 +36,10 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="bgStyle.backgroundImage" class="bg-layer" :style="bgStyle"></div>
|
||||
<div :class="{ 'has-bg': !!bgRaw, glass: glassOn }">
|
||||
<div v-if="bgRaw" class="bg-layer" :style="bgStyle"></div>
|
||||
<div v-if="auth.isLoggedIn" class="top-right">
|
||||
<Bell />
|
||||
<TimeTrackerButton />
|
||||
<button class="ghost icon-btn" @click="ui.openSettings()" title="⚙️">⚙️</button>
|
||||
</div>
|
||||
|
||||
@@ -239,9 +239,90 @@ input:focus, select:focus { outline: 2px solid var(--accent); border-color: tran
|
||||
.toolbar button { padding: 5px 10px; font-size: 13px; }
|
||||
.comment { border-top: 1px solid var(--border); padding: 8px 0; }
|
||||
|
||||
/* ---- Glassmorphism over background image ---- */
|
||||
.has-bg .layout { background: transparent; }
|
||||
.glass .sidebar,
|
||||
.glass .card,
|
||||
.glass .greeting,
|
||||
.glass .clock,
|
||||
.glass .kanban-col,
|
||||
.glass .cal-cell {
|
||||
background: color-mix(in srgb, var(--card) 72%, transparent);
|
||||
backdrop-filter: blur(18px) saturate(1.25);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.25);
|
||||
}
|
||||
|
||||
/* ---- Bell 🔔 ---- */
|
||||
.bell { position: relative; }
|
||||
.bell-btn { position: relative; }
|
||||
.badge {
|
||||
position: absolute; top: -6px; right: -6px;
|
||||
background: #ef4444; color: #fff; font-size: 11px; font-weight: 800;
|
||||
border-radius: 12px; padding: 1px 6px; min-width: 18px; text-align: center;
|
||||
}
|
||||
.bell-pop {
|
||||
position: absolute; right: 0; top: 44px; width: 330px; z-index: 70;
|
||||
max-height: 60vh; overflow-y: auto; display: grid; gap: 8px;
|
||||
}
|
||||
.invite-card { padding: 10px; }
|
||||
.notif { padding: 8px; border-radius: 8px; cursor: pointer; border-top: 1px solid var(--border); }
|
||||
.notif.unread { background: var(--accent-soft); }
|
||||
.notif:hover { background: var(--bg-soft); }
|
||||
|
||||
/* ---- Chat 💬 ---- */
|
||||
.chat-main { display: flex; flex-direction: column; }
|
||||
.chat-box {
|
||||
flex: 1; min-height: 50vh; max-height: calc(100vh - 220px);
|
||||
overflow-y: auto; display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.chat-msg { display: flex; gap: 8px; align-items: flex-start; }
|
||||
.chat-bubble {
|
||||
background: var(--bg-soft); border: 1px solid var(--border);
|
||||
border-radius: 4px 14px 14px 14px; padding: 8px 12px; max-width: 75%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.chat-msg.mine { flex-direction: row-reverse; }
|
||||
.chat-msg.mine .chat-bubble { border-radius: 14px 4px 14px 14px; background: var(--accent-soft); }
|
||||
.chat-meta { margin-bottom: 2px; }
|
||||
|
||||
/* ---- Admin / teams / groups ---- */
|
||||
.admin-tabs { margin-bottom: 14px; }
|
||||
.team-tabs { margin-bottom: 12px; }
|
||||
.team-pill { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; border: none; }
|
||||
.perm-check { font-size: 13px; }
|
||||
.perm-check code {
|
||||
background: var(--bg-soft); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 1px 6px; font-size: 12px;
|
||||
}
|
||||
.side-error {
|
||||
background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca;
|
||||
border-radius: 10px; padding: 8px 10px; font-size: 13px; margin: 0;
|
||||
}
|
||||
[data-theme='dark'] .side-error { background: #3f1d1d; color: #fca5a5; border-color: #7f2d2d; }
|
||||
.ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ---- Scaling / overflow fixes ---- */
|
||||
.main { min-width: 0; }
|
||||
.row > input, .row > select { min-width: 0; flex: 1; }
|
||||
.row > button { flex-shrink: 0; }
|
||||
.sidebar select, .sidebar input { min-width: 0; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.data-table { min-width: 560px; }
|
||||
img { max-width: 100%; }
|
||||
.topbar { flex-wrap: wrap; }
|
||||
input[type='file'] { padding: 4px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.kanban { grid-template-columns: 1fr; }
|
||||
.sidebar { width: 240px; min-width: 240px; }
|
||||
.settings-body { flex-direction: column; }
|
||||
.settings-tabs { flex-direction: row; border-right: none; border-bottom: 1px solid var(--border); overflow-x: auto; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.layout { flex-direction: column; }
|
||||
.sidebar { width: 100%; min-width: 0; max-height: none; position: static; }
|
||||
.main { padding: 16px; }
|
||||
.top-right { top: 8px; right: 8px; }
|
||||
.clock { font-size: 20px; }
|
||||
.greeting { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { fileUrl } from '../files';
|
||||
const props = defineProps({ name: String, avatar: String, size: { type: Number, default: 26 } });
|
||||
const isUrl = computed(() => (props.avatar || '').startsWith('http'));
|
||||
const src = computed(() => {
|
||||
const a = props.avatar || '';
|
||||
if (a.startsWith('http') || a.startsWith('/uploads') || a.startsWith('data:')) {
|
||||
return fileUrl(a);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const letter = computed(() => ((props.name || props.avatar || '?')[0] || '?').toUpperCase());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="avatar" :style="{ width: size + 'px', height: size + 'px', fontSize: Math.round(size * 0.5) + 'px' }" :title="name">
|
||||
<img v-if="isUrl" :src="avatar" :alt="name" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover" />
|
||||
<img v-if="src" :src="src" :alt="name" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover" />
|
||||
<span v-else-if="avatar">{{ avatar }}</span>
|
||||
<span v-else>{{ letter }}</span>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { getSocket } from '../socket';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import { useWorkspaceStore } from '../stores/workspace';
|
||||
|
||||
const L = useLocaleStore();
|
||||
const ws = useWorkspaceStore();
|
||||
const router = useRouter();
|
||||
|
||||
const open = ref(false);
|
||||
const invites = ref([]);
|
||||
const notifs = ref([]);
|
||||
const unread = ref(0);
|
||||
let timer = null;
|
||||
|
||||
const total = computed(() => invites.value.length + unread.value);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await api.get('/invites/mine');
|
||||
invites.value = data.invites;
|
||||
const n = await api.get('/notifications/mine');
|
||||
notifs.value = n.data.notifications;
|
||||
unread.value = n.data.unread;
|
||||
} catch { /* offline */ }
|
||||
}
|
||||
|
||||
async function accept(inv) {
|
||||
await api.post(`/invites/${inv.id}/accept`);
|
||||
invites.value = invites.value.filter((i) => i.id !== inv.id);
|
||||
await ws.fetchWorkspaces();
|
||||
if (ws.currentId) {
|
||||
await ws.fetchBooks();
|
||||
await ws.fetchPages();
|
||||
}
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
async function decline(inv) {
|
||||
await api.post(`/invites/${inv.id}/decline`);
|
||||
invites.value = invites.value.filter((i) => i.id !== inv.id);
|
||||
}
|
||||
|
||||
async function markAll() {
|
||||
await api.post('/notifications/read-all');
|
||||
notifs.value.forEach((n) => (n.is_read = true));
|
||||
unread.value = 0;
|
||||
}
|
||||
|
||||
async function markOne(n) {
|
||||
await api.patch(`/notifications/${n.id}/read`);
|
||||
n.is_read = true;
|
||||
unread.value = Math.max(0, unread.value - 1);
|
||||
}
|
||||
|
||||
function onPush() {
|
||||
load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
timer = setInterval(load, 20000);
|
||||
const s = getSocket();
|
||||
s?.on('notification:new', onPush);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer);
|
||||
getSocket()?.off('notification:new', onPush);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bell">
|
||||
<button class="ghost icon-btn bell-btn" @click="open = !open" :title="L.t('bell.title')">
|
||||
🔔<span v-if="total" class="badge">{{ total }}</span>
|
||||
</button>
|
||||
<div v-if="open" class="card bell-pop">
|
||||
<h4 style="margin: 0 0 8px">🔔 {{ L.t('bell.title') }}</h4>
|
||||
<div v-if="invites.length">
|
||||
<div class="side-label">{{ L.t('bell.invites') }}</div>
|
||||
<div v-for="inv in invites" :key="inv.id" class="card invite-card">
|
||||
<strong>{{ inv.workspace_name }}</strong>
|
||||
<div class="muted">{{ L.t('bell.as') }} {{ inv.role }} · {{ inv.inviter_name || '?' }}</div>
|
||||
<div class="row" style="margin-top: 6px">
|
||||
<button class="mini" @click="accept(inv)">✓ {{ L.t('bell.accept') }}</button>
|
||||
<button class="ghost mini" @click="decline(inv)">✕ {{ L.t('bell.decline') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-label">{{ L.t('bell.notifs') }}</div>
|
||||
<div v-if="!notifs.length" class="muted">{{ L.t('bell.empty') }}</div>
|
||||
<div
|
||||
v-for="n in notifs.slice(0, 12)" :key="n.id"
|
||||
class="notif" :class="{ unread: !n.is_read }" @click="markOne(n)"
|
||||
>
|
||||
<strong>{{ n.title }}</strong>
|
||||
<div class="muted">{{ n.body }}</div>
|
||||
</div>
|
||||
<button v-if="unread" class="ghost mini" style="margin-top: 6px" @click="markAll">
|
||||
{{ L.t('bell.readAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,10 +12,10 @@ onUnmounted(() => clearInterval(timer));
|
||||
|
||||
const period = computed(() => {
|
||||
const h = now.value.getHours();
|
||||
if (h >= 5 && h < 11) return { key: 'greet.morning', emoji: '🌅' };
|
||||
if (h >= 11 && h < 14) return { key: 'greet.midday', emoji: '🍱' };
|
||||
if (h >= 14 && h < 18) return { key: 'greet.afternoon', emoji: '☀️' };
|
||||
if (h >= 18 && h < 23) return { key: 'greet.evening', emoji: '🌇' };
|
||||
if (h >= 5 && h < 11) return { key: 'greet.morning', emoji: '☀️' };
|
||||
if (h >= 11 && h < 14) return { key: 'greet.midday', emoji: '☕' };
|
||||
if (h >= 14 && h < 18) return { key: 'greet.afternoon', emoji: '🌤️' };
|
||||
if (h >= 18 && h < 23) return { key: 'greet.evening', emoji: '🌆' };
|
||||
return { key: 'greet.night', emoji: '🌙' };
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ async function invite() {
|
||||
if (!target) return;
|
||||
try {
|
||||
await api.post(`/workspaces/${props.workspaceId}/members`, { email: target, role: role.value });
|
||||
msg.value = `🎉 ${target} → ${role.value}`;
|
||||
msg.value = `✨ ${target} → ${role.value}`;
|
||||
email.value = '';
|
||||
picked.value = null;
|
||||
emit('invited');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import api from '../api';
|
||||
import { uploadImage } from '../files';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import { useThemeStore } from '../stores/theme';
|
||||
@@ -19,7 +20,9 @@ const msg = ref('');
|
||||
const err = ref('');
|
||||
const bgUrl = ref('');
|
||||
const bgBlur = ref(true);
|
||||
const bgGlass = ref(true);
|
||||
const bgPreset = ref('none');
|
||||
const uploading = ref('');
|
||||
|
||||
const GRADIENTS = {
|
||||
none: '',
|
||||
@@ -42,6 +45,7 @@ watch(
|
||||
([, v]) => v && v === auth.user.bgImage,
|
||||
)?.[0] || (auth.user.bgImage ? 'custom' : 'none');
|
||||
bgBlur.value = auth.user.bgBlur !== false;
|
||||
bgGlass.value = auth.user.bgGlass !== false;
|
||||
msg.value = '';
|
||||
err.value = '';
|
||||
}
|
||||
@@ -106,13 +110,42 @@ async function saveBackground() {
|
||||
msg.value = '';
|
||||
err.value = '';
|
||||
try {
|
||||
const { data } = await api.patch('/users/me', { bg_image: bgValue(), bg_blur: bgBlur.value });
|
||||
const { data } = await api.patch('/users/me', {
|
||||
bg_image: bgValue(), bg_blur: bgBlur.value, bg_glass: bgGlass.value,
|
||||
});
|
||||
persistUser(data.user);
|
||||
msg.value = L.t('set.saved');
|
||||
} catch (e) {
|
||||
err.value = e.response?.data?.error || 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAvatar(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploading.value = 'avatar';
|
||||
try {
|
||||
form.avatar = await uploadImage(file);
|
||||
} catch {
|
||||
err.value = 'Upload failed';
|
||||
} finally {
|
||||
uploading.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBg(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploading.value = 'bg';
|
||||
try {
|
||||
bgUrl.value = await uploadImage(file);
|
||||
bgPreset.value = 'custom';
|
||||
} catch {
|
||||
err.value = 'Upload failed';
|
||||
} finally {
|
||||
uploading.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -146,6 +179,7 @@ async function saveBackground() {
|
||||
<div class="emoji-row">
|
||||
<span v-for="e in EMOJIS" :key="e" @click="form.avatar = e">{{ e }}</span>
|
||||
</div>
|
||||
<label class="row"><input type="file" accept="image/*" style="width: auto" @change="uploadAvatar" /> {{ uploading === 'avatar' ? '⏳' : L.t('set.upload') }}</label>
|
||||
<label>{{ L.t('set.name') }}</label>
|
||||
<input v-model="form.name" />
|
||||
<label>{{ L.t('set.email') }}</label>
|
||||
@@ -190,8 +224,10 @@ async function saveBackground() {
|
||||
</div>
|
||||
<label>{{ L.t('set.bgUrl') }}</label>
|
||||
<input v-model="bgUrl" placeholder="https://…" @input="bgPreset = 'custom'" />
|
||||
<label class="row"><input type="file" accept="image/*" style="width: auto" @change="uploadBg" /> {{ uploading === 'bg' ? '⏳' : L.t('set.upload') }}</label>
|
||||
<p class="muted">{{ L.t('set.bgUrlHint') }}</p>
|
||||
<label class="row"><input type="checkbox" v-model="bgBlur" style="width: auto" /> {{ L.t('set.bgBlur') }}</label>
|
||||
<label class="row"><input type="checkbox" v-model="bgGlass" style="width: auto" /> {{ L.t('set.bgGlass') }}</label>
|
||||
<button @click="saveBackground">{{ L.t('set.save') }}</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const newPage = ref('');
|
||||
const newBook = ref('');
|
||||
const search = ref('');
|
||||
const results = ref([]);
|
||||
const sideErr = ref('');
|
||||
const collapsed = ref(JSON.parse(localStorage.getItem('collapsedBooks') || '{}'));
|
||||
|
||||
function toggleBook(id) {
|
||||
@@ -28,9 +29,19 @@ function toggleBook(id) {
|
||||
localStorage.setItem('collapsedBooks', JSON.stringify(collapsed.value));
|
||||
}
|
||||
|
||||
function fail(e, fallback) {
|
||||
sideErr.value = e.response?.data?.error || fallback || 'Error';
|
||||
setTimeout(() => (sideErr.value = ''), 4000);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
await ws.fetchWorkspaces();
|
||||
await ws.fetchBooks();
|
||||
await ws.fetchPages();
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function switchWs(id) {
|
||||
@@ -40,39 +51,60 @@ async function switchWs(id) {
|
||||
|
||||
async function createWs() {
|
||||
if (!newWs.value.trim()) return;
|
||||
try {
|
||||
await ws.createWorkspace(newWs.value.trim());
|
||||
newWs.value = '';
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function createPage(bookId = null) {
|
||||
async function createPage(bookId = null, parentId = null) {
|
||||
try {
|
||||
const title = newPage.value.trim() || 'Untitled';
|
||||
const { data } = await api.post('/pages', {
|
||||
workspace_id: ws.currentId, book_id: bookId, title,
|
||||
workspace_id: ws.currentId, book_id: bookId, parent_id: parentId, title,
|
||||
});
|
||||
newPage.value = '';
|
||||
await ws.fetchPages();
|
||||
router.push(`/page/${data.page.id}`);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function createBook() {
|
||||
if (!newBook.value.trim()) return;
|
||||
try {
|
||||
await ws.createBook(newBook.value.trim(), '📚');
|
||||
newBook.value = '';
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBook(id) {
|
||||
if (!confirm('🗑️?')) return;
|
||||
try {
|
||||
await api.delete(`/books/${id}`);
|
||||
await refresh();
|
||||
await ws.fetchBooks();
|
||||
await ws.fetchPages();
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
if (!search.value.trim()) { results.value = []; return; }
|
||||
try {
|
||||
const { data } = await api.get('/pages/search/query', {
|
||||
params: { q: search.value, workspace_id: ws.currentId },
|
||||
});
|
||||
results.value = data.results;
|
||||
} catch {
|
||||
results.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
@@ -87,13 +119,20 @@ onMounted(refresh);
|
||||
<aside class="sidebar">
|
||||
<div class="user-chip">
|
||||
<Avatar :name="auth.user?.name" :avatar="auth.user?.avatar" :size="32" />
|
||||
<strong style="flex: 1">{{ auth.user?.name }}</strong>
|
||||
<strong style="flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis">{{ auth.user?.name }}</strong>
|
||||
<button class="ghost icon-btn" @click="ui.openSettings()" title="⚙️">⚙️</button>
|
||||
</div>
|
||||
|
||||
<select :value="ws.currentId" @change="switchWs($event.target.value)">
|
||||
<option v-for="w in ws.workspaces" :key="w.id" :value="w.id">{{ w.name }} ({{ w.role }})</option>
|
||||
<p v-if="sideErr" class="side-error">{{ sideErr }}</p>
|
||||
|
||||
<div class="row">
|
||||
<select :value="ws.currentId" @change="switchWs($event.target.value)" style="flex: 1; min-width: 0">
|
||||
<option v-for="w in ws.workspaces" :key="w.id" :value="w.id">
|
||||
{{ w.name }}{{ w.team_name ? ` · TEAM_${w.team_name}` : '' }} ({{ w.role }})
|
||||
</option>
|
||||
</select>
|
||||
<button class="ghost icon-btn" @click="refresh" :title="L.t('side.refresh')">⟳</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input v-model="newWs" :placeholder="L.t('side.newWorkspace')" @keyup.enter="createWs" />
|
||||
<button @click="createWs">+</button>
|
||||
@@ -104,6 +143,7 @@ onMounted(refresh);
|
||||
<router-link to="/tasks">✅ {{ L.t('nav.tasks') }}</router-link>
|
||||
<router-link to="/calendar">🗓️ {{ L.t('nav.calendar') }}</router-link>
|
||||
<router-link to="/time">⏱️ {{ L.t('nav.time') }}</router-link>
|
||||
<router-link to="/chat">💬 {{ L.t('nav.chat') }}</router-link>
|
||||
<router-link v-if="auth.user?.isAdmin" to="/admin">🛡️ {{ L.t('nav.admin') }}</router-link>
|
||||
</nav>
|
||||
|
||||
@@ -121,28 +161,30 @@ onMounted(refresh);
|
||||
</div>
|
||||
<div v-for="b in ws.books" :key="b.id" class="book">
|
||||
<div class="tree-item book-head" @click="toggleBook(b.id)">
|
||||
<span>{{ collapsed[b.id] ? '▶' : '▼' }} {{ b.icon }} {{ b.title }}</span>
|
||||
<span>
|
||||
<button class="ghost mini" @click.stop="createPage(b.id)" title="+">+</button>
|
||||
<span class="ellipsis">{{ collapsed[b.id] ? '▶' : '▼' }} {{ b.icon }} {{ b.title }}</span>
|
||||
<span class="row">
|
||||
<button class="ghost mini" @click.stop="createPage(b.id, null)" title="+">+</button>
|
||||
<button class="ghost mini" @click.stop="deleteBook(b.id)" title="🗑️">🗑️</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!collapsed[b.id]" class="book-pages">
|
||||
<PageTree
|
||||
:nodes="ws.treeFor(b.id)" :active-id="activePageId"
|
||||
@open="router.push(`/page/${$event}`)" @add-child="createPage(b.id)"
|
||||
@open="router.push(`/page/${$event}`)"
|
||||
@add-child="createPage(b.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-label">{{ L.t('side.pages') }}</div>
|
||||
<div class="row">
|
||||
<input v-model="newPage" :placeholder="L.t('side.newPagePh')" @keyup.enter="createPage()" />
|
||||
<button @click="createPage()">+</button>
|
||||
<input v-model="newPage" :placeholder="L.t('side.newPagePh')" @keyup.enter="createPage(null, null)" />
|
||||
<button @click="createPage(null, null)">+</button>
|
||||
</div>
|
||||
<PageTree
|
||||
:nodes="ws.tree()" :active-id="activePageId"
|
||||
@open="router.push(`/page/${$event}`)" @add-child="createPage()"
|
||||
@open="router.push(`/page/${$event}`)"
|
||||
@add-child="createPage(null, $event)"
|
||||
/>
|
||||
|
||||
<div class="side-footer">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Resolve avatar/background paths: absolute URLs pass through,
|
||||
// relative /uploads/... paths are prefixed with the backend origin.
|
||||
export function fileUrl(path) {
|
||||
if (!path) return '';
|
||||
if (/^https?:\/\//i.test(path) || path.startsWith('data:') || path.startsWith('linear-gradient')) {
|
||||
return path;
|
||||
}
|
||||
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
||||
const origin = apiBase.replace(/\/api\/?$/, '');
|
||||
return `${origin}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export async function uploadImage(file) {
|
||||
const { default: api } = await import('./api');
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await api.post('/upload', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return data.url; // relative /uploads/... path
|
||||
}
|
||||
+32
-4
@@ -1,12 +1,37 @@
|
||||
export default {
|
||||
nav: {
|
||||
dashboard: 'Dashboard', tasks: 'Aufgaben', calendar: 'Kalender', time: 'Zeiterfassung',
|
||||
admin: 'Admin', logout: 'Abmelden',
|
||||
admin: 'Admin', logout: 'Abmelden', chat: 'Chat',
|
||||
},
|
||||
side: {
|
||||
workspaces: 'Workspaces', newWorkspace: 'Neuer Workspace', searchPh: 'Seiten suchen …',
|
||||
newPagePh: 'Neue Seite', newBookPh: 'Neues Book', books: '📚 Books', pages: '📄 Seiten',
|
||||
members: 'Mitglieder', role: 'Rolle',
|
||||
members: 'Mitglieder', role: 'Rolle', refresh: 'Aktualisieren', teamBadge: 'Team',
|
||||
},
|
||||
auth: {
|
||||
title: 'Willkommen zurück', sub: 'Melde dich an.',
|
||||
email: 'E-Mail', password: 'Passwort', btn: 'Anmelden',
|
||||
locked: '🔒 Accounts werden vom Admin erstellt.',
|
||||
},
|
||||
bell: {
|
||||
title: 'Mitteilungen', invites: 'Einladungen', notifs: 'Neuigkeiten',
|
||||
empty: 'Alles gelesen. ✨', as: 'als', accept: 'Annehmen', decline: 'Ablehnen',
|
||||
readAll: 'Alle als gelesen markieren',
|
||||
},
|
||||
chat: {
|
||||
title: 'Team-Chat', ph: 'Nachricht schreiben …', send: 'Senden',
|
||||
empty: 'Noch keine Nachrichten. Sag Hallo! 👋',
|
||||
},
|
||||
teams: {
|
||||
title: 'Teams', new: 'Neues Team', ph: 'Teamname …',
|
||||
members: 'Mitglieder', makeWs: 'Workspace für Team erstellen',
|
||||
wsName: 'Workspace-Name …', manager: 'Manager', member: 'Mitglied',
|
||||
addMember: 'Hinzufügen', pickUser: 'User wählen …',
|
||||
noTeams: 'Noch keine Teams. Erstelle eins! 🚀',
|
||||
},
|
||||
groups: {
|
||||
title: 'Gruppen & Rechte', new: 'Neue Gruppe', ph: 'Gruppenname …',
|
||||
members: 'Mitglieder', save: 'Speichern', perms: 'Rechte',
|
||||
},
|
||||
greet: {
|
||||
morning: 'Guten Morgen', midday: 'Mahlzeit', afternoon: 'Guten Tag',
|
||||
@@ -22,14 +47,16 @@ export default {
|
||||
save: 'Speichern', saved: 'Gespeichert ✅', theme: 'Farbschema',
|
||||
light: '☀️ Hell', dark: '🌙 Dunkel', system: '🖥️ System',
|
||||
bgUrl: 'Hintergrundbild-URL', bgUrlHint: 'Leer lassen = kein Bild. Oder Preset wählen:',
|
||||
bgBlur: 'Hintergrund weichzeichnen (Blur)', close: 'Schließen',
|
||||
bgBlur: 'Hintergrund weichzeichnen (Blur)', bgGlass: 'Widgets als Glas darstellen',
|
||||
upload: 'Bild hochladen …', close: 'Schließen',
|
||||
presetNone: 'Kein Bild', presetCustom: 'Eigene URL',
|
||||
},
|
||||
task: {
|
||||
title: 'Aufgaben', new: 'Neue Aufgabe', ph: 'Was ist zu tun?',
|
||||
todo: '📝 Offen', doing: '🚧 In Arbeit', done: '✅ Erledigt',
|
||||
todo: '📝 Offen', doing: '⏳ In Arbeit', done: '✅ Erledigt',
|
||||
priority: 'Prio', low: 'Niedrig', normal: 'Normal', high: 'Hoch',
|
||||
due: 'Fällig', noDue: 'Ohne Termin', book: 'Book', page: 'Seite',
|
||||
team: 'Team', assignee: 'Zuständig', allTeams: 'Alle Teams',
|
||||
none: '—', overdue: 'Überfällig!', toTodo: '← Offen', toDoing: 'In Arbeit →',
|
||||
toDone: 'Erledigt →', reopen: '↩ Öffnen',
|
||||
},
|
||||
@@ -47,6 +74,7 @@ export default {
|
||||
workspaces: 'Workspaces', joined: 'Dabei seit', makeAdmin: 'Zum Admin machen',
|
||||
unmake: 'Admin entziehen', del: 'Löschen', confirm: 'Wirklich löschen?',
|
||||
you: '(du)', books: 'Books', pages: 'Seiten', tasks: 'Aufgaben', time: 'Zeiteinträge',
|
||||
createUser: 'Benutzer anlegen', newPw: 'Passwort', create: 'Anlegen', created: 'Angelegt ✅',
|
||||
},
|
||||
c: {
|
||||
save: 'Speichern', cancel: 'Abbrechen', delete: 'Löschen', create: 'Anlegen',
|
||||
|
||||
+32
-4
@@ -1,12 +1,37 @@
|
||||
export default {
|
||||
nav: {
|
||||
dashboard: 'Dashboard', tasks: 'Tasks', calendar: 'Calendar', time: 'Time tracking',
|
||||
admin: 'Admin', logout: 'Log out',
|
||||
admin: 'Admin', logout: 'Log out', chat: 'Chat',
|
||||
},
|
||||
side: {
|
||||
workspaces: 'Workspaces', newWorkspace: 'New workspace', searchPh: 'Search pages …',
|
||||
newPagePh: 'New page', newBookPh: 'New book', books: '📚 Books', pages: '📄 Pages',
|
||||
members: 'Members', role: 'Role',
|
||||
members: 'Members', role: 'Role', refresh: 'Refresh', teamBadge: 'Team',
|
||||
},
|
||||
auth: {
|
||||
title: 'Welcome back', sub: 'Log in to your workspace.',
|
||||
email: 'Email', password: 'Password', btn: 'Log in',
|
||||
locked: '🔒 Accounts are created by an admin.',
|
||||
},
|
||||
bell: {
|
||||
title: 'Notifications', invites: 'Invites', notifs: 'Updates',
|
||||
empty: 'All caught up. ✨', as: 'as', accept: 'Accept', decline: 'Decline',
|
||||
readAll: 'Mark all as read',
|
||||
},
|
||||
chat: {
|
||||
title: 'Team chat', ph: 'Write a message …', send: 'Send',
|
||||
empty: 'No messages yet. Say hi! 👋',
|
||||
},
|
||||
teams: {
|
||||
title: 'Teams', new: 'New team', ph: 'Team name …',
|
||||
members: 'Members', makeWs: 'Create workspace for team',
|
||||
wsName: 'Workspace name …', manager: 'Manager', member: 'Member',
|
||||
addMember: 'Add', pickUser: 'Pick user …',
|
||||
noTeams: 'No teams yet. Create one! 🚀',
|
||||
},
|
||||
groups: {
|
||||
title: 'Groups & permissions', new: 'New group', ph: 'Group name …',
|
||||
members: 'Members', save: 'Save', perms: 'Permissions',
|
||||
},
|
||||
greet: {
|
||||
morning: 'Good morning', midday: 'Enjoy your lunch', afternoon: 'Good afternoon',
|
||||
@@ -22,14 +47,16 @@ export default {
|
||||
save: 'Save', saved: 'Saved ✅', theme: 'Color scheme',
|
||||
light: '☀️ Light', dark: '🌙 Dark', system: '🖥️ System',
|
||||
bgUrl: 'Background image URL', bgUrlHint: 'Empty = no image. Or pick a preset:',
|
||||
bgBlur: 'Blur background', close: 'Close',
|
||||
bgBlur: 'Blur background', bgGlass: 'Glassmorphism widgets',
|
||||
upload: 'Upload image …', close: 'Close',
|
||||
presetNone: 'No image', presetCustom: 'Custom URL',
|
||||
},
|
||||
task: {
|
||||
title: 'Tasks', new: 'New task', ph: 'What needs doing?',
|
||||
todo: '📝 Todo', doing: '🚧 Doing', done: '✅ Done',
|
||||
todo: '📝 Todo', doing: '⏳ Doing', done: '✅ Done',
|
||||
priority: 'Priority', low: 'Low', normal: 'Normal', high: 'High',
|
||||
due: 'Due', noDue: 'No due date', book: 'Book', page: 'Page',
|
||||
team: 'Team', assignee: 'Assignee', allTeams: 'All teams',
|
||||
none: '—', overdue: 'Overdue!', toTodo: '← Todo', toDoing: 'Doing →',
|
||||
toDone: 'Done →', reopen: '↩ Reopen',
|
||||
},
|
||||
@@ -47,6 +74,7 @@ export default {
|
||||
workspaces: 'Workspaces', joined: 'Joined', makeAdmin: 'Make admin',
|
||||
unmake: 'Remove admin', del: 'Delete', confirm: 'Really delete?',
|
||||
you: '(you)', books: 'Books', pages: 'Pages', tasks: 'Tasks', time: 'Time entries',
|
||||
createUser: 'Create user', newPw: 'Password', create: 'Create', created: 'Created ✅',
|
||||
},
|
||||
c: {
|
||||
save: 'Save', cancel: 'Cancel', delete: 'Delete', create: 'Create',
|
||||
|
||||
+32
-4
@@ -1,12 +1,37 @@
|
||||
export default {
|
||||
nav: {
|
||||
dashboard: '仪表盘', tasks: '任务', calendar: '日历', time: '时间记录',
|
||||
admin: '管理', logout: '退出登录',
|
||||
admin: '管理', logout: '退出登录', chat: '聊天',
|
||||
},
|
||||
side: {
|
||||
workspaces: '工作区', newWorkspace: '新建工作区', searchPh: '搜索页面 …',
|
||||
newPagePh: '新建页面', newBookPh: '新建书册', books: '📚 书册', pages: '📄 页面',
|
||||
members: '成员', role: '角色',
|
||||
members: '成员', role: '角色', refresh: '刷新', teamBadge: '团队',
|
||||
},
|
||||
auth: {
|
||||
title: '欢迎回来', sub: '登录到你的工作区。',
|
||||
email: '电子邮件', password: '密码', btn: '登录',
|
||||
locked: '🔒 账号由管理员创建。',
|
||||
},
|
||||
bell: {
|
||||
title: '通知', invites: '邀请', notifs: '动态',
|
||||
empty: '全部已读。✨', as: '以', accept: '接受', decline: '拒绝',
|
||||
readAll: '全部标为已读',
|
||||
},
|
||||
chat: {
|
||||
title: '团队聊天', ph: '写消息 …', send: '发送',
|
||||
empty: '还没有消息,打个招呼吧!👋',
|
||||
},
|
||||
teams: {
|
||||
title: '团队', new: '新建团队', ph: '团队名称 …',
|
||||
members: '成员', makeWs: '为团队创建工作区',
|
||||
wsName: '工作区名称 …', manager: '管理员', member: '成员',
|
||||
addMember: '添加', pickUser: '选择用户 …',
|
||||
noTeams: '还没有团队,创建一个吧!🚀',
|
||||
},
|
||||
groups: {
|
||||
title: '分组与权限', new: '新建分组', ph: '分组名称 …',
|
||||
members: '成员', save: '保存', perms: '权限',
|
||||
},
|
||||
greet: {
|
||||
morning: '早上好', midday: '午餐愉快', afternoon: '下午好',
|
||||
@@ -22,14 +47,16 @@ export default {
|
||||
save: '保存', saved: '已保存 ✅', theme: '配色方案',
|
||||
light: '☀️ 浅色', dark: '🌙 深色', system: '🖥️ 跟随系统',
|
||||
bgUrl: '背景图片链接', bgUrlHint: '留空 = 无背景,或选择预设:',
|
||||
bgBlur: '模糊背景', close: '关闭',
|
||||
bgBlur: '模糊背景', bgGlass: '玻璃拟态组件',
|
||||
upload: '上传图片 …', close: '关闭',
|
||||
presetNone: '无背景', presetCustom: '自定义链接',
|
||||
},
|
||||
task: {
|
||||
title: '任务', new: '新建任务', ph: '要做什么?',
|
||||
todo: '📝 待办', doing: '🚧 进行中', done: '✅ 已完成',
|
||||
todo: '📝 待办', doing: '⏳ 进行中', done: '✅ 已完成',
|
||||
priority: '优先级', low: '低', normal: '中', high: '高',
|
||||
due: '截止', noDue: '无截止日期', book: '书册', page: '页面',
|
||||
team: '团队', assignee: '负责人', allTeams: '全部团队',
|
||||
none: '—', overdue: '已逾期!', toTodo: '← 待办', toDoing: '进行中 →',
|
||||
toDone: '完成 →', reopen: '↩ 重新打开',
|
||||
},
|
||||
@@ -47,6 +74,7 @@ export default {
|
||||
workspaces: '工作区', joined: '加入时间', makeAdmin: '设为管理员',
|
||||
unmake: '取消管理员', del: '删除', confirm: '确认删除?',
|
||||
you: '(你)', books: '书册', pages: '页面', tasks: '任务', time: '时间记录',
|
||||
createUser: '创建用户', newPw: '密码', create: '创建', created: '已创建 ✅',
|
||||
},
|
||||
c: {
|
||||
save: '保存', cancel: '取消', delete: '删除', create: '创建',
|
||||
|
||||
@@ -2,24 +2,24 @@ import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useSetupStore } from '../stores/setup';
|
||||
import Login from '../views/Login.vue';
|
||||
import Register from '../views/Register.vue';
|
||||
import Setup from '../views/Setup.vue';
|
||||
import Dashboard from '../views/Dashboard.vue';
|
||||
import PageEditor from '../views/PageEditor.vue';
|
||||
import Tasks from '../views/Tasks.vue';
|
||||
import Calendar from '../views/Calendar.vue';
|
||||
import Time from '../views/Time.vue';
|
||||
import Chat from '../views/Chat.vue';
|
||||
import Admin from '../views/Admin.vue';
|
||||
|
||||
const routes = [
|
||||
{ path: '/setup', component: Setup },
|
||||
{ path: '/login', component: Login, meta: { guest: true } },
|
||||
{ path: '/register', component: Register, meta: { guest: true } },
|
||||
{ path: '/', component: Dashboard, meta: { auth: true } },
|
||||
{ path: '/page/:id', component: PageEditor, meta: { auth: true } },
|
||||
{ path: '/tasks', component: Tasks, meta: { auth: true } },
|
||||
{ path: '/calendar', component: Calendar, meta: { auth: true } },
|
||||
{ path: '/time', component: Time, meta: { auth: true } },
|
||||
{ path: '/chat', component: Chat, meta: { auth: true } },
|
||||
{ path: '/admin', component: Admin, meta: { auth: true, admin: true } },
|
||||
];
|
||||
|
||||
|
||||
+231
-21
@@ -1,36 +1,152 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import api from '../api';
|
||||
import { useWorkspaceStore } from '../stores/workspace';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import Sidebar from '../components/Sidebar.vue';
|
||||
import Avatar from '../components/Avatar.vue';
|
||||
|
||||
const L = useLocaleStore();
|
||||
const ws = useWorkspaceStore();
|
||||
const tab = ref('overview');
|
||||
|
||||
const counts = ref({});
|
||||
const users = ref([]);
|
||||
const recent = ref([]);
|
||||
const newUser = reactive({ name: '', email: '', password: '', is_admin: false });
|
||||
const userMsg = ref('');
|
||||
|
||||
async function load() {
|
||||
const teams = ref([]);
|
||||
const newTeam = ref('');
|
||||
const teamMembers = ref({});
|
||||
const teamWsName = ref({});
|
||||
const members = ref([]);
|
||||
|
||||
const groups = ref([]);
|
||||
const availPerms = ref([]);
|
||||
const newGroup = ref('');
|
||||
const newPerms = ref([]);
|
||||
const groupMembers = ref({});
|
||||
|
||||
async function loadOverview() {
|
||||
const { data } = await api.get('/admin/overview');
|
||||
counts.value = data.counts;
|
||||
recent.value = data.recentUsers;
|
||||
const u = await api.get('/admin/users');
|
||||
users.value = u.data.users;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const { data } = await api.get('/admin/users');
|
||||
users.value = data.users;
|
||||
}
|
||||
async function createUser() {
|
||||
userMsg.value = '';
|
||||
try {
|
||||
const { data } = await api.post('/admin/users', { ...newUser });
|
||||
users.value.push({ ...data.user, workspaces: 0 });
|
||||
newUser.name = '';
|
||||
newUser.email = '';
|
||||
newUser.password = '';
|
||||
newUser.is_admin = false;
|
||||
userMsg.value = L.t('admin.created');
|
||||
} catch (e) {
|
||||
userMsg.value = e.response?.data?.error || 'Error';
|
||||
}
|
||||
}
|
||||
async function setAdmin(u, val) {
|
||||
const { data } = await api.patch(`/admin/users/${u.id}`, { is_admin: val });
|
||||
u.is_admin = data.user.is_admin;
|
||||
load();
|
||||
}
|
||||
|
||||
async function removeUser(u) {
|
||||
if (!confirm(`${L.t('admin.confirm')} (${u.email})`)) return;
|
||||
await api.delete(`/admin/users/${u.id}`);
|
||||
users.value = users.value.filter((x) => x.id !== u.id);
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
async function loadTeams() {
|
||||
if (!ws.currentId) return;
|
||||
const { data } = await api.get(`/teams/by-workspace/${ws.currentId}`);
|
||||
teams.value = data.teams;
|
||||
const m = await api.get(`/workspaces/${ws.currentId}/members`);
|
||||
members.value = m.data.members;
|
||||
}
|
||||
async function createTeam() {
|
||||
if (!newTeam.value.trim()) return;
|
||||
const { data } = await api.post('/teams', { workspace_id: ws.currentId, name: newTeam.value.trim() });
|
||||
teams.value.push(data.team);
|
||||
newTeam.value = '';
|
||||
}
|
||||
async function removeTeam(t) {
|
||||
if (!confirm('🗑️?')) return;
|
||||
await api.delete(`/teams/${t.id}`);
|
||||
teams.value = teams.value.filter((x) => x.id !== t.id);
|
||||
}
|
||||
async function showTeamMembers(t) {
|
||||
const { data } = await api.get(`/teams/${t.id}/members`);
|
||||
teamMembers.value[t.id] = data.members;
|
||||
}
|
||||
async function addTeamMember(t, userId, role) {
|
||||
if (!userId) return;
|
||||
await api.post(`/teams/${t.id}/members`, { user_id: userId, role });
|
||||
showTeamMembers(t);
|
||||
}
|
||||
async function removeTeamMember(t, userId) {
|
||||
await api.delete(`/teams/${t.id}/members/${userId}`);
|
||||
showTeamMembers(t);
|
||||
}
|
||||
async function makeTeamWs(t) {
|
||||
const name = teamWsName.value[t.id]?.trim();
|
||||
if (!name) return;
|
||||
const { data } = await api.post(`/teams/${t.id}/workspace`, { name });
|
||||
teamWsName.value[t.id] = '';
|
||||
await ws.fetchWorkspaces();
|
||||
alert(`✓ ${data.workspace.name} · TEAM_${t.name}`);
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
if (!ws.currentId) return;
|
||||
const { data } = await api.get(`/groups/by-workspace/${ws.currentId}`);
|
||||
groups.value = data.groups;
|
||||
availPerms.value = data.availablePermissions;
|
||||
}
|
||||
async function createGroup() {
|
||||
if (!newGroup.value.trim()) return;
|
||||
const { data } = await api.post('/groups', {
|
||||
workspace_id: ws.currentId, name: newGroup.value.trim(), permissions: newPerms.value,
|
||||
});
|
||||
groups.value.push(data.group);
|
||||
newGroup.value = '';
|
||||
newPerms.value = [];
|
||||
}
|
||||
async function togglePerm(g, perm) {
|
||||
const perms = new Set(g.permissions || []);
|
||||
if (perms.has(perm)) perms.delete(perm);
|
||||
else perms.add(perm);
|
||||
const { data } = await api.patch(`/groups/${g.id}`, { permissions: [...perms] });
|
||||
g.permissions = data.group.permissions;
|
||||
}
|
||||
async function removeGroup(g) {
|
||||
if (!confirm('🗑️?')) return;
|
||||
await api.delete(`/groups/${g.id}`);
|
||||
groups.value = groups.value.filter((x) => x.id !== g.id);
|
||||
}
|
||||
async function showGroupMembers(g) {
|
||||
const { data } = await api.get(`/groups/${g.id}/members`);
|
||||
groupMembers.value[g.id] = data.members;
|
||||
}
|
||||
async function addGroupMember(g, userId) {
|
||||
if (!userId) return;
|
||||
await api.post(`/groups/${g.id}/members`, { user_id: userId });
|
||||
showGroupMembers(g);
|
||||
}
|
||||
async function removeGroupMember(g, userId) {
|
||||
await api.delete(`/groups/${g.id}/members/${userId}`);
|
||||
showGroupMembers(g);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!ws.workspaces.length) await ws.fetchWorkspaces();
|
||||
await loadOverview();
|
||||
await loadUsers();
|
||||
await loadTeams();
|
||||
await loadGroups();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -38,27 +154,43 @@ onMounted(load);
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
<h2>🛡️ {{ L.t('admin.title') }}</h2>
|
||||
<h3>{{ L.t('admin.overview') }}</h3>
|
||||
<div class="row wrap admin-tabs">
|
||||
<button :class="{ ghost: tab !== 'overview' }" @click="tab = 'overview'">{{ L.t('admin.overview') }}</button>
|
||||
<button :class="{ ghost: tab !== 'users' }" @click="tab = 'users'">{{ L.t('admin.users') }}</button>
|
||||
<button :class="{ ghost: tab !== 'teams' }" @click="tab = 'teams'">👥 {{ L.t('teams.title') }}</button>
|
||||
<button :class="{ ghost: tab !== 'groups' }" @click="tab = 'groups'">🔑 {{ L.t('groups.title') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'overview'">
|
||||
<div class="stat-grid">
|
||||
<div class="card stat">👥<strong>{{ counts.users }}</strong>{{ L.t('admin.users') }}</div>
|
||||
<div class="card stat">🏠<strong>{{ counts.workspaces }}</strong>{{ L.t('nav.dashboard') }}</div>
|
||||
<div class="card stat">🏠<strong>{{ counts.workspaces }}</strong>Workspaces</div>
|
||||
<div class="card stat">📚<strong>{{ counts.books }}</strong>{{ L.t('admin.books') }}</div>
|
||||
<div class="card stat">📄<strong>{{ counts.pages }}</strong>{{ L.t('admin.pages') }}</div>
|
||||
<div class="card stat">✅<strong>{{ counts.tasks }}</strong>{{ L.t('admin.tasks') }}</div>
|
||||
<div class="card stat">⏱️<strong>{{ counts.timeEntries }}</strong>{{ L.t('admin.time') }}</div>
|
||||
</div>
|
||||
<h3>{{ L.t('admin.users') }} ({{ users.length }})</h3>
|
||||
<div class="card">
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'users'">
|
||||
<div class="card" style="margin-bottom: 12px">
|
||||
<h4>+ {{ L.t('admin.createUser') }}</h4>
|
||||
<div class="row wrap">
|
||||
<input v-model="newUser.name" :placeholder="L.t('admin.name')" />
|
||||
<input v-model="newUser.email" type="email" :placeholder="L.t('admin.email')" />
|
||||
<input v-model="newUser.password" type="password" :placeholder="L.t('admin.newPw')" />
|
||||
<label class="row"><input type="checkbox" v-model="newUser.is_admin" style="width: auto" /> {{ L.t('admin.admin') }}</label>
|
||||
<button @click="createUser">{{ L.t('admin.create') }}</button>
|
||||
</div>
|
||||
<p v-if="userMsg" class="muted">{{ userMsg }}</p>
|
||||
</div>
|
||||
<div class="card table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{{ L.t('admin.name') }}</th>
|
||||
<th>{{ L.t('admin.email') }}</th>
|
||||
<th>{{ L.t('admin.role') }}</th>
|
||||
<th>{{ L.t('admin.workspaces') }}</th>
|
||||
<th>{{ L.t('admin.joined') }}</th>
|
||||
<th></th>
|
||||
<th></th><th>{{ L.t('admin.name') }}</th><th>{{ L.t('admin.email') }}</th>
|
||||
<th>{{ L.t('admin.role') }}</th><th>{{ L.t('admin.workspaces') }}</th>
|
||||
<th>{{ L.t('admin.joined') }}</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -78,6 +210,84 @@ onMounted(load);
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'teams'">
|
||||
<div class="card" style="margin-bottom: 12px">
|
||||
<h4>+ {{ L.t('teams.new') }}</h4>
|
||||
<div class="row">
|
||||
<input v-model="newTeam" :placeholder="L.t('teams.ph')" @keyup.enter="createTeam" />
|
||||
<button @click="createTeam">{{ L.t('c.create') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!teams.length" class="muted">{{ L.t('teams.noTeams') }}</p>
|
||||
<div v-for="t in teams" :key="t.id" class="card" style="margin-bottom: 10px">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<strong>{{ t.icon }} {{ t.name }} <span class="muted">({{ t.members }})</span></strong>
|
||||
<button class="ghost mini" @click="removeTeam(t)">🗑️</button>
|
||||
</div>
|
||||
<div class="row wrap" style="margin-top: 8px">
|
||||
<button class="ghost mini" @click="showTeamMembers(t)">👥 {{ L.t('teams.members') }}</button>
|
||||
<input v-model="teamWsName[t.id]" :placeholder="L.t('teams.wsName')" style="max-width: 220px" />
|
||||
<button class="ghost mini" @click="makeTeamWs(t)">🏠 {{ L.t('teams.makeWs') }}</button>
|
||||
</div>
|
||||
<div v-if="teamMembers[t.id]?.length" style="margin-top: 8px">
|
||||
<div v-for="m in teamMembers[t.id]" :key="m.id" class="row member-row">
|
||||
<Avatar :name="m.name" :avatar="m.avatar" :size="24" />
|
||||
<span style="flex: 1">{{ m.name }}</span>
|
||||
<span class="pill">{{ m.role }}</span>
|
||||
<button class="ghost mini" @click="removeTeamMember(t, m.id)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 8px">
|
||||
<select @change="addTeamMember(t, $event.target.value, 'member'); $event.target.value = ''" style="max-width: 200px">
|
||||
<option value="">{{ L.t('teams.pickUser') }}</option>
|
||||
<option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'groups'">
|
||||
<div class="card" style="margin-bottom: 12px">
|
||||
<h4>+ {{ L.t('groups.new') }}</h4>
|
||||
<div class="row wrap">
|
||||
<input v-model="newGroup" :placeholder="L.t('groups.ph')" @keyup.enter="createGroup" style="flex: 1" />
|
||||
<button @click="createGroup">{{ L.t('c.create') }}</button>
|
||||
</div>
|
||||
<div class="row wrap" style="margin-top: 8px">
|
||||
<label v-for="p in availPerms" :key="p" class="row perm-check">
|
||||
<input type="checkbox" :value="p" v-model="newPerms" style="width: auto" /> <code>{{ p }}</code>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="g in groups" :key="g.id" class="card" style="margin-bottom: 10px">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<strong>🔑 {{ g.name }} <span class="muted">({{ g.members }})</span></strong>
|
||||
<button class="ghost mini" @click="removeGroup(g)">🗑️</button>
|
||||
</div>
|
||||
<div class="muted">{{ L.t('groups.perms') }}:</div>
|
||||
<div class="row wrap">
|
||||
<label v-for="p in availPerms" :key="p" class="row perm-check">
|
||||
<input type="checkbox" :checked="(g.permissions || []).includes(p)" @change="togglePerm(g, p)" style="width: auto" /> <code>{{ p }}</code>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 8px">
|
||||
<button class="ghost mini" @click="showGroupMembers(g)">👥 {{ L.t('groups.members') }}</button>
|
||||
<select @change="addGroupMember(g, $event.target.value); $event.target.value = ''" style="max-width: 200px">
|
||||
<option value="">{{ L.t('teams.pickUser') }}</option>
|
||||
<option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="groupMembers[g.id]?.length" style="margin-top: 8px">
|
||||
<div v-for="m in groupMembers[g.id]" :key="m.id" class="row member-row">
|
||||
<Avatar :name="m.name" :avatar="m.avatar" :size="24" />
|
||||
<span style="flex: 1">{{ m.name }}</span>
|
||||
<button class="ghost mini" @click="removeGroupMember(g, m.id)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import api from '../api';
|
||||
import { connectSocket, getSocket } from '../socket';
|
||||
import { useWorkspaceStore } from '../stores/workspace';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import Sidebar from '../components/Sidebar.vue';
|
||||
import Avatar from '../components/Avatar.vue';
|
||||
|
||||
const ws = useWorkspaceStore();
|
||||
const auth = useAuthStore();
|
||||
const L = useLocaleStore();
|
||||
const messages = ref([]);
|
||||
const body = ref('');
|
||||
const box = ref(null);
|
||||
|
||||
function scrollDown() {
|
||||
nextTick(() => {
|
||||
if (box.value) box.value.scrollTop = box.value.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!ws.currentId) return;
|
||||
const { data } = await api.get(`/chat/${ws.currentId}`);
|
||||
messages.value = data.messages;
|
||||
const s = connectSocket();
|
||||
s?.emit('chat:join', { workspaceId: ws.currentId });
|
||||
scrollDown();
|
||||
}
|
||||
|
||||
function onMsg({ message }) {
|
||||
if (message.workspace_id !== ws.currentId) return;
|
||||
messages.value.push(message);
|
||||
scrollDown();
|
||||
}
|
||||
function onDel({ id }) {
|
||||
messages.value = messages.value.filter((m) => m.id !== id);
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = body.value.trim();
|
||||
if (!text) return;
|
||||
body.value = '';
|
||||
await api.post(`/chat/${ws.currentId}`, { body: text });
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
await api.delete(`/chat/message/${id}`);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!ws.workspaces.length) await ws.fetchWorkspaces();
|
||||
await load();
|
||||
const s = getSocket();
|
||||
s?.on('chat:message', onMsg);
|
||||
s?.on('chat:deleted', onDel);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
const s = getSocket();
|
||||
s?.off('chat:message', onMsg);
|
||||
s?.off('chat:deleted', onDel);
|
||||
});
|
||||
watch(() => ws.currentId, load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<Sidebar />
|
||||
<main class="main chat-main">
|
||||
<h2>💬 {{ L.t('chat.title') }}</h2>
|
||||
<div ref="box" class="card chat-box">
|
||||
<div
|
||||
v-for="m in messages" :key="m.id"
|
||||
class="chat-msg" :class="{ mine: m.user_id === auth.user?.id }"
|
||||
>
|
||||
<Avatar :name="m.author_name" :avatar="m.author_avatar" :size="30" />
|
||||
<div class="chat-bubble">
|
||||
<div class="muted chat-meta">
|
||||
<strong>{{ m.author_name || '?' }}</strong> ·
|
||||
{{ new Date(m.created_at).toLocaleString() }}
|
||||
<span v-if="m.user_id === auth.user?.id" class="link" @click="remove(m.id)"> 🗑</span>
|
||||
</div>
|
||||
<div>{{ m.body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!messages.length" class="muted">{{ L.t('chat.empty') }}</p>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 8px">
|
||||
<input v-model="body" :placeholder="L.t('chat.ph')" @keyup.enter="send" />
|
||||
<button @click="send">{{ L.t('chat.send') }}</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,6 +21,7 @@ async function fetchMembers() {
|
||||
}
|
||||
|
||||
const currentName = computed(() => ws.workspaces.find((w) => w.id === ws.currentId)?.name || '');
|
||||
const currentTeam = computed(() => ws.workspaces.find((w) => w.id === ws.currentId)?.team_name || '');
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
@@ -36,7 +37,7 @@ onMounted(async () => {
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
<GreetingHeader :name="auth.user?.name" />
|
||||
<p class="muted">🏠 {{ currentName }} · {{ L.t('side.role') || ws.role }}: <strong>{{ ws.role }}</strong></p>
|
||||
<p class="muted">🏠 {{ currentName }} <span v-if="currentTeam" class="pill team-pill">TEAM_{{ currentTeam }}</span> · {{ L.t('side.role') }}: <strong>{{ ws.role }}</strong></p>
|
||||
<InviteModal v-if="ws.currentId" :workspace-id="ws.currentId" @invited="fetchMembers" />
|
||||
<div class="card" style="margin-top: 12px">
|
||||
<h4>👥 {{ L.t('side.members') }} ({{ members.length }})</h4>
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
|
||||
const L = useLocaleStore();
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
@@ -22,14 +25,14 @@ async function submit() {
|
||||
|
||||
<template>
|
||||
<div class="auth-wrap card">
|
||||
<h1>Welcome back</h1>
|
||||
<p class="muted">Log in to your workspace.</p>
|
||||
<h1>{{ L.t('auth.title') }}</h1>
|
||||
<p class="muted">{{ L.t('auth.sub') }}</p>
|
||||
<form @submit.prevent="submit" style="display: grid; gap: 10px">
|
||||
<input v-model="email" type="email" placeholder="Email" required />
|
||||
<input v-model="password" type="password" placeholder="Password" required />
|
||||
<button type="submit">Login</button>
|
||||
<input v-model="email" type="email" :placeholder="L.t('auth.email')" required />
|
||||
<input v-model="password" type="password" :placeholder="L.t('auth.password')" required />
|
||||
<button type="submit">{{ L.t('auth.btn') }}</button>
|
||||
</form>
|
||||
<p v-if="err" style="color: red">{{ err }}</p>
|
||||
<p class="muted">No account? <router-link to="/register">Register</router-link></p>
|
||||
<p class="muted">{{ L.t('auth.locked') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -158,7 +158,7 @@ onUnmounted(() => {
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">H2</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleBulletList().run()">• {{ L.t('side.pages') }}</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleTaskList().run()">☑️</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run()">▦ Table</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run()">⊞ Table</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleCodeBlock().run()"></></button>
|
||||
<button @click="save()">💾 {{ L.t('c.save') }}</button>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const email = ref('');
|
||||
const name = ref('');
|
||||
const password = ref('');
|
||||
const err = ref('');
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
async function submit() {
|
||||
err.value = '';
|
||||
try {
|
||||
await auth.register(email.value, name.value, password.value);
|
||||
router.push('/');
|
||||
} catch (e) {
|
||||
err.value = e.response?.data?.error || 'Registration failed';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-wrap card">
|
||||
<h1>Create account</h1>
|
||||
<p class="muted">Start your collaborative workspace.</p>
|
||||
<form @submit.prevent="submit" style="display: grid; gap: 10px">
|
||||
<input v-model="name" placeholder="Display name" required />
|
||||
<input v-model="email" type="email" placeholder="Email" required />
|
||||
<input v-model="password" type="password" placeholder="Password (min 8 chars)" required minlength="8" />
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
<p v-if="err" style="color: red">{{ err }}</p>
|
||||
<p class="muted">Have an account? <router-link to="/login">Log in</router-link></p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +1,27 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { useWorkspaceStore } from '../stores/workspace';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import Sidebar from '../components/Sidebar.vue';
|
||||
import Avatar from '../components/Avatar.vue';
|
||||
|
||||
const ws = useWorkspaceStore();
|
||||
const L = useLocaleStore();
|
||||
const router = useRouter();
|
||||
|
||||
const tasks = ref([]);
|
||||
const teams = ref([]);
|
||||
const members = ref([]);
|
||||
const teamFilter = ref('');
|
||||
const title = ref('');
|
||||
const due = ref('');
|
||||
const priority = ref('normal');
|
||||
const bookId = ref('');
|
||||
const pageId = ref('');
|
||||
const teamId = ref('');
|
||||
const assigneeId = ref('');
|
||||
|
||||
const cols = computed(() => ({
|
||||
todo: tasks.value.filter((t) => t.status === 'todo'),
|
||||
@@ -32,10 +38,19 @@ function fmtDate(d) {
|
||||
|
||||
async function load() {
|
||||
if (!ws.currentId) return;
|
||||
const { data } = await api.get(`/tasks/by-workspace/${ws.currentId}`);
|
||||
const params = {};
|
||||
if (teamFilter.value) params.team_id = teamFilter.value;
|
||||
const { data } = await api.get(`/tasks/by-workspace/${ws.currentId}`, { params });
|
||||
tasks.value = data.tasks;
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const { data } = await api.get(`/teams/by-workspace/${ws.currentId}`);
|
||||
teams.value = data.teams;
|
||||
const m = await api.get(`/workspaces/${ws.currentId}/members`);
|
||||
members.value = m.data.members;
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!title.value.trim()) return;
|
||||
const { data } = await api.post('/tasks', {
|
||||
@@ -45,12 +60,16 @@ async function create() {
|
||||
due_at: due.value || null,
|
||||
book_id: bookId.value || null,
|
||||
page_id: pageId.value || null,
|
||||
team_id: teamId.value || null,
|
||||
assignee_id: assigneeId.value || null,
|
||||
});
|
||||
tasks.value.unshift(data.task);
|
||||
title.value = '';
|
||||
due.value = '';
|
||||
bookId.value = '';
|
||||
pageId.value = '';
|
||||
teamId.value = '';
|
||||
assigneeId.value = '';
|
||||
}
|
||||
|
||||
async function setStatus(t, status) {
|
||||
@@ -58,16 +77,27 @@ async function setStatus(t, status) {
|
||||
Object.assign(t, data.task);
|
||||
}
|
||||
|
||||
async function assign(t, userId) {
|
||||
const { data } = await api.patch(`/tasks/${t.id}`, { assignee_id: userId || null });
|
||||
Object.assign(t, data.task);
|
||||
const m = members.value.find((x) => x.id === data.task.assignee_id);
|
||||
t.assignee_name = m?.name || null;
|
||||
t.assignee_avatar = m?.avatar || null;
|
||||
}
|
||||
|
||||
async function remove(t) {
|
||||
if (!confirm('🗑️?')) return;
|
||||
await api.delete(`/tasks/${t.id}`);
|
||||
tasks.value = tasks.value.filter((x) => x.id !== t.id);
|
||||
}
|
||||
|
||||
watch(teamFilter, load);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!ws.workspaces.length) await ws.fetchWorkspaces();
|
||||
await ws.fetchBooks();
|
||||
await ws.fetchPages();
|
||||
await loadMeta();
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
@@ -77,6 +107,16 @@ onMounted(async () => {
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
<h2>✅ {{ L.t('task.title') }}</h2>
|
||||
<div class="row wrap team-tabs">
|
||||
<button :class="{ ghost: teamFilter !== '' }" @click="teamFilter = ''">🌐 {{ L.t('task.allTeams') }}</button>
|
||||
<button
|
||||
v-for="tm in teams" :key="tm.id"
|
||||
:class="{ ghost: teamFilter !== tm.id }"
|
||||
@click="teamFilter = tm.id"
|
||||
>
|
||||
{{ tm.icon }} {{ tm.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="card new-task">
|
||||
<div class="row wrap">
|
||||
<input v-model="title" :placeholder="L.t('task.ph')" @keyup.enter="create" style="flex: 2" />
|
||||
@@ -96,6 +136,14 @@ onMounted(async () => {
|
||||
<option value="">{{ L.t('task.page') }}: {{ L.t('task.none') }}</option>
|
||||
<option v-for="p in ws.pages" :key="p.id" :value="p.id">{{ p.title }}</option>
|
||||
</select>
|
||||
<select v-model="teamId">
|
||||
<option value="">{{ L.t('task.team') }}: {{ L.t('task.none') }}</option>
|
||||
<option v-for="tm in teams" :key="tm.id" :value="tm.id">{{ tm.icon }} {{ tm.name }}</option>
|
||||
</select>
|
||||
<select v-model="assigneeId">
|
||||
<option value="">{{ L.t('task.assignee') }}: {{ L.t('task.none') }}</option>
|
||||
<option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
|
||||
</select>
|
||||
<button @click="create">+ {{ L.t('task.new') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,6 +160,14 @@ onMounted(async () => {
|
||||
<div class="muted">
|
||||
<span v-if="t.book_title">📚 {{ t.book_title }}</span>
|
||||
<span v-if="t.page_title" class="link" @click="router.push(`/page/${t.page_id}`)">📄 {{ t.page_title }}</span>
|
||||
<span v-if="t.team_name" class="pill">👥 {{ t.team_name }}</span>
|
||||
</div>
|
||||
<div class="row wrap">
|
||||
<Avatar v-if="t.assignee_name" :name="t.assignee_name" :avatar="t.assignee_avatar" :size="22" />
|
||||
<select :value="t.assignee_id || ''" @change="assign(t, $event.target.value)" style="max-width: 150px">
|
||||
<option value="">{{ L.t('task.assignee') }}</option>
|
||||
<option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row wrap">
|
||||
<button v-if="t.status !== 'todo'" class="ghost mini" @click="setStatus(t, 'todo')">{{ L.t('task.toTodo') }}</button>
|
||||
|
||||
Reference in New Issue
Block a user