1088 lines
41 KiB
Markdown
1088 lines
41 KiB
Markdown
# Phase 10: UX & Interaction — Pattern Map
|
|
|
|
**Mapped:** 2026-06-14
|
|
**Files analyzed:** 18 new/modified files
|
|
**Analogs found:** 17 / 18
|
|
|
|
---
|
|
|
|
## File Classification
|
|
|
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
|
|---|---|---|---|---|
|
|
| `frontend/src/components/ui/AppIcon.vue` | utility | transform | `frontend/src/components/ui/AppSpinner.vue` | role-match |
|
|
| `frontend/src/components/ui/EmptyState.vue` | component | request-response | `frontend/src/views/SharedView.vue` (inline empty) | partial |
|
|
| `frontend/src/components/ui/BreadcrumbBar.vue` | component | request-response | `frontend/src/components/folders/FolderBreadcrumb.vue` | exact |
|
|
| `frontend/src/components/ui/ToastContainer.vue` | component | event-driven | `frontend/src/components/ui/SearchableModelSelect.vue` (Teleport) | role-match |
|
|
| `frontend/src/components/layout/OsDragOverlay.vue` | component | event-driven | `frontend/src/components/documents/DocumentPreviewModal.vue` (window listener) | partial |
|
|
| `frontend/src/stores/toast.js` | store | event-driven | existing stub — fill in | exact |
|
|
| `frontend/src/App.vue` | provider | event-driven | `frontend/src/components/documents/DocumentPreviewModal.vue` | partial |
|
|
| `frontend/src/stores/toast.js` (impl) | store | event-driven | Pinia setup-store pattern in `frontend/src/stores/topics.js` | role-match |
|
|
| `frontend/src/components/storage/StorageBrowser.vue` | component | CRUD | self (modify) | exact |
|
|
| `frontend/src/components/layout/AppSidebar.vue` | component | CRUD | self (modify) | exact |
|
|
| `frontend/src/views/FileManagerView.vue` | view | CRUD | self (modify) | exact |
|
|
| `frontend/src/components/upload/DropZone.vue` | component | file-I/O | self (modify) | exact |
|
|
| `frontend/src/views/SharedView.vue` | view | request-response | self (modify) | exact |
|
|
| `frontend/src/views/CloudStorageView.vue` | view | request-response | self (modify) | exact |
|
|
| `frontend/src/views/admin/AdminUsersView.vue` | view | CRUD | self (modify) | exact |
|
|
| `frontend/src/views/admin/AdminAuditView.vue` | view | CRUD | self (modify) | exact |
|
|
| `frontend/src/components/documents/DocumentCard.vue` | component | CRUD | `frontend/src/components/ui/SearchableModelSelect.vue` (Teleport/position) | role-match |
|
|
| `frontend/src/components/folders/FolderRow.vue` | component | CRUD | `frontend/src/components/ui/SearchableModelSelect.vue` (Teleport/position) | role-match |
|
|
| `frontend/src/components/documents/SearchBar.vue` | component | request-response | self (modify: add defineExpose) | exact |
|
|
|
|
---
|
|
|
|
## Pattern Assignments
|
|
|
|
### `frontend/src/components/ui/AppIcon.vue` (NEW — utility, transform)
|
|
|
|
**Analog:** `frontend/src/components/ui/AppSpinner.vue` (single-purpose SVG UI component)
|
|
|
|
**Imports / component declaration pattern** (AppSpinner.vue, lines 1-23 — entire file):
|
|
The file has no script block at all — pure template. AppIcon needs a script block for the `name` prop and the path map. Use Options API per project convention.
|
|
|
|
**Options API component structure** (copy from AppSidebar.vue lines 235-313 — script setup means new components use Options API per CLAUDE.md pitfall note):
|
|
|
|
```html
|
|
<template>
|
|
<svg
|
|
v-if="paths[name]"
|
|
:class="$attrs.class"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<template v-if="Array.isArray(resolvedPaths)">
|
|
<path
|
|
v-for="(d, i) in resolvedPaths"
|
|
:key="i"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
:d="d"
|
|
/>
|
|
</template>
|
|
<path
|
|
v-else
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
:d="resolvedPaths"
|
|
/>
|
|
</svg>
|
|
</template>
|
|
|
|
<script>
|
|
const ICON_PATHS = {
|
|
// single-path icons
|
|
plus: 'M12 4v16m8-8H4',
|
|
folder: 'M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z',
|
|
folderMove: 'M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z',
|
|
pencil: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z',
|
|
trash: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
|
|
share: 'M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z',
|
|
document: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
|
fileDoc: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z',
|
|
chevronRight: 'M9 5l7 7-7 7',
|
|
chevronDown: 'M19 9l-7 7-7-7',
|
|
tag: 'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z',
|
|
inbox: 'M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4',
|
|
cloud: 'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z',
|
|
shield: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
|
logout: 'M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1',
|
|
home: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
|
|
users: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
|
|
chartBar: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
|
|
clipboardList: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01',
|
|
upload: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12',
|
|
x: 'M6 18L18 6M6 6l12 12',
|
|
checkCircle: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
|
exclamationCircle: 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
|
warning: 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z',
|
|
copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
|
|
check: 'M5 13l4 4L19 7',
|
|
checkMark: 'M4.5 12.75l6 6 9-13.5',
|
|
refresh: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15',
|
|
pencilEdit: 'M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z',
|
|
lightBulb: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
|
|
// search: verify Heroicons path before committing (A3 — assumed)
|
|
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0',
|
|
// dots: stroke replacement for fill-based three-dot icon
|
|
dots: 'M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z',
|
|
// dual-path icon — stored as array
|
|
cog: [
|
|
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
|
|
'M15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
|
],
|
|
}
|
|
|
|
export default {
|
|
name: 'AppIcon',
|
|
inheritAttrs: false,
|
|
props: {
|
|
name: { type: String, required: true },
|
|
},
|
|
computed: {
|
|
resolvedPaths() {
|
|
const p = ICON_PATHS[this.name]
|
|
if (!p && import.meta.env.DEV) {
|
|
console.warn(`[AppIcon] Unknown icon name: "${this.name}"`)
|
|
}
|
|
return p ?? null
|
|
},
|
|
},
|
|
}
|
|
</script>
|
|
```
|
|
|
|
**Key conventions to copy:**
|
|
- `inheritAttrs: false` so `$attrs.class` passes through to the `<svg>` element
|
|
- All existing inline SVGs use `fill="none" stroke="currentColor" viewBox="0 0 24 24"` — match exactly
|
|
- All existing path attrs: `stroke-linecap="round" stroke-linejoin="round" stroke-width="2"` — match exactly
|
|
- `aria-hidden="true"` on the `<svg>` (decorative icons; label is on parent element)
|
|
- Dev-mode `console.warn` for unknown names; renders nothing (`v-if="paths[name]"`)
|
|
|
|
---
|
|
|
|
### `frontend/src/components/ui/EmptyState.vue` (NEW — component, request-response)
|
|
|
|
**Analog:** Inline empty-state blocks in `SharedView.vue` (lines 9-13), `StorageBrowser.vue` (lines 226-241), `AppSidebar.vue` (lines 102-103, 148-149, 163-164). The pattern is consistent: centered container, small text, optional action.
|
|
|
|
**Current inline pattern to replace** (SharedView.vue lines 9-13):
|
|
```html
|
|
<div v-else-if="sharedDocs.length === 0" class="text-center py-12 text-gray-400">
|
|
<p class="text-sm font-medium text-gray-500">No documents shared with you yet.</p>
|
|
<p class="text-xs mt-1">When someone shares a document with you, it will appear here.</p>
|
|
</div>
|
|
```
|
|
|
|
**Current inline pattern to replace** (StorageBrowser.vue lines 226-233):
|
|
```html
|
|
<div
|
|
v-if="!loading && folders.length === 0 && files.length === 0 && !showNewFolderInput"
|
|
class="px-4 py-10 text-center text-gray-300"
|
|
>
|
|
<p class="text-gray-400 text-sm">{{ emptyMessage }}</p>
|
|
<p class="text-xs mt-1">{{ emptyHint }}</p>
|
|
</div>
|
|
```
|
|
|
|
**Current inline pattern for sidebar** (AppSidebar.vue lines 102-103):
|
|
```html
|
|
<div v-else-if="foldersStore.rootFolders.length === 0 && !showNewFolderInput"
|
|
class="pl-7 py-1 text-xs text-gray-400">No folders yet</div>
|
|
```
|
|
|
|
**Target component structure for EmptyState.vue** (Options API, consistent with component convention):
|
|
```html
|
|
<template>
|
|
<div :class="containerClass">
|
|
<AppIcon v-if="icon" :name="icon" :class="iconClass" />
|
|
<p :class="headlineClass">{{ headline }}</p>
|
|
<p v-if="subtext" :class="subtextClass">{{ subtext }}</p>
|
|
<slot name="cta" />
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import AppIcon from './AppIcon.vue'
|
|
export default {
|
|
name: 'EmptyState',
|
|
components: { AppIcon },
|
|
props: {
|
|
icon: { type: String, default: null },
|
|
headline: { type: String, required: true },
|
|
subtext: { type: String, default: '' },
|
|
size: { type: String, default: 'md' }, // 'sm' for sidebar micro states
|
|
},
|
|
computed: {
|
|
containerClass() {
|
|
return this.size === 'sm'
|
|
? 'flex items-center gap-2 py-1 text-xs text-gray-400'
|
|
: 'text-center py-10 px-4'
|
|
},
|
|
iconClass() {
|
|
return this.size === 'sm' ? 'w-3.5 h-3.5 shrink-0' : 'w-8 h-8 mx-auto mb-3 text-gray-300'
|
|
},
|
|
headlineClass() {
|
|
return this.size === 'sm' ? '' : 'text-sm font-medium text-gray-500'
|
|
},
|
|
subtextClass() {
|
|
return this.size === 'sm' ? 'hidden' : 'text-xs text-gray-400 mt-1'
|
|
},
|
|
},
|
|
}
|
|
</script>
|
|
```
|
|
|
|
**Usage patterns** (copy from call sites):
|
|
- `size="sm"` for AppSidebar micro states (same indent as `pl-7 py-1 text-xs text-gray-400`)
|
|
- Default `size="md"` for StorageBrowser, SharedView, CloudStorageView, AdminAuditView
|
|
- `#cta` slot renders buttons using existing button styles (e.g., `class="text-sm text-indigo-600 hover:underline"`)
|
|
|
|
---
|
|
|
|
### `frontend/src/components/ui/BreadcrumbBar.vue` (NEW — component, request-response)
|
|
|
|
**Analog:** `frontend/src/components/folders/FolderBreadcrumb.vue` (lines 1-68 — **read completely above**)
|
|
|
|
**Existing interface to extend** (FolderBreadcrumb.vue, lines 46-68):
|
|
```javascript
|
|
// script setup
|
|
const props = defineProps({
|
|
segments: { type: Array, default: () => [] }, // [{ id, name }]
|
|
})
|
|
const emit = defineEmits(['navigate'])
|
|
|
|
const visibleSegments = computed(() => {
|
|
if (props.segments.length > 4) {
|
|
return [
|
|
props.segments[0],
|
|
{ id: 'ellipsis', name: '…' },
|
|
...props.segments.slice(-2),
|
|
]
|
|
}
|
|
return props.segments
|
|
})
|
|
```
|
|
|
|
**Existing template pattern** (FolderBreadcrumb.vue, lines 1-44):
|
|
```html
|
|
<nav aria-label="Folder navigation">
|
|
<ol class="flex items-center gap-1 text-sm flex-wrap">
|
|
<!-- Root "Home" segment -->
|
|
<li class="flex items-center gap-1">
|
|
<button @click="emit('navigate', null)" class="text-indigo-600 hover:underline font-medium">
|
|
Home
|
|
</button>
|
|
</li>
|
|
<template v-for="(segment, idx) in visibleSegments" :key="segment.id ?? 'ellipsis-' + idx">
|
|
<!-- Separator -->
|
|
<li class="shrink-0" aria-hidden="true">
|
|
<svg class="w-3 h-3 text-gray-400" ...>
|
|
<path ... d="M9 5l7 7-7 7" /> <!-- chevronRight icon — replace with <AppIcon name="chevronRight"> -->
|
|
</svg>
|
|
</li>
|
|
<!-- Ellipsis -->
|
|
<li v-if="segment.id === 'ellipsis'" ...>
|
|
<span class="px-2 py-1 text-gray-400">…</span>
|
|
</li>
|
|
<!-- Last segment (non-clickable) -->
|
|
<li v-else-if="idx === visibleSegments.length - 1" ...>
|
|
<span class="text-gray-900 font-medium">{{ segment.name }}</span>
|
|
</li>
|
|
<!-- Clickable segment -->
|
|
<li v-else ...>
|
|
<button @click="emit('navigate', segment.id)" class="text-indigo-600 hover:underline font-medium">
|
|
{{ segment.name }}
|
|
</button>
|
|
</li>
|
|
</template>
|
|
</ol>
|
|
</nav>
|
|
```
|
|
|
|
**What BreadcrumbBar.vue adds over FolderBreadcrumb.vue:**
|
|
1. Renamed prop shape: `segments: [{ id?, label }]` — `label` replaces `name`
|
|
2. New props: `rootLabel` (String, default `'Home'`) and `showRoot` (Boolean, default `true`)
|
|
3. Segments without `id` are non-clickable (admin static breadcrumbs)
|
|
4. Use `<AppIcon name="chevronRight" class="w-3 h-3 text-gray-400" />` instead of inline SVG
|
|
|
|
**BreadcrumbBar prop interface:**
|
|
```javascript
|
|
props: {
|
|
segments: { type: Array, default: () => [] },
|
|
rootLabel: { type: String, default: 'Home' },
|
|
showRoot: { type: Boolean, default: true },
|
|
}
|
|
emits: ['navigate'] // emits segment.id (or null for root)
|
|
```
|
|
|
|
**How StorageBrowser currently uses FolderBreadcrumb** (StorageBrowser.vue lines 7-10):
|
|
```html
|
|
<FolderBreadcrumb
|
|
:segments="breadcrumb"
|
|
@navigate="$emit('breadcrumb-navigate', $event)"
|
|
/>
|
|
```
|
|
After swap: `<BreadcrumbBar :segments="breadcrumb" @navigate="$emit('breadcrumb-navigate', $event)" />`
|
|
|
|
Existing breadcrumb prop shape from FileManagerView (passes `foldersStore.breadcrumb` which returns `[{ id, name }]`) — segments must be remapped to `{ id, label: name }` or BreadcrumbBar must accept both `name` and `label` for backward compat.
|
|
|
|
---
|
|
|
|
### `frontend/src/components/ui/ToastContainer.vue` (NEW — component, event-driven)
|
|
|
|
**Analog:** `frontend/src/components/ui/SearchableModelSelect.vue` — the `<Teleport to="body">` + `z-[9999]` pattern (lines 37-91)
|
|
|
|
**Teleport pattern to copy** (SearchableModelSelect.vue lines 37-43):
|
|
```html
|
|
<Teleport to="body">
|
|
<ul
|
|
v-if="isOpen"
|
|
:style="dropdownStyle"
|
|
class="fixed z-[9999] bg-white border border-gray-200 rounded-lg shadow-xl overflow-y-auto"
|
|
...
|
|
>
|
|
```
|
|
|
|
**Target ToastContainer structure** (Options API, matches D-01 through D-04):
|
|
```html
|
|
<template>
|
|
<Teleport to="body">
|
|
<div class="fixed bottom-4 right-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none">
|
|
<TransitionGroup name="toast">
|
|
<div
|
|
v-for="toast in toastStore.toasts"
|
|
:key="toast.id"
|
|
class="pointer-events-auto flex items-center gap-3 bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden max-w-sm min-w-[280px]"
|
|
@click="toastStore.dismiss(toast.id)"
|
|
>
|
|
<!-- Left-border accent (D-02) -->
|
|
<div class="w-1 self-stretch shrink-0" :class="accentClass(toast.type)"></div>
|
|
<!-- Icon -->
|
|
<AppIcon :name="iconName(toast.type)" class="w-5 h-5 shrink-0" :class="iconColorClass(toast.type)" />
|
|
<!-- Message -->
|
|
<p class="text-sm text-gray-800 flex-1 py-3 pr-4">{{ toast.message }}</p>
|
|
</div>
|
|
</TransitionGroup>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
```
|
|
|
|
**Toast type → classes mapping:**
|
|
- `success`: accent `bg-green-500`, icon `checkCircle` `text-green-500`
|
|
- `error`: accent `bg-red-500`, icon `exclamationCircle` `text-red-500`
|
|
- `warning`: accent `bg-amber-400`, icon `warning` `text-amber-500`
|
|
- `info`: accent `bg-sky-400`, icon `exclamationCircle` `text-sky-500`
|
|
|
|
**App.vue mount point** (App.vue current lines 1-21):
|
|
```html
|
|
<!-- App.vue — add ToastContainer alongside router-view -->
|
|
<template>
|
|
<AuthLayout v-if="route.meta.layout === 'auth'" />
|
|
<div v-else class="flex h-screen overflow-hidden">
|
|
<AppSidebar />
|
|
<main class="flex-1 overflow-y-auto">
|
|
<router-view ref="routeViewRef" /> <!-- add ref for keyboard shortcuts -->
|
|
</main>
|
|
</div>
|
|
<ToastContainer /> <!-- mounts via Teleport, no positioning needed here -->
|
|
<OsDragOverlay @files-dropped="handleOsDrop" /> <!-- mounts via Teleport -->
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/components/layout/OsDragOverlay.vue` (NEW — component, event-driven)
|
|
|
|
**Analog:** `frontend/src/components/documents/DocumentPreviewModal.vue` — `onMounted`/`onUnmounted` window-level event listener pattern (lines 118-135)
|
|
|
|
**Keydown listener pattern to copy** (DocumentPreviewModal.vue lines 112-135):
|
|
```javascript
|
|
function handleKeydown(e) {
|
|
if (e.key === 'Escape') {
|
|
emit('close')
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('keydown', handleKeydown)
|
|
loadContent(props.doc.id)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('keydown', handleKeydown)
|
|
// cleanup...
|
|
})
|
|
```
|
|
|
|
**Adapt for window drag events** (Options API, D-16 depth counter from Pitfall 3):
|
|
```javascript
|
|
export default {
|
|
name: 'OsDragOverlay',
|
|
emits: ['files-dropped'],
|
|
data() {
|
|
return { dragDepth: 0, showOverlay: false }
|
|
},
|
|
methods: {
|
|
onDragEnter(e) {
|
|
if (!e.dataTransfer?.types.includes('Files')) return
|
|
this.dragDepth++
|
|
this.showOverlay = true
|
|
},
|
|
onDragLeave() {
|
|
this.dragDepth = Math.max(0, this.dragDepth - 1)
|
|
if (this.dragDepth === 0) this.showOverlay = false
|
|
},
|
|
onDragOver(e) { e.preventDefault() },
|
|
onDrop(e) {
|
|
e.preventDefault()
|
|
this.dragDepth = 0
|
|
this.showOverlay = false
|
|
const files = Array.from(e.dataTransfer.files)
|
|
if (files.length) this.$emit('files-dropped', files)
|
|
},
|
|
},
|
|
mounted() {
|
|
window.addEventListener('dragenter', this.onDragEnter)
|
|
window.addEventListener('dragleave', this.onDragLeave)
|
|
window.addEventListener('dragover', this.onDragOver)
|
|
window.addEventListener('drop', this.onDrop)
|
|
},
|
|
beforeUnmount() {
|
|
window.removeEventListener('dragenter', this.onDragEnter)
|
|
window.removeEventListener('dragleave', this.onDragLeave)
|
|
window.removeEventListener('dragover', this.onDragOver)
|
|
window.removeEventListener('drop', this.onDrop)
|
|
},
|
|
}
|
|
```
|
|
|
|
**Overlay template** (z-index below toasts per Pitfall 6 — `z-[9998]`):
|
|
```html
|
|
<template>
|
|
<Teleport to="body">
|
|
<Transition name="fade">
|
|
<div
|
|
v-if="showOverlay"
|
|
class="fixed inset-0 z-[9998] bg-indigo-900/40 flex items-center justify-center pointer-events-none"
|
|
>
|
|
<div class="bg-white rounded-2xl px-10 py-8 text-center shadow-xl pointer-events-none">
|
|
<AppIcon name="upload" class="w-10 h-10 text-indigo-400 mx-auto mb-3" />
|
|
<p class="text-base font-semibold text-gray-800">Drop files to upload</p>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/stores/toast.js` (MODIFY — store, event-driven)
|
|
|
|
**Current stub** (toast.js lines 1-20 — entire file):
|
|
```javascript
|
|
import { defineStore } from 'pinia'
|
|
|
|
export const useToastStore = defineStore('toast', () => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
function show(message, type = 'success', duration = 4000) {
|
|
// No-op stub — Phase 10 implements rendering.
|
|
}
|
|
return { show }
|
|
})
|
|
```
|
|
|
|
**Implementation target** (preserve exact `show(message, type, duration)` signature — D-04):
|
|
```javascript
|
|
import { ref } from 'vue'
|
|
import { defineStore } from 'pinia'
|
|
|
|
export const useToastStore = defineStore('toast', () => {
|
|
const toasts = ref([]) // [{ id, message, type, duration }]
|
|
|
|
function show(message, type = 'success', duration = 4000) {
|
|
const id = Date.now() + Math.random()
|
|
toasts.value.push({ id, message, type, duration })
|
|
if (duration > 0) setTimeout(() => dismiss(id), duration)
|
|
}
|
|
|
|
function dismiss(id) {
|
|
toasts.value = toasts.value.filter(t => t.id !== id)
|
|
}
|
|
|
|
return { toasts, show, dismiss }
|
|
})
|
|
```
|
|
|
|
**Existing call sites that must remain unchanged** (from CONTEXT.md D-04):
|
|
- `SettingsAccountTab.vue`: `useToastStore().show('Sessions revoked', 'success')`
|
|
- `TotpEnrollment.vue`: `useToastStore().show('TOTP enabled', 'success')`
|
|
|
|
---
|
|
|
|
### `frontend/src/App.vue` (MODIFY — provider, event-driven)
|
|
|
|
**Current state** (App.vue lines 1-21 — entire file): Uses `<script setup>` (Composition API). `onMounted()` already used for `topicsStore.fetchTopics()`.
|
|
|
|
**Keydown listener pattern to add** (mirrors DocumentPreviewModal.vue lines 118-135):
|
|
```javascript
|
|
// App.vue additions to <script setup>
|
|
import { ref, onMounted, onUnmounted } from 'vue'
|
|
// (useRoute already imported)
|
|
|
|
const routeViewRef = ref(null) // ref on <router-view>
|
|
|
|
function onKeydown(e) {
|
|
const tag = document.activeElement?.tagName
|
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) || document.activeElement?.isContentEditable) return
|
|
|
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
|
|
e.preventDefault()
|
|
routeViewRef.value?.focusSearch?.()
|
|
}
|
|
if (e.key === 'Escape') {
|
|
routeViewRef.value?.clearSearch?.()
|
|
}
|
|
if (e.key === 'u' || e.key === 'U') {
|
|
routeViewRef.value?.triggerUpload?.()
|
|
}
|
|
if (e.key === 'n' || e.key === 'N') {
|
|
routeViewRef.value?.startNewFolder?.()
|
|
}
|
|
}
|
|
|
|
onMounted(() => document.addEventListener('keydown', onKeydown))
|
|
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
|
```
|
|
|
|
**OsDrop handler:**
|
|
```javascript
|
|
function handleOsDrop(files) {
|
|
routeViewRef.value?.handleOsDrop?.(files) // optional chain — only FileManagerView exposes this
|
|
}
|
|
```
|
|
|
|
**Template modification pattern** (existing lines 1-9):
|
|
```html
|
|
<!-- BEFORE -->
|
|
<router-view />
|
|
|
|
<!-- AFTER -->
|
|
<router-view ref="routeViewRef" />
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/components/storage/StorageBrowser.vue` (MODIFY — component, CRUD)
|
|
|
|
**Current state baseline** (StorageBrowser.vue lines 1-374 — read completely above).
|
|
|
|
**Key existing patterns the planner will modify:**
|
|
|
|
1. **FolderBreadcrumb import/usage** (lines 7-10, 252):
|
|
- Import line 252: `import FolderBreadcrumb from '../folders/FolderBreadcrumb.vue'`
|
|
- Usage lines 7-10: `<FolderBreadcrumb :segments="breadcrumb" @navigate="$emit('breadcrumb-navigate', $event)" />`
|
|
- Replace with `BreadcrumbBar` import + update usage to pass `showRoot: true`
|
|
|
|
2. **Current empty state block** (lines 226-241) — **replace with EmptyState.vue**:
|
|
```html
|
|
<!-- lines 226-233: root/folder empty -->
|
|
<div v-if="!loading && folders.length === 0 && files.length === 0 && !showNewFolderInput"
|
|
class="px-4 py-10 text-center text-gray-300">
|
|
<p class="text-gray-400 text-sm">{{ emptyMessage }}</p>
|
|
<p class="text-xs mt-1">{{ emptyHint }}</p>
|
|
</div>
|
|
<!-- lines 235-241: search no-results -->
|
|
<div v-else-if="searchQuery && files.length === 0 && folders.length === 0"
|
|
class="px-4 py-10 text-center text-sm text-gray-400">
|
|
No items match "{{ searchQuery }}".
|
|
</div>
|
|
```
|
|
|
|
3. **Current loading state** (line 244) — **replace with skeleton rows**:
|
|
```html
|
|
<!-- line 244 — replace this: -->
|
|
<div v-if="loading" class="py-6 text-center text-sm text-gray-400">Loading…</div>
|
|
```
|
|
Skeleton rows must match grid: `grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 px-4 py-2.5`
|
|
|
|
4. **Current defineExpose** (line 327):
|
|
```javascript
|
|
defineExpose({ startNewFolder })
|
|
// ADD: triggerUpload, focusSearch
|
|
```
|
|
|
|
5. **Folder picker dropdown** (lines 196-213) — **current pattern (clips in overflow)**:
|
|
```html
|
|
<div class="relative">
|
|
<button @click.stop="folderPickerFileId = ... " ...>...</button>
|
|
<div v-if="folderPickerFileId === file.id"
|
|
class="absolute right-0 top-full mt-1 w-48 bg-white ...z-20">
|
|
...
|
|
</div>
|
|
</div>
|
|
```
|
|
Replace with Teleport + getBoundingClientRect pattern (see Shared Patterns section).
|
|
|
|
6. **Existing drag-to-move state** (lines 341-362) — already implemented, do NOT rebuild:
|
|
```javascript
|
|
const draggingFile = ref(null) // line 341
|
|
const dragOverFolderId = ref(null) // line 342
|
|
// onFileDragStart: lines 344-348
|
|
// onFolderDragOver: lines 350-354
|
|
// onDropDocOnFolder: lines 356-362 — emits 'file-move'
|
|
```
|
|
Ring class is already wired (line 84): `'bg-amber-50 ring-2 ring-inset ring-amber-300': dragOverFolderId === folder.id`
|
|
|
|
---
|
|
|
|
### `frontend/src/components/layout/AppSidebar.vue` (MODIFY — component, CRUD)
|
|
|
|
**Current loading/empty text patterns to replace** (lines 101-103, 147-149, 163-164):
|
|
```html
|
|
<!-- line 101 — folder tree loading -->
|
|
<div v-if="loadingRoots" class="pl-7 py-1 text-xs text-gray-400">Loading…</div>
|
|
<!-- line 102-103 — folder tree empty -->
|
|
<div v-else-if="foldersStore.rootFolders.length === 0 && !showNewFolderInput"
|
|
class="pl-7 py-1 text-xs text-gray-400">No folders yet</div>
|
|
<!-- line 147 — cloud loading -->
|
|
<div v-if="loadingCloudConnections" class="pl-7 py-1 text-xs text-gray-400">Loading…</div>
|
|
<!-- line 148-149 — cloud empty -->
|
|
<div v-else-if="activeCloudConnections.length === 0" class="pl-7 py-1 text-xs text-gray-400">
|
|
No cloud storage connected
|
|
</div>
|
|
<!-- line 163 — topics loading -->
|
|
<div v-if="topicsStore.loading" class="px-3 py-1 text-xs text-gray-400">Loading…</div>
|
|
<!-- line 164 — topics empty -->
|
|
<div v-else-if="topicsStore.topics.length === 0" class="px-3 py-1 text-xs text-gray-400">No topics yet</div>
|
|
```
|
|
|
|
**UX-14 sidebar "New" button to remove** (lines 75-82):
|
|
```html
|
|
<button
|
|
@click="startNewFolder"
|
|
class="text-xs text-indigo-600 hover:underline shrink-0 mr-1"
|
|
title="New root folder"
|
|
>
|
|
New
|
|
</button>
|
|
```
|
|
Also remove: `startNewFolder()` (line 289-293), `cancelNewFolder()` (295-298), `submitNewFolder()` (300-312), and state: `showNewFolderInput`, `newFolderName`, `newFolderError` from `data`/`ref`. The inline new-folder `<div>` at lines 87-98 also gets deleted.
|
|
|
|
**Skeleton pattern** (replace `Loading…` text — must match `TreeItem.vue` indent):
|
|
```html
|
|
<!-- TreeItem renders children at paddingLeft: depth * 12px -->
|
|
<!-- Sidebar tree items are at depth=1, so pl-7 (28px = ~1 * 12 + 16) -->
|
|
<div class="pl-7 py-1 flex flex-col gap-1">
|
|
<div v-for="n in 3" :key="n" class="flex items-center gap-2 py-1">
|
|
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
|
|
<div class="h-3 bg-gray-100 rounded animate-pulse flex-1"></div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/views/FileManagerView.vue` (MODIFY — view, CRUD)
|
|
|
|
**Current state baseline** (FileManagerView.vue lines 1-158 — read completely above).
|
|
|
|
**Current `doMove` function** (lines 145-147) — **add toast**:
|
|
```javascript
|
|
// BEFORE
|
|
async function doMove(docId, folderId) {
|
|
try { await docsStore.moveToFolder(docId, folderId) } catch (e) { console.error(e.message) }
|
|
}
|
|
|
|
// AFTER
|
|
async function doMove(docId, folderId) {
|
|
try {
|
|
await docsStore.moveToFolder(docId, folderId)
|
|
useToastStore().show('Document moved', 'success')
|
|
} catch (e) {
|
|
useToastStore().show('Move failed: ' + (e.message || 'unknown error'), 'error')
|
|
}
|
|
}
|
|
```
|
|
|
|
**Methods to expose via defineExpose** (for keyboard shortcuts from App.vue):
|
|
```javascript
|
|
// Add to FileManagerView.vue — these don't exist yet
|
|
defineExpose({
|
|
focusSearch: () => browserRef.value?.focusSearch?.(),
|
|
triggerUpload: () => browserRef.value?.triggerUpload?.(),
|
|
startNewFolder: () => browserRef.value?.startNewFolder(),
|
|
clearSearch: () => { docsStore.searchQuery = '' },
|
|
handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true }),
|
|
})
|
|
```
|
|
|
|
**browserRef already exists** (line 61): `const browserRef = ref(null)` with `ref="browserRef"` on `<StorageBrowser>` (line 16).
|
|
|
|
---
|
|
|
|
### `frontend/src/components/upload/DropZone.vue` (MODIFY — component, file-I/O)
|
|
|
|
**Current state** (DropZone.vue lines 1-62 — read completely above).
|
|
|
|
**`triggerInput` exists but not exposed** (lines 47-49):
|
|
```javascript
|
|
function triggerInput() {
|
|
inputRef.value?.click()
|
|
}
|
|
// ADD:
|
|
defineExpose({ triggerInput })
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/components/documents/SearchBar.vue` (MODIFY — component, request-response)
|
|
|
|
**Current state** (SearchBar.vue lines 1-28 — read completely above). No `defineExpose` exists.
|
|
|
|
**Input ref and expose pattern to add:**
|
|
```javascript
|
|
// SearchBar.vue additions
|
|
import { ref } from 'vue' // add to existing import (currently no script imports)
|
|
|
|
const inputEl = ref(null) // add ref="inputEl" to the <input> element
|
|
|
|
defineExpose({
|
|
focus() { inputEl.value?.focus() }
|
|
})
|
|
```
|
|
|
|
And on the `<input>` element (line 4), add `ref="inputEl"`.
|
|
|
|
---
|
|
|
|
### `frontend/src/views/SharedView.vue` (MODIFY — view, request-response)
|
|
|
|
**Current inline empty state** (lines 9-13):
|
|
```html
|
|
<div v-else-if="sharedDocs.length === 0" class="text-center py-12 text-gray-400">
|
|
<p class="text-sm font-medium text-gray-500">No documents shared with you yet.</p>
|
|
<p class="text-xs mt-1">When someone shares a document with you, it will appear here.</p>
|
|
</div>
|
|
```
|
|
|
|
**Replace with** (per Section 3 EmptyState inventory):
|
|
```html
|
|
<EmptyState
|
|
v-else-if="sharedDocs.length === 0"
|
|
icon="inbox"
|
|
headline="Nothing shared with you yet"
|
|
subtext="When someone shares a document with you, it will appear here."
|
|
/>
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/views/CloudStorageView.vue` (MODIFY — view, request-response)
|
|
|
|
**Current inline empty state** (lines 23-28):
|
|
```html
|
|
<div v-else-if="connections.length === 0" class="text-center py-12 text-gray-400">
|
|
<p class="text-sm">No cloud storage connected.</p>
|
|
<router-link to="/settings" class="text-sm text-indigo-600 hover:underline mt-1 inline-block">
|
|
Add a connection in Settings
|
|
</router-link>
|
|
</div>
|
|
```
|
|
|
|
**Replace with** (CTA slot contains the settings link):
|
|
```html
|
|
<EmptyState
|
|
v-else-if="connections.length === 0"
|
|
icon="cloud"
|
|
headline="No cloud storage connected"
|
|
subtext="Connect Google Drive, OneDrive, Nextcloud, or a WebDAV server in Settings."
|
|
>
|
|
<template #cta>
|
|
<router-link to="/settings" class="mt-3 inline-block text-sm text-indigo-600 hover:underline">
|
|
Go to Settings
|
|
</router-link>
|
|
</template>
|
|
</EmptyState>
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/views/admin/AdminAuditView.vue` (MODIFY — view, CRUD)
|
|
|
|
**Current loading state** (lines 78-83):
|
|
```html
|
|
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
|
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
|
|
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
|
|
Loading audit log…
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**Table row pattern** (lines 102-126) — skeleton must match these columns: Timestamp | User | Email | Action Type | IP Address.
|
|
|
|
**Skeleton row pattern** (5 columns, mirrors real `<tr>` structure):
|
|
```html
|
|
<tr v-for="n in 8" :key="n" class="border-b border-gray-100">
|
|
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-32"></div></td>
|
|
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-20"></div></td>
|
|
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-36"></div></td>
|
|
<td class="px-4 py-3"><div class="h-4 bg-gray-100 rounded-full animate-pulse w-16"></div></td>
|
|
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-24"></div></td>
|
|
</tr>
|
|
```
|
|
|
|
**Current empty state** (line 87-89):
|
|
```html
|
|
<div v-else-if="entries.length === 0" class="text-center py-12 text-gray-400 text-sm">
|
|
No audit log entries match the selected filters.
|
|
</div>
|
|
```
|
|
Replace with `<EmptyState icon="clipboardList" headline="No entries found" subtext="Try adjusting your filters or date range." />`.
|
|
|
|
---
|
|
|
|
### `frontend/src/views/admin/AdminUsersView.vue` (MODIFY — view, CRUD)
|
|
|
|
**Current loading state** (lines 93-98):
|
|
```html
|
|
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
|
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
|
|
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
|
|
Loading users…
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**Table header** (lines 108-115) — skeleton must match: Email | Handle | Role | Status | Created | Actions.
|
|
|
|
**Skeleton row pattern** (6 columns):
|
|
```html
|
|
<tr v-for="n in 5" :key="n" class="border-b border-gray-200">
|
|
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-40"></div></td>
|
|
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-20"></div></td>
|
|
<td class="px-4 py-3"><div class="h-5 bg-gray-100 rounded animate-pulse w-12"></div></td>
|
|
<td class="px-4 py-3"><div class="h-5 bg-gray-100 rounded animate-pulse w-16"></div></td>
|
|
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-20"></div></td>
|
|
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-16"></div></td>
|
|
</tr>
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/components/documents/DocumentCard.vue` + `FolderRow.vue` (MODIFY — dropdown Teleport fix)
|
|
|
|
**Analog for fix:** `frontend/src/components/ui/SearchableModelSelect.vue` — the complete Teleport + getBoundingClientRect pattern (lines 37-91, 143-163, 269-276).
|
|
|
|
**Current broken pattern in DocumentCard.vue** (lines 63-91):
|
|
```html
|
|
<div class="relative">
|
|
<button @click.stop="toggleFolderPicker" ...> <!-- trigger -->
|
|
</button>
|
|
<!-- THIS CLIPS: absolute positioning inside overflow parent -->
|
|
<div v-if="showFolderPicker" class="absolute right-0 top-full mt-1 w-48 ... z-20">
|
|
...
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**Current broken pattern in FolderRow.vue** (lines 37-66):
|
|
```html
|
|
<div class="relative shrink-0" @click.stop>
|
|
<button @click="toggleMenu" ...> <!-- trigger -->
|
|
</button>
|
|
<!-- clips at viewport edge -->
|
|
<div v-if="menuOpen" class="absolute right-0 top-full mt-1 ... z-10">
|
|
...
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**SearchableModelSelect position calculation pattern to copy** (lines 143-163):
|
|
```javascript
|
|
// Store ref on trigger button
|
|
const inputEl = ref(null) // → ref on trigger button
|
|
const dropdownStyle = ref({})
|
|
|
|
function updatePosition() {
|
|
if (!inputEl.value) return
|
|
const rect = inputEl.value.getBoundingClientRect()
|
|
const spaceBelow = window.innerHeight - rect.bottom
|
|
const dropH = 200 // approximate picker height
|
|
|
|
if (spaceBelow >= dropH || spaceBelow > 120) {
|
|
dropdownStyle.value = {
|
|
top: `${rect.bottom + 4}px`,
|
|
left: `${rect.left}px`,
|
|
width: '192px', // w-48 = 12rem = 192px
|
|
}
|
|
} else {
|
|
dropdownStyle.value = {
|
|
bottom: `${window.innerHeight - rect.top + 4}px`,
|
|
left: `${rect.left}px`,
|
|
width: '192px',
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**SearchableModelSelect scroll handling** (lines 269-276):
|
|
```javascript
|
|
function onScroll() { if (isOpen.value) updatePosition() }
|
|
window.addEventListener('scroll', onScroll, true)
|
|
window.addEventListener('resize', onScroll)
|
|
onUnmounted(() => {
|
|
window.removeEventListener('scroll', onScroll, true)
|
|
window.removeEventListener('resize', onScroll)
|
|
})
|
|
```
|
|
|
|
**Teleported dropdown** (SearchableModelSelect.vue lines 37-43):
|
|
```html
|
|
<Teleport to="body">
|
|
<ul v-if="isOpen" :style="dropdownStyle"
|
|
class="fixed z-[9999] bg-white border border-gray-200 rounded-xl shadow-lg overflow-y-auto py-1"
|
|
style="max-height: 240px;">
|
|
...
|
|
</ul>
|
|
</Teleport>
|
|
```
|
|
|
|
---
|
|
|
|
## Shared Patterns
|
|
|
|
### 1. Inline SVG → AppIcon Migration
|
|
|
|
**Source:** All 29 files with inline `<svg>` blocks (see RESEARCH.md Section 1 for full audit)
|
|
**Apply to:** All 29 files in Wave 3
|
|
|
|
**Before pattern** (e.g., AppSidebar.vue line 16-19):
|
|
```html
|
|
<svg class="w-4 h-4 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7..." />
|
|
</svg>
|
|
```
|
|
|
|
**After pattern:**
|
|
```html
|
|
<AppIcon name="tag" class="w-4 h-4 mr-2 shrink-0" />
|
|
```
|
|
|
|
**Class forwarding:** `AppIcon` uses `inheritAttrs: false` + `:class="$attrs.class"` so all Tailwind sizing/color/margin classes pass through identically.
|
|
|
|
**Special case — Settings dual-path icon** (AppSidebar.vue lines 206-210):
|
|
```html
|
|
<!-- BEFORE: two <path> elements in one <svg> -->
|
|
<svg class="w-4 h-4 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317..." />
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0..." />
|
|
</svg>
|
|
<!-- AFTER: single AppIcon, Array.isArray handling inside AppIcon -->
|
|
<AppIcon name="cog" class="w-4 h-4 mr-2 shrink-0" />
|
|
```
|
|
|
|
**Special case — FolderRow fill-based dots icon** (FolderRow.vue line 43-45):
|
|
```html
|
|
<!-- BEFORE: fill="currentColor" -->
|
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4..." />
|
|
</svg>
|
|
<!-- AFTER: stroke equivalent in AppIcon map -->
|
|
<AppIcon name="dots" class="w-4 h-4" />
|
|
```
|
|
|
|
---
|
|
|
|
### 2. Teleport + getBoundingClientRect Dropdown Fix
|
|
|
|
**Source:** `frontend/src/components/ui/SearchableModelSelect.vue` (lines 37-91, 143-163, 269-276)
|
|
**Apply to:** `StorageBrowser.vue` (folder picker), `DocumentCard.vue` (folder picker), `FolderRow.vue` (three-dot menu)
|
|
|
|
Pattern steps:
|
|
1. Add `ref` to the trigger `<button>` element
|
|
2. Store `dropdownStyle = ref({})` reactive object
|
|
3. Call `updatePosition()` (using `getBoundingClientRect()`) in the open handler
|
|
4. Wrap the dropdown `<div>` in `<Teleport to="body">` with `class="fixed z-[9999]"` and `:style="dropdownStyle"`
|
|
5. Register `window.addEventListener('scroll', onScroll, true)` in `onMounted` and clean up in `onUnmounted`
|
|
|
|
---
|
|
|
|
### 3. `animate-pulse` Skeleton Loading
|
|
|
|
**Source:** Tailwind built-in (already in project bundle)
|
|
**Apply to:** StorageBrowser.vue, AppSidebar.vue, AdminUsersView.vue, AdminAuditView.vue
|
|
|
|
**5-column StorageBrowser skeleton** (matches existing grid — StorageBrowser.vue line 43):
|
|
```html
|
|
<!-- Grid class from existing column headers: grid-cols-[2rem_1fr_6rem_8rem_6rem] -->
|
|
<div v-if="loading">
|
|
<div
|
|
v-for="n in 8"
|
|
:key="n"
|
|
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center border-b border-gray-100"
|
|
>
|
|
<div class="w-7 h-7 bg-gray-100 rounded-lg animate-pulse"></div>
|
|
<div class="h-4 bg-gray-100 rounded animate-pulse w-2/3"></div>
|
|
<div class="h-3 bg-gray-100 rounded animate-pulse hidden md:block"></div>
|
|
<div class="h-3 bg-gray-100 rounded animate-pulse hidden sm:block"></div>
|
|
<div class="w-14 h-3 bg-gray-100 rounded animate-pulse"></div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**Sidebar skeleton** (matches `pl-7 py-1` tree indent):
|
|
```html
|
|
<div class="pl-7 py-1 space-y-1">
|
|
<div v-for="n in 3" :key="n" class="flex items-center gap-2 py-1">
|
|
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
|
|
<div class="h-3 bg-gray-100 rounded animate-pulse" :style="{ width: (50 + n * 15) + 'px' }"></div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
### 4. Options API Component Structure
|
|
|
|
**Apply to:** All NEW components (AppIcon, EmptyState, BreadcrumbBar, ToastContainer, OsDragOverlay)
|
|
|
|
**Reference:** AppSidebar.vue (lines 235-313) — the project's canonical Options API component style:
|
|
```javascript
|
|
// Standard Options API structure for new components
|
|
export default {
|
|
name: 'ComponentName',
|
|
components: { /* imports */ },
|
|
props: {
|
|
propName: { type: String, required: true },
|
|
optionalProp: { type: Boolean, default: false },
|
|
},
|
|
emits: ['event-name'],
|
|
data() {
|
|
return { localState: null }
|
|
},
|
|
computed: {
|
|
derivedValue() { return this.propName + '...' }
|
|
},
|
|
methods: {
|
|
handleAction() { this.$emit('event-name', payload) }
|
|
},
|
|
mounted() { /* setup */ },
|
|
beforeUnmount() { /* cleanup */ },
|
|
}
|
|
```
|
|
|
|
**Exception:** `BreadcrumbBar.vue` can use `<script setup>` since it directly replaces `FolderBreadcrumb.vue` which already uses `<script setup>`.
|
|
|
|
---
|
|
|
|
### 5. Pinia Store Pattern (setup store)
|
|
|
|
**Source:** `frontend/src/stores/toast.js` (stub) — already uses setup-store form `defineStore('id', () => { ... })`
|
|
**Apply to:** toast.js implementation only
|
|
|
|
The setup-store form is the correct pattern for toast.js because the stub already uses it. Do not convert to options-store.
|
|
|
|
---
|
|
|
|
## No Analog Found
|
|
|
|
| File | Role | Data Flow | Reason |
|
|
|---|---|---|---|
|
|
| `<TransitionGroup name="toast">` CSS | utility | — | No existing CSS transitions in the project for stacked notifications; planner must define `toast-enter-active`, `toast-leave-active` CSS in ToastContainer.vue `<style>` |
|
|
|
|
---
|
|
|
|
## Metadata
|
|
|
|
**Analog search scope:** `frontend/src/components/`, `frontend/src/views/`, `frontend/src/stores/`
|
|
**Files read:** 18 source files read in full
|
|
**Pattern extraction date:** 2026-06-14
|