Files
FabledScribe/frontend/src/views/SharedWithMeView.vue
T
bvandeusen c7ef709633 Multi-user sharing, groups, and in-app notifications
Backend:
- Alembic migration 0025: groups, group_memberships, project_shares,
  note_shares, notifications tables
- models: Group, GroupMembership, ProjectShare, NoteShare, Notification
- services/access.py: permission resolution (viewer/editor/admin/owner)
- services/groups.py + routes/groups.py: full group CRUD + membership
- services/sharing.py + routes/shares.py: project/note sharing API
- services/notifications.py: in-app notification create/list/mark-read
- routes/in_app_notifications.py: GET/POST notification endpoints
- routes/users.py: user search endpoint
- services/projects.py + services/notes.py: *_for_user variants
- routes updated to use *_for_user on get/list; adds permission field
- app.py: register all new blueprints

Frontend:
- api/client.ts: ShareEntry, GroupEntry, NotificationEntry types + helpers
- stores/notifications.ts: Pinia store with polling
- NotificationBell.vue: bell icon + badge + dropdown toggle + 60s poll
- NotificationsPanel.vue: unread notification list with mark-all-read
- ShareDialog.vue: teleport modal for sharing projects/notes with users/groups
- SharedWithMeView.vue: /shared route listing shared projects and notes
- AppHeader: NotificationBell, Shared nav link
- ProjectView, NoteViewerView, TaskViewerView: Share button + ShareDialog
- SettingsView: Groups admin tab with create/delete groups + member mgmt
- router: /shared route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:37:00 -04:00

257 lines
5.8 KiB
Vue

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getSharedWithMe } from '@/api/client'
interface SharedProject {
id: number
title: string
description: string | null
status: string
color: string | null
permission: string
owner_username: string
}
interface SharedNote {
id: number
title: string
is_task: boolean
permission: string
owner_username: string
}
const projects = ref<SharedProject[]>([])
const notes = ref<SharedNote[]>([])
const loading = ref(true)
onMounted(async () => {
try {
const data = await getSharedWithMe()
projects.value = data.projects as unknown as SharedProject[]
notes.value = data.notes as unknown as SharedNote[]
} finally {
loading.value = false
}
})
</script>
<template>
<div class="shared-page page-container">
<header class="page-header">
<h1 class="page-title">Shared with me</h1>
</header>
<div v-if="loading" class="loading-state">Loading</div>
<template v-else>
<!-- Projects -->
<section class="shared-section">
<h2 class="section-heading">Projects</h2>
<div v-if="projects.length" class="shared-grid">
<router-link
v-for="p in projects"
:key="p.id"
:to="`/projects/${p.id}`"
class="shared-card"
>
<div class="card-color-bar" :style="p.color ? `background: ${p.color}` : ''" />
<div class="card-body">
<div class="card-title">{{ p.title }}</div>
<div class="card-meta">
<span class="card-owner">by {{ p.owner_username }}</span>
<span class="perm-badge" :class="`perm-${p.permission}`">{{ p.permission }}</span>
</div>
<p v-if="p.description" class="card-desc">{{ p.description }}</p>
</div>
</router-link>
</div>
<p v-else class="empty-msg">No projects shared with you yet.</p>
</section>
<!-- Notes & Tasks -->
<section class="shared-section">
<h2 class="section-heading">Notes & Tasks</h2>
<div v-if="notes.length" class="shared-list">
<router-link
v-for="n in notes"
:key="n.id"
:to="n.is_task ? `/tasks/${n.id}` : `/notes/${n.id}`"
class="shared-row"
>
<span class="row-icon">{{ n.is_task ? '✓' : '📄' }}</span>
<span class="row-title">{{ n.title || '(untitled)' }}</span>
<span class="row-owner">by {{ n.owner_username }}</span>
<span class="perm-badge" :class="`perm-${n.permission}`">{{ n.permission }}</span>
</router-link>
</div>
<p v-else class="empty-msg">No notes or tasks shared with you yet.</p>
</section>
</template>
</div>
</template>
<style scoped>
.shared-page {
padding: 2rem;
max-width: 900px;
margin: 0 auto;
}
.page-header {
margin-bottom: 2rem;
}
.page-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.8rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.loading-state {
color: var(--color-muted);
padding: 2rem;
text-align: center;
}
.shared-section {
margin-bottom: 2.5rem;
}
.section-heading {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-muted);
margin: 0 0 0.75rem;
}
.shared-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
.shared-card {
display: flex;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
text-decoration: none;
transition: box-shadow 0.15s, transform 0.15s;
}
.shared-card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.card-color-bar {
width: 4px;
flex-shrink: 0;
background: var(--color-border);
}
.card-body {
padding: 0.9rem 1rem;
flex: 1;
min-width: 0;
}
.card-title {
font-weight: 600;
font-size: 0.95rem;
color: var(--color-text);
margin-bottom: 0.3rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.card-owner {
font-size: 0.78rem;
color: var(--color-muted);
}
.card-desc {
font-size: 0.82rem;
color: var(--color-muted);
margin: 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.shared-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.shared-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
text-decoration: none;
transition: background 0.1s;
}
.shared-row:hover {
background: var(--color-hover);
}
.row-icon {
font-size: 0.9rem;
flex-shrink: 0;
color: var(--color-muted);
}
.row-title {
flex: 1;
font-size: 0.9rem;
color: var(--color-text);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-owner {
font-size: 0.78rem;
color: var(--color-muted);
white-space: nowrap;
}
.perm-badge {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.45rem;
border-radius: 4px;
white-space: nowrap;
}
.perm-viewer { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
.perm-editor { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
.perm-admin { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
.empty-msg {
color: var(--color-muted);
font-style: italic;
font-size: 0.88rem;
margin: 0;
padding: 1rem 0;
}
</style>