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
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
381 lines
10 KiB
Vue
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(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
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(--color-border);
|
|
}
|
|
|
|
.share-title {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-size: 1.1rem;
|
|
font-weight: 700;
|
|
margin: 0;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
|
|
.share-tabs {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.share-tab {
|
|
background: none;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 6px;
|
|
padding: 0.3rem 0.8rem;
|
|
font-size: 0.82rem;
|
|
cursor: pointer;
|
|
color: var(--color-text-muted);
|
|
transition: all 0.15s;
|
|
}
|
|
.share-tab.active {
|
|
background: var(--color-primary);
|
|
border-color: var(--color-primary);
|
|
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(--color-border);
|
|
border-radius: 6px;
|
|
background: var(--color-bg-card);
|
|
color: var(--color-text);
|
|
font-size: 0.9rem;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.share-input:focus { border-color: var(--color-primary); }
|
|
|
|
.user-results {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
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(--color-bg-secondary); }
|
|
|
|
.user-result-name { font-weight: 600; font-size: 0.88rem; }
|
|
.user-result-email { color: var(--color-text-muted); font-size: 0.8rem; }
|
|
|
|
.perm-select {
|
|
padding: 0.45rem 0.5rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 6px;
|
|
background: var(--color-bg-card);
|
|
color: var(--color-text);
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn-add-share {
|
|
padding: 0.45rem 1rem;
|
|
background: var(--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(--color-text-muted);
|
|
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(--color-bg-secondary);
|
|
}
|
|
|
|
.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(--color-border);
|
|
border-radius: 4px;
|
|
background: var(--color-bg-card);
|
|
color: var(--color-text);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
</style>
|