Files
FabledScribe/frontend/src/components/ShareDialog.vue
T
bvandeusen 4ba544e2af
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
refactor(theme): retire the --color-* shim — the sweep it promised, run
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.

73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.

One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.

Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.

Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).

Refs #2533
2026-08-08 22:42:37 -04:00

381 lines
10 KiB
Vue

<script setup lang="ts">
import { X } from "lucide-vue-next";
import { ref, onMounted } from 'vue'
import {
searchUsers,
listGroups,
listProjectShares,
createProjectShare,
updateProjectShare,
deleteProjectShare,
listNoteShares,
createNoteShare,
updateNoteShare,
deleteNoteShare,
type ShareEntry,
type GroupEntry,
type UserSearchResult,
} from '@/api/client'
const props = defineProps<{
resourceType: 'project' | 'note'
resourceId: number
resourceTitle: string
}>()
const emit = defineEmits<{ close: [] }>()
const addMode = ref<'user' | 'group'>('user')
const userQuery = ref('')
const userResults = ref<UserSearchResult[]>([])
const selectedUser = ref<UserSearchResult | null>(null)
const selectedGroupId = ref<number | ''>('')
const newPermission = ref('viewer')
const groups = ref<GroupEntry[]>([])
const shares = ref<ShareEntry[]>([])
const saving = ref(false)
let searchTimer: ReturnType<typeof setTimeout> | null = null
function debounceSearch() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(async () => {
if (userQuery.value.length < 2) { userResults.value = []; return }
userResults.value = await searchUsers(userQuery.value)
}, 300)
}
function selectUser(u: UserSearchResult) {
selectedUser.value = u
userQuery.value = u.username
userResults.value = []
}
async function loadShares() {
if (props.resourceType === 'project') {
shares.value = await listProjectShares(props.resourceId)
} else {
shares.value = await listNoteShares(props.resourceId)
}
}
async function addShare() {
if (saving.value) return
saving.value = true
try {
const body: { user_id?: number; group_id?: number; permission: string } = {
permission: newPermission.value,
}
if (addMode.value === 'user' && selectedUser.value) {
body.user_id = selectedUser.value.id
} else if (addMode.value === 'group' && selectedGroupId.value) {
body.group_id = selectedGroupId.value as number
} else {
return
}
if (props.resourceType === 'project') {
await createProjectShare(props.resourceId, body)
} else {
await createNoteShare(props.resourceId, body)
}
selectedUser.value = null
userQuery.value = ''
selectedGroupId.value = ''
newPermission.value = 'viewer'
await loadShares()
} finally {
saving.value = false
}
}
async function changePermission(share: ShareEntry, permission: string) {
if (props.resourceType === 'project') {
await updateProjectShare(props.resourceId, share.id, permission)
} else {
await updateNoteShare(props.resourceId, share.id, permission)
}
await loadShares()
}
async function removeShare(share: ShareEntry) {
if (props.resourceType === 'project') {
await deleteProjectShare(props.resourceId, share.id)
} else {
await deleteNoteShare(props.resourceId, share.id)
}
await loadShares()
}
onMounted(async () => {
groups.value = await listGroups()
await loadShares()
})
</script>
<template>
<Teleport to="body">
<div class="share-overlay" @click.self="emit('close')">
<div class="share-dialog" role="dialog" :aria-label="`Share ${resourceTitle}`">
<header class="share-header">
<h2 class="share-title">Share "{{ resourceTitle }}"</h2>
<button class="btn-text" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
</header>
<!-- Add share form -->
<div class="share-add">
<div class="share-tabs">
<button :class="['share-tab', { active: addMode === 'user' }]" @click="addMode = 'user'">User</button>
<button :class="['share-tab', { active: addMode === 'group' }]" @click="addMode = 'group'">Group</button>
</div>
<div v-if="addMode === 'user'" class="share-target-form">
<div class="user-search-wrap">
<input
v-model="userQuery"
class="share-input"
placeholder="Search by username or email…"
@input="debounceSearch"
autocomplete="off"
/>
<ul v-if="userResults.length" class="user-results">
<li v-for="u in userResults" :key="u.id" @click="selectUser(u)" class="user-result-item">
<span class="user-result-name">{{ u.username }}</span>
</li>
</ul>
</div>
<template v-if="selectedUser">
<select v-model="newPermission" class="perm-select">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button class="btn-add-share" @click="addShare" :disabled="saving">Add</button>
</template>
</div>
<div v-else class="share-target-form">
<select v-model="selectedGroupId" class="share-input">
<option value="" disabled>Select a group</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }} ({{ g.member_count }} members)</option>
</select>
<select v-model="newPermission" class="perm-select">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button class="btn-add-share" @click="addShare" :disabled="saving || !selectedGroupId">Add</button>
</div>
</div>
<!-- Current shares -->
<div class="shares-list-wrap">
<p class="shares-label">Current access</p>
<ul class="shares-list">
<li v-for="share in shares" :key="share.id" class="share-row">
<span class="share-target-icon">{{ share.shared_with_user_id ? '👤' : '👥' }}</span>
<span class="share-target-name">
{{ share.shared_with_user_id ? share.username : share.group_name }}
</span>
<select
class="perm-select-inline"
:value="share.permission"
@change="changePermission(share, ($event.target as HTMLSelectElement).value)"
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button class="btn-text" @click="removeShare(share)" aria-label="Remove"><X :size="16" /></button>
</li>
<li v-if="!shares.length" class="shares-empty">Not shared with anyone yet</li>
</ul>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.share-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.share-dialog {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-xl);
width: 480px;
max-width: 95vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.share-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--fs-border-color);
}
.share-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.1rem;
font-weight: 700;
margin: 0;
color: var(--fs-text-primary);
}
.share-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.share-tab {
background: none;
border: 1px solid var(--fs-border-color);
border-radius: 6px;
padding: 0.3rem 0.8rem;
font-size: 0.82rem;
cursor: pointer;
color: var(--fs-text-tertiary);
transition: all 0.15s;
}
.share-tab.active {
background: var(--fs-accent);
border-color: var(--fs-accent);
color: var(--fs-text-on-action);
}
.share-target-form {
display: flex;
gap: 0.5rem;
align-items: flex-start;
flex-wrap: wrap;
}
.user-search-wrap {
flex: 1;
position: relative;
}
.share-input {
width: 100%;
padding: 0.45rem 0.7rem;
border: 1px solid var(--fs-border-color);
border-radius: 6px;
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
font-size: 0.9rem;
outline: none;
transition: border-color 0.15s;
}
.share-input:focus { border-color: var(--fs-accent); }
.user-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: 6px;
margin-top: 2px;
list-style: none;
padding: 0.25rem 0;
z-index: 10;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
max-height: 180px;
overflow-y: auto;
}
.user-result-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.8rem;
cursor: pointer;
transition: background 0.1s;
}
.user-result-item:hover { background: var(--fs-surface-raised); }
.user-result-name { font-weight: 600; font-size: 0.88rem; }
.user-result-email { color: var(--fs-text-tertiary); font-size: 0.8rem; }
.perm-select {
padding: 0.45rem 0.5rem;
border: 1px solid var(--fs-border-color);
border-radius: 6px;
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
font-size: 0.85rem;
cursor: pointer;
}
.btn-add-share {
padding: 0.45rem 1rem;
background: var(--fs-gradient-cta);
color: var(--fs-text-on-action);
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: var(--fs-weight-medium);
cursor: pointer;
transition: opacity 0.15s;
white-space: nowrap;
}
.btn-add-share:disabled { opacity: 0.5; cursor: not-allowed; }
.shares-list-wrap {
padding: 1rem 1.5rem 1.5rem;
}
.shares-label {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fs-text-tertiary);
margin: 0 0 0.5rem;
}
.shares-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.share-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: var(--fs-surface-raised);
}
.share-target-icon { font-size: 1rem; flex-shrink: 0; }
.share-target-name { flex: 1; font-size: 0.88rem; font-weight: 500; }
.perm-select-inline {
padding: 0.25rem 0.4rem;
border: 1px solid var(--fs-border-color);
border-radius: 4px;
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
font-size: 0.8rem;
cursor: pointer;
}
</style>