Files
FabledScribe/frontend/src/components/NotificationsPanel.vue
T
bvandeusenandClaude Opus 5 5f8b824523
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 30s
CI & Build / TypeScript typecheck (push) Successful in 45s
CI & Build / integration (push) Successful in 1m50s
CI & Build / Python tests (push) Successful in 2m13s
CI & Build / Build & push image (push) Successful in 42s
refactor(ui): the remaining views migrate; 2 dead classes, 2 off-palette hovers
Twelve more files onto the shared buttons. What the pass turned up:

DEAD, verified not merely unnamed:
- .btn-reconsolidate (TaskEditorView). A comment eleven hundred lines up in the
  same file says the feature was removed in Phase 8. The CSS outlived it.
- .btn-remove-slot (SettingsView), style rules only, no template anywhere.

OFF-PALETTE, the #2319 shape: TrashView's restore and purge hovers used
`var(--color-primary, #6366f1)` and `var(--color-danger, #ef4444)` — Tailwind
indigo and Tailwind red, from no palette in this system. The fallback is what
renders if the token is ever absent, and it renders something plausible
forever. Now the action and destructive colours, no fallback.

A REAL BREAKAGE MY OWN CHECK COULD NOT SEE, worth recording. Deleting a rule
whose selector was part of a comma-separated group left the leading selectors
behind:

    .btn-log-edit,
    <nothing>
    .log-textarea { … }

which silently swallows the next rule. Brace counting passed — there are no
braces in a dangling fragment. Found by scanning for selector lines ending in
`,` not followed by another selector; three instances across two files, one of
them interleaved with comments so the first sweep missed it. The sweep is now
part of the verification, not a one-off.

Kept bespoke, deliberately: .btn-pin/.btn-unpin (pill-shaped history badges),
.btn-add-share and .btn-new-note (gradient CTAs — brand moments, which the
house style does sanction), .btn-icon/.btn-bell (icon buttons, a different
component), .btn-add-system/.btn-add-milestone (dashed "add" affordances).
These are not drift; they are other things wearing a btn- prefix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-01 22:53:13 -04:00

137 lines
3.6 KiB
Vue

<script setup lang="ts">
import { X } from "lucide-vue-next";
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useNotificationsStore } from '@/stores/notifications'
import { relativeTime } from '@/composables/useRelativeTime'
const emit = defineEmits<{ close: [] }>()
const store = useNotificationsStore()
const router = useRouter()
const typeIcon: Record<string, string> = {
project_shared: '📁',
note_shared: '📝',
group_added: '👥',
event_reminder: '⏰',
}
function notifMessage(n: { type: string; payload: Record<string, unknown> }): string {
const p = n.payload
switch (n.type) {
case 'project_shared':
return ` shared "${p.project_title}" with you as ${p.permission}`
case 'note_shared':
return ` shared "${p.note_title}" with you as ${p.permission}`
case 'group_added':
return ` added you to "${p.group_name}" as ${p.role}`
case 'event_reminder':
return `Reminder: "${p.title}" is coming up`
default:
return ''
}
}
async function handleClick(notif: { id: number; payload: Record<string, unknown> }) {
await store.markRead(notif.id)
const url = notif.payload.url as string | undefined
if (url) {
emit('close')
router.push(url)
}
}
onMounted(() => store.fetchAll())
</script>
<template>
<div class="notif-panel" role="dialog" aria-label="Notifications">
<header class="notif-panel-header">
<span class="notif-panel-title">Notifications</span>
<button
v-if="store.count > 0"
class="btn-text"
@click="store.markAll()"
>Mark all read</button>
</header>
<ul class="notif-list" v-if="store.items.length">
<li
v-for="n in store.items"
:key="n.id"
class="notif-item"
@click="handleClick(n)"
>
<span class="notif-icon">{{ typeIcon[n.type] ?? '🔔' }}</span>
<div class="notif-body">
<p class="notif-msg">
<strong v-if="n.payload.invited_by">{{ n.payload.invited_by }}</strong>
{{ notifMessage(n) }}
</p>
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
</div>
<button class="btn-text" @click.stop="store.markRead(n.id)" aria-label="Dismiss"><X :size="16" /></button>
</li>
</ul>
<div v-else class="notif-empty">No unread notifications</div>
</div>
</template>
<style scoped>
.notif-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 340px;
max-height: 400px;
overflow-y: auto;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
z-index: 500;
}
.notif-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
background: var(--color-surface);
}
.notif-panel-title {
font-weight: 700;
font-size: 0.9rem;
}
.notif-item {
display: flex;
align-items: flex-start;
gap: 0.6rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: background 0.1s;
}
.notif-item:last-child { border-bottom: none; }
.notif-item:hover { background: var(--color-hover); }
.notif-icon { font-size: 1.2rem; flex-shrink: 0; margin-top: 0.1rem; }
.notif-body { flex: 1; min-width: 0; }
.notif-msg {
margin: 0 0 0.2rem;
font-size: 0.85rem;
color: var(--color-text);
line-height: 1.4;
word-break: break-word;
}
.notif-time { font-size: 0.75rem; color: var(--color-muted); }
</style>