feat(frontend): chat tabs, flat SVG icons, upload autosave, per-user rights
- Chat rework: Chats (private DMs with user search, online dots, unread badges) and Team chats tabs; shared message component with reactions, edit, image attachments, message search and typing indicators - Flat modern inline SVG icon set (no external dependency) replaces emoji chrome across nav, headers, buttons, tabs and settings - Uploaded avatar/background images apply instantly (auto-save) with confirmation note; per-user permission matrix in dashboard members - Login localized, invite header uses icon, comment header cleaned
This commit is contained in:
@@ -5,6 +5,7 @@ import api from '../api';
|
||||
import { getSocket } from '../socket';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import { useWorkspaceStore } from '../stores/workspace';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const L = useLocaleStore();
|
||||
const ws = useWorkspaceStore();
|
||||
@@ -75,10 +76,10 @@ onUnmounted(() => {
|
||||
<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>
|
||||
<Icon name="bell" /><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>
|
||||
<h4 style="margin: 0 0 8px"><Icon name="bell" :size="16" /> {{ 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">
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import api from '../api';
|
||||
import { fileUrl } from '../files';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import Avatar from './Avatar.vue';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: Object,
|
||||
targetType: { type: String, default: 'chat' }, // 'chat' | 'dm'
|
||||
basePath: { type: String, default: 'chat' }, // REST base for edit/delete
|
||||
});
|
||||
const emit = defineEmits(['deleted']);
|
||||
const auth = useAuthStore();
|
||||
const L = useLocaleStore();
|
||||
|
||||
const editing = ref(false);
|
||||
const editText = ref('');
|
||||
const showPicker = ref(false);
|
||||
const FLAT = ['👍', '❤️', '😄', '🎉', '👀', '✅', '🔥', '💡'];
|
||||
|
||||
const mine = (id) => (props.message.reactions || []).some((r) => (r.users || []).includes(id));
|
||||
|
||||
async function toggle(emoji) {
|
||||
showPicker.value = false;
|
||||
const { data } = await api.post('/reactions/toggle', {
|
||||
target_type: props.targetType, target_id: props.message.id, emoji,
|
||||
});
|
||||
props.message.reactions = data.reactions;
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
editText.value = props.message.body;
|
||||
editing.value = true;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editText.value.trim()) return;
|
||||
const { data } = await api.patch(`/${props.basePath}/message/${props.message.id}`, {
|
||||
body: editText.value.trim(),
|
||||
});
|
||||
Object.assign(props.message, data.message);
|
||||
editing.value = false;
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm('✕?')) return;
|
||||
await api.delete(`/${props.basePath}/message/${props.message.id}`);
|
||||
emit('deleted', props.message.id);
|
||||
}
|
||||
|
||||
function attachSrc() {
|
||||
return props.message.attachment ? fileUrl(props.message.attachment) : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-msg" :class="{ mine: message.user_id === auth.user?.id }">
|
||||
<Avatar :name="message.author_name" :avatar="message.author_avatar" :size="32" />
|
||||
<div class="chat-bubble">
|
||||
<div class="muted chat-meta">
|
||||
<strong>{{ message.author_name || '?' }}</strong> ·
|
||||
{{ new Date(message.created_at).toLocaleString() }}
|
||||
<span v-if="message.edited_at" class="muted"> · {{ L.t('chat.edited') }}</span>
|
||||
</div>
|
||||
<div v-if="!editing">{{ message.body }}</div>
|
||||
<div v-else class="row">
|
||||
<input v-model="editText" @keyup.enter="saveEdit" />
|
||||
<button class="mini" @click="saveEdit">✓</button>
|
||||
<button class="ghost mini" @click="editing = false">✕</button>
|
||||
</div>
|
||||
<img v-if="attachSrc()" :src="attachSrc()" class="chat-img" loading="lazy" />
|
||||
<div v-if="(message.reactions || []).length" class="reactions">
|
||||
<span
|
||||
v-for="r in message.reactions" :key="r.emoji"
|
||||
class="reaction" :class="{ mine: mine(auth.user?.id) }"
|
||||
@click="toggle(r.emoji)"
|
||||
>
|
||||
{{ r.emoji }} {{ r.count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="msg-actions">
|
||||
<button class="ghost mini" @click="showPicker = !showPicker"><Icon name="smile" :size="14" /></button>
|
||||
<div v-if="showPicker" class="emoji-pop">
|
||||
<span v-for="e in FLAT" :key="e" @click="toggle(e)">{{ e }}</span>
|
||||
</div>
|
||||
<button v-if="message.user_id === auth.user?.id" class="ghost mini" @click="startEdit">{{ L.t('chat.edit') }}</button>
|
||||
<button v-if="message.user_id === auth.user?.id" class="ghost mini" @click="remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -36,6 +36,6 @@ const date = computed(() =>
|
||||
<h2 style="margin: 0">{{ period.emoji }} {{ L.t(period.key) }}{{ name ? `, ${name}` : '' }}!</h2>
|
||||
<p class="muted" style="margin: 4px 0 0">📅 {{ date }}</p>
|
||||
</div>
|
||||
<div class="clock">🕒 {{ clock }}</div>
|
||||
<div class="clock">{{ clock }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
// Flat, modern line icons (inline SVG, no external dependency).
|
||||
// Usage: <Icon name="home" :size="18" />
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
size: { type: Number, default: 18 },
|
||||
});
|
||||
|
||||
const PATHS = {
|
||||
home: '<path d="M3 11l9-8 9 8"/><path d="M5 10v10h5v-6h4v6h5V10"/>',
|
||||
tasks: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 12l2 2 4-4"/>',
|
||||
calendar: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/>',
|
||||
timer: '<circle cx="12" cy="13" r="7"/><path d="M12 9v4l2.5 2.5M9 2h6"/>',
|
||||
chat: '<path d="M4 5h16v11H9l-5 4z"/>',
|
||||
shield: '<path d="M12 3l7 3v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V6z"/><path d="M9.5 12l2 2 3.5-4"/>',
|
||||
settings:
|
||||
'<path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/><path d="M4 12h9M17 12h3"/><circle cx="15" cy="12" r="2"/>',
|
||||
bell: '<path d="M18 8a6 6 0 1 0-12 0c0 7-3 8-3 8h18s-3-1-3-8"/><path d="M10.3 21a2 2 0 0 0 3.4 0"/>',
|
||||
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/>',
|
||||
refresh: '<path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/>',
|
||||
search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
close: '<path d="M6 6l12 12M18 6L6 18"/>',
|
||||
check: '<path d="M4 12l5 5L20 7"/>',
|
||||
chevDown: '<path d="M6 9l6 6 6-6"/>',
|
||||
chevRight: '<path d="M9 6l6 6-6 6"/>',
|
||||
users: '<circle cx="9" cy="8" r="3.5"/><path d="M3.5 20c0-3.6 2.5-6 5.5-6s5.5 2.4 5.5 6"/><circle cx="17" cy="9" r="2.5"/><path d="M15.8 14.6c2.7.4 4.7 2.4 4.7 5.4"/>',
|
||||
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.5-7 8-7s8 3 8 7"/>',
|
||||
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7v5l3 2"/>',
|
||||
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>',
|
||||
image: '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.5"/><path d="M21 16l-5-5-9 9"/>',
|
||||
attach: '<path d="M21 11l-9 9a5.5 5.5 0 0 1-8-8l9-9a4 4 0 0 1 5 5l-9 9a2.5 2.5 0 0 1-4-4l8-8"/>',
|
||||
send: '<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/>',
|
||||
edit: '<path d="M17 3l4 4L8 20l-5 1 1-5z"/>',
|
||||
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z"/>',
|
||||
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 5l2 2M14 8l2 2"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
|
||||
moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
|
||||
desktop: '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
|
||||
lock: '<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
|
||||
back: '<path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/>',
|
||||
smile: '<circle cx="12" cy="12" r="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"/>',
|
||||
dot: '<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none"/>',
|
||||
};
|
||||
|
||||
const inner = computed(() => PATHS[props.name] || '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size" :height="size" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
style="flex-shrink: 0; vertical-align: -3px" v-html="inner"
|
||||
></svg>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue';
|
||||
import api from '../api';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import UserSearch from './UserSearch.vue';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const props = defineProps({ workspaceId: String });
|
||||
const emit = defineEmits(['invited']);
|
||||
@@ -37,7 +38,7 @@ async function invite() {
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h4>{{ L.t('inv.title') }}</h4>
|
||||
<h4><Icon name="mail" :size="16" /> {{ L.t('inv.title') }}</h4>
|
||||
<p class="muted">{{ L.t('inv.hint') }}</p>
|
||||
<UserSearch @pick="onPick" />
|
||||
<div class="row" style="margin-top: 8px">
|
||||
@@ -47,7 +48,7 @@ async function invite() {
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button @click="invite">✉️ {{ L.t('inv.btn') }}</button>
|
||||
<button @click="invite">{{ L.t('inv.btn') }}</button>
|
||||
</div>
|
||||
<p v-if="picked" class="muted">🔎 {{ picked.name }} <{{ picked.email }}></p>
|
||||
<p v-if="msg" style="color: green">{{ msg }}</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLocaleStore } from '../stores/locale';
|
||||
import { useThemeStore } from '../stores/theme';
|
||||
import { useUiStore } from '../stores/ui';
|
||||
import Avatar from './Avatar.vue';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const L = useLocaleStore();
|
||||
@@ -124,8 +125,12 @@ async function uploadAvatar(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploading.value = 'avatar';
|
||||
err.value = '';
|
||||
try {
|
||||
form.avatar = await uploadImage(file);
|
||||
const { data } = await api.patch('/users/me', { avatar: form.avatar });
|
||||
persistUser(data.user);
|
||||
msg.value = L.t('set.saved');
|
||||
} catch {
|
||||
err.value = 'Upload failed';
|
||||
} finally {
|
||||
@@ -137,9 +142,13 @@ async function uploadBg(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploading.value = 'bg';
|
||||
err.value = '';
|
||||
try {
|
||||
bgUrl.value = await uploadImage(file);
|
||||
bgPreset.value = 'custom';
|
||||
const { data } = await api.patch('/users/me', { bg_image: bgUrl.value });
|
||||
persistUser(data.user);
|
||||
msg.value = L.t('set.saved');
|
||||
} catch {
|
||||
err.value = 'Upload failed';
|
||||
} finally {
|
||||
@@ -157,15 +166,15 @@ async function uploadBg(e) {
|
||||
<span class="dot yellow"></span>
|
||||
<span class="dot green"></span>
|
||||
</div>
|
||||
<strong>⚙️ {{ L.t('set.title') }}</strong>
|
||||
<strong><Icon name="settings" :size="18" /> {{ L.t('set.title') }}</strong>
|
||||
<button class="ghost" @click="ui.closeSettings()">✕ {{ L.t('set.close') }}</button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<nav class="settings-tabs">
|
||||
<button :class="{ active: ui.settingsTab === 'profile' }" @click="ui.settingsTab = 'profile'">{{ L.t('set.profile') }}</button>
|
||||
<button :class="{ active: ui.settingsTab === 'appearance' }" @click="ui.settingsTab = 'appearance'">{{ L.t('set.appearance') }}</button>
|
||||
<button :class="{ active: ui.settingsTab === 'language' }" @click="ui.settingsTab = 'language'">{{ L.t('set.language') }}</button>
|
||||
<button :class="{ active: ui.settingsTab === 'background' }" @click="ui.settingsTab = 'background'">{{ L.t('set.background') }}</button>
|
||||
<button :class="{ active: ui.settingsTab === 'profile' }" @click="ui.settingsTab = 'profile'"><Icon name="user" :size="16" /> {{ L.t('set.profile') }}</button>
|
||||
<button :class="{ active: ui.settingsTab === 'appearance' }" @click="ui.settingsTab = 'appearance'"><Icon name="sun" :size="16" /> {{ L.t('set.appearance') }}</button>
|
||||
<button :class="{ active: ui.settingsTab === 'language' }" @click="ui.settingsTab = 'language'"><Icon name="globe" :size="16" /> {{ L.t('set.language') }}</button>
|
||||
<button :class="{ active: ui.settingsTab === 'background' }" @click="ui.settingsTab = 'background'"><Icon name="image" :size="16" /> {{ L.t('set.background') }}</button>
|
||||
</nav>
|
||||
<section class="settings-content">
|
||||
<div v-if="ui.settingsTab === 'profile'" style="display: grid; gap: 10px">
|
||||
@@ -180,6 +189,7 @@ async function uploadBg(e) {
|
||||
<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>
|
||||
<p class="muted">✓ {{ L.t('set.autoSaved') }}</p>
|
||||
<label>{{ L.t('set.name') }}</label>
|
||||
<input v-model="form.name" />
|
||||
<label>{{ L.t('set.email') }}</label>
|
||||
@@ -196,9 +206,9 @@ async function uploadBg(e) {
|
||||
<div v-if="ui.settingsTab === 'appearance'" style="display: grid; gap: 10px">
|
||||
<label>{{ L.t('set.theme') }}</label>
|
||||
<div class="row">
|
||||
<button :class="{ ghost: theme.mode !== 'light' }" @click="setTheme('light')">{{ L.t('set.light') }}</button>
|
||||
<button :class="{ ghost: theme.mode !== 'dark' }" @click="setTheme('dark')">{{ L.t('set.dark') }}</button>
|
||||
<button :class="{ ghost: theme.mode !== 'system' }" @click="setTheme('system')">{{ L.t('set.system') }}</button>
|
||||
<button :class="{ ghost: theme.mode !== 'light' }" @click="setTheme('light')"><Icon name="sun" :size="16" /> {{ L.t('set.light') }}</button>
|
||||
<button :class="{ ghost: theme.mode !== 'dark' }" @click="setTheme('dark')"><Icon name="moon" :size="16" /> {{ L.t('set.dark') }}</button>
|
||||
<button :class="{ ghost: theme.mode !== 'system' }" @click="setTheme('system')"><Icon name="desktop" :size="16" /> {{ L.t('set.system') }}</button>
|
||||
</div>
|
||||
<p class="muted">👀 {{ L.t('set.theme') }}: <strong>{{ theme.resolved }}</strong></p>
|
||||
</div>
|
||||
@@ -225,6 +235,7 @@ async function uploadBg(e) {
|
||||
<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.autoSaved') }}</p>
|
||||
<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>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useLocaleStore } from '../stores/locale';
|
||||
import { useUiStore } from '../stores/ui';
|
||||
import PageTree from './PageTree.vue';
|
||||
import Avatar from './Avatar.vue';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const props = defineProps({ activePageId: String });
|
||||
const auth = useAuthStore();
|
||||
@@ -85,7 +86,7 @@ async function createBook() {
|
||||
}
|
||||
|
||||
async function deleteBook(id) {
|
||||
if (!confirm('🗑️?')) return;
|
||||
if (!confirm('✕?')) return;
|
||||
try {
|
||||
await api.delete(`/books/${id}`);
|
||||
await ws.fetchBooks();
|
||||
@@ -120,7 +121,7 @@ onMounted(refresh);
|
||||
<div class="user-chip">
|
||||
<Avatar :name="auth.user?.name" :avatar="auth.user?.avatar" :size="32" />
|
||||
<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>
|
||||
<button class="ghost icon-btn" @click="ui.openSettings()" title="Settings"><Icon name="settings" /></button>
|
||||
</div>
|
||||
|
||||
<p v-if="sideErr" class="side-error">{{ sideErr }}</p>
|
||||
@@ -131,7 +132,7 @@ onMounted(refresh);
|
||||
{{ 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>
|
||||
<button class="ghost icon-btn" @click="refresh" :title="L.t('side.refresh')"><Icon name="refresh" /></button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input v-model="newWs" :placeholder="L.t('side.newWorkspace')" @keyup.enter="createWs" />
|
||||
@@ -139,12 +140,12 @@ onMounted(refresh);
|
||||
</div>
|
||||
|
||||
<nav class="side-nav">
|
||||
<router-link to="/">🏠 {{ L.t('nav.dashboard') }}</router-link>
|
||||
<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>
|
||||
<router-link to="/"><Icon name="home" /> {{ L.t('nav.dashboard') }}</router-link>
|
||||
<router-link to="/tasks"><Icon name="tasks" /> {{ L.t('nav.tasks') }}</router-link>
|
||||
<router-link to="/calendar"><Icon name="calendar" /> {{ L.t('nav.calendar') }}</router-link>
|
||||
<router-link to="/time"><Icon name="timer" /> {{ L.t('nav.time') }}</router-link>
|
||||
<router-link to="/chat"><Icon name="chat" /> {{ L.t('nav.chat') }}</router-link>
|
||||
<router-link v-if="auth.user?.isAdmin" to="/admin"><Icon name="shield" /> {{ L.t('nav.admin') }}</router-link>
|
||||
</nav>
|
||||
|
||||
<input v-model="search" :placeholder="L.t('side.searchPh')" @input="doSearch" />
|
||||
@@ -164,7 +165,7 @@ onMounted(refresh);
|
||||
<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>
|
||||
<button class="ghost mini" @click.stop="deleteBook(b.id)" title="✕">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!collapsed[b.id]" class="book-pages">
|
||||
@@ -189,7 +190,7 @@ onMounted(refresh);
|
||||
|
||||
<div class="side-footer">
|
||||
<span class="muted">{{ ws.role }}</span>
|
||||
<button class="ghost" @click="logout">🚪 {{ L.t('nav.logout') }}</button>
|
||||
<button class="ghost" @click="logout"><Icon name="logout" /> {{ L.t('nav.logout') }}</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
|
||||
import { useTimeStore, formatDuration } from '../stores/time';
|
||||
import { useWorkspaceStore } from '../stores/workspace';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const time = useTimeStore();
|
||||
const ws = useWorkspaceStore();
|
||||
@@ -49,7 +50,7 @@ async function start() {
|
||||
@click="toggle"
|
||||
:title="L.t('time.title')"
|
||||
>
|
||||
⏱️ {{ time.open ? elapsed : L.t('time.start') }}
|
||||
<Icon name="timer" :size="16" /> {{ time.open ? elapsed : L.t('time.start') }}
|
||||
</button>
|
||||
<div v-if="open && !time.open" class="card timer-pop">
|
||||
<input v-model="desc" :placeholder="L.t('time.descPh')" />
|
||||
|
||||
Reference in New Issue
Block a user