feat(frontend): chat tabs, flat SVG icons, upload autosave, per-user rights
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s

- 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:
x3
2026-09-22 14:41:42 +02:00
parent ab751ea7ef
commit 4fe56265c9
25 changed files with 660 additions and 118 deletions
+3 -2
View File
@@ -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">
+94
View File
@@ -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>
+1 -1
View File
@@ -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>
+57
View File
@@ -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 -2
View File
@@ -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 }} &lt;{{ picked.email }}&gt;</p>
<p v-if="msg" style="color: green">{{ msg }}</p>
+19 -8
View File
@@ -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>
+12 -11
View File
@@ -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')" />