Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
/dist
|
||||
/.env.local
|
||||
@@ -0,0 +1,25 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist ./
|
||||
|
||||
ENV API_BASE_URL=""
|
||||
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Papercrate Frontend
|
||||
|
||||
A minimal Webpack-powered SPA to interact with the Papercrate Milestone 1 backend.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Backend API running locally on `http://127.0.0.1:3000`
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- Starts `webpack-dev-server` on <http://localhost:5173>
|
||||
- Proxies `/api` requests to the backend (no CORS needed)
|
||||
- Edit files in `src/` and the browser reloads automatically
|
||||
|
||||
## Production Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
- Output written to `dist/`
|
||||
- Set `API_BASE_URL` in `.env.local` if the API is not served from the same origin.
|
||||
|
||||
## Features
|
||||
|
||||
- Finder-style layout: folder tree, document table, and detail pane with metadata & tags
|
||||
- Drag-and-drop moves (documents between folders) and file uploads (window-wide or onto a folder)
|
||||
- Search box plus tag chips filter documents across the selected folder and all descendants
|
||||
- Tag management (create/assign/remove) from the detail panel
|
||||
- Login via the seeded admin account (`admin` / `adminadmin`) with stored JWT session
|
||||
- Inline status banner for quick feedback on API interactions
|
||||
|
||||
## Assets
|
||||
|
||||
- The folder icon (`public/folder.svg`) is derived from the Adwaita icon theme by the [GNOME Project](http://www.gnome.org/).
|
||||
@@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
targets: 'defaults',
|
||||
},
|
||||
],
|
||||
[
|
||||
'@babel/preset-react',
|
||||
{
|
||||
runtime: 'automatic',
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
API_BASE_URL_TRIMMED="${API_BASE_URL:-}"
|
||||
API_BASE_URL_TRIMMED="${API_BASE_URL_TRIMMED%%/}"
|
||||
|
||||
cat <<CONFIG > /usr/share/nginx/html/config.js
|
||||
window.__PAPERCRATE_API_BASE_URL = "${API_BASE_URL_TRIMMED}";
|
||||
CONFIG
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,11 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+7171
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "papercrate-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Rudimentary Webpack SPA for Papercrate Milestone 1",
|
||||
"scripts": {
|
||||
"dev": "webpack serve --mode development --open",
|
||||
"build": "webpack --mode production",
|
||||
"lint": "echo \"No linting configured\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "3.11.0",
|
||||
"axios": "1.7.7",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.26.0",
|
||||
"@babel/preset-env": "7.26.0",
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"babel-loader": "9.2.1",
|
||||
"css-loader": "7.1.2",
|
||||
"dotenv": "16.4.5",
|
||||
"html-webpack-plugin": "5.6.3",
|
||||
"style-loader": "4.0.0",
|
||||
"webpack": "5.95.0",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
window.__PAPERCRATE_API_BASE_URL = window.__PAPERCRATE_API_BASE_URL || '';
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<linearGradient id="a" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979">
|
||||
<stop offset="0" stop-color="#62a0ea"/>
|
||||
<stop offset="0.0576991" stop-color="#afd4ff"/>
|
||||
<stop offset="0.122204" stop-color="#62a0ea"/>
|
||||
<stop offset="0.873306" stop-color="#62a0ea"/>
|
||||
<stop offset="0.955997" stop-color="#c0d5ea"/>
|
||||
<stop offset="1" stop-color="#62a0ea"/>
|
||||
</linearGradient>
|
||||
<path d="m 21.976562 12 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 86.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -72.085938 c 0 -6.628906 -5.359375 -12 -11.972656 -12 h -46.027344 c -2.453125 0 -4.695312 -1.386718 -5.796875 -3.582031 l -1.503906 -2.992187 c -1.65625 -3.292969 -5.019531 -5.371094 -8.699219 -5.371094 z m 0 0" fill="#438de6"/>
|
||||
<path d="m 65.976562 36 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 54.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -62.03125 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="url(#a)"/>
|
||||
<path d="m 65.976562 32 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 55.976562 c 0 5.539063 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.460937 9.980469 -10 v -63.976562 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="#a4caee"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,226 @@
|
||||
export const getAssetFromGroup = (assets, assetType) => {
|
||||
if (!assetType || !assets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(assets)) {
|
||||
return assets.find((entry) => entry?.asset_type === assetType) || null;
|
||||
}
|
||||
|
||||
return assets?.[assetType] || null;
|
||||
};
|
||||
|
||||
export const getAssetFromVersion = (currentVersion, assetType) => {
|
||||
if (!currentVersion) {
|
||||
return null;
|
||||
}
|
||||
return getAssetFromGroup(currentVersion.assets, assetType);
|
||||
};
|
||||
|
||||
export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, ensureOptions } = {}) => {
|
||||
if (!doc || !type) {
|
||||
return null;
|
||||
}
|
||||
const asset = typeof getAsset === 'function' ? getAsset(doc, type) : null;
|
||||
if (!asset) {
|
||||
return null;
|
||||
}
|
||||
const now = Date.now();
|
||||
const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
|
||||
const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now);
|
||||
if (hasFreshUrl) {
|
||||
return asset.url;
|
||||
}
|
||||
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
|
||||
const force = Boolean(asset.url && expiresAt && expiresAt <= now);
|
||||
const options = ensureOptions ? { ...ensureOptions, force } : { force };
|
||||
ensureAssetUrl(doc.id, asset, options).catch(() => {});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
class AssetManager {
|
||||
constructor({ api, assetPresignTtlMs }) {
|
||||
this.api = api;
|
||||
this.assetPresignTtlMs = assetPresignTtlMs;
|
||||
this.assetCache = new Map();
|
||||
this.assetInflight = new Map();
|
||||
}
|
||||
|
||||
setApi(api) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
rememberAsset(entry) {
|
||||
if (entry?.id) {
|
||||
this.assetCache.set(entry.id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
hydrateAsset(asset) {
|
||||
if (!asset || !asset.id) {
|
||||
return asset;
|
||||
}
|
||||
const cached = this.assetCache.get(asset.id);
|
||||
if (!cached) {
|
||||
return asset;
|
||||
}
|
||||
const merged = { ...cached, ...asset };
|
||||
if (cached.url && !asset.url) {
|
||||
merged.url = cached.url;
|
||||
}
|
||||
if (cached.expiresAt) {
|
||||
const cachedExpires = Number(cached.expiresAt) || null;
|
||||
const assetExpires = Number(asset.expiresAt) || null;
|
||||
if (!assetExpires || (cachedExpires && cachedExpires > assetExpires)) {
|
||||
merged.expiresAt = cachedExpires;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
hydrateDocument(document) {
|
||||
if (!document) {
|
||||
return document;
|
||||
}
|
||||
|
||||
const currentVersion = document.current_version || null;
|
||||
if (!currentVersion) {
|
||||
return document;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
let nextAssets = currentVersion.assets;
|
||||
|
||||
if (nextAssets && !Array.isArray(nextAssets)) {
|
||||
const hydrated = {};
|
||||
Object.keys(nextAssets).forEach((key) => {
|
||||
hydrated[key] = this.hydrateAsset(nextAssets[key]);
|
||||
if (hydrated[key] !== nextAssets[key]) {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
nextAssets = { ...nextAssets, ...hydrated };
|
||||
}
|
||||
} else if (Array.isArray(nextAssets)) {
|
||||
const hydratedList = nextAssets.map((item) => this.hydrateAsset(item));
|
||||
if (
|
||||
hydratedList.length !== nextAssets.length ||
|
||||
hydratedList.some((item, index) => item !== nextAssets[index])
|
||||
) {
|
||||
changed = true;
|
||||
nextAssets = hydratedList;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return document;
|
||||
}
|
||||
|
||||
const nextCurrentVersion = { ...currentVersion, assets: nextAssets };
|
||||
return { ...document, current_version: nextCurrentVersion };
|
||||
}
|
||||
|
||||
hydrateDocuments(documents) {
|
||||
if (!Array.isArray(documents)) {
|
||||
return documents;
|
||||
}
|
||||
return documents.map((doc) => this.hydrateDocument(doc));
|
||||
}
|
||||
|
||||
hydrateDetail(detail) {
|
||||
if (!detail) {
|
||||
return detail;
|
||||
}
|
||||
let changed = false;
|
||||
const next = { ...detail };
|
||||
|
||||
if (detail.document) {
|
||||
const hydratedDocument = this.hydrateDocument(detail.document);
|
||||
if (hydratedDocument !== detail.document) {
|
||||
next.document = hydratedDocument;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(detail.assets)) {
|
||||
const hydratedAssets = detail.assets.map((item) => this.hydrateAsset(item));
|
||||
if (
|
||||
hydratedAssets.length !== detail.assets.length ||
|
||||
hydratedAssets.some((item, index) => item !== detail.assets[index])
|
||||
) {
|
||||
next.assets = hydratedAssets;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : detail;
|
||||
}
|
||||
|
||||
hydrateFolderContents(contents) {
|
||||
if (!contents) {
|
||||
return contents;
|
||||
}
|
||||
const next = { ...contents };
|
||||
if (Array.isArray(contents.documents)) {
|
||||
next.documents = this.hydrateDocuments(contents.documents);
|
||||
}
|
||||
if (contents.document) {
|
||||
next.document = this.hydrateDocument(contents.document);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
ensureAsset(documentId, asset, { force = false } = {}) {
|
||||
if (!documentId || !asset?.id) {
|
||||
return Promise.resolve(asset || null);
|
||||
}
|
||||
|
||||
const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
|
||||
if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) {
|
||||
this.rememberAsset(asset);
|
||||
return Promise.resolve(asset);
|
||||
}
|
||||
|
||||
const cached = this.assetCache.get(asset.id);
|
||||
const now = Date.now();
|
||||
if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) {
|
||||
return Promise.resolve({ ...asset, ...cached });
|
||||
}
|
||||
|
||||
const inflightKey = `${documentId}:${asset.id}`;
|
||||
if (!force && this.assetInflight.has(inflightKey)) {
|
||||
return this.assetInflight.get(inflightKey);
|
||||
}
|
||||
|
||||
if (!this.api) {
|
||||
return Promise.reject(new Error('AssetManager API client is not configured.'));
|
||||
}
|
||||
|
||||
const request = this.api
|
||||
.get(`/documents/${documentId}/assets/${asset.id}`)
|
||||
.then(({ data }) => {
|
||||
const entry = {
|
||||
...asset,
|
||||
...data,
|
||||
expiresAt: Date.now() + this.assetPresignTtlMs,
|
||||
};
|
||||
this.rememberAsset(entry);
|
||||
return entry;
|
||||
})
|
||||
.finally(() => {
|
||||
this.assetInflight.delete(inflightKey);
|
||||
});
|
||||
|
||||
this.assetInflight.set(inflightKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.assetCache.clear();
|
||||
this.assetInflight.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export default AssetManager;
|
||||
@@ -0,0 +1,3 @@
|
||||
export const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
|
||||
|
||||
export default CORRESPONDENT_ROLES;
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
function CorrespondentsPanel({
|
||||
correspondents = [],
|
||||
onRefresh,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onNotify,
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [draftName, setDraftName] = useState('');
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
|
||||
const startEdit = useCallback((correspondent) => {
|
||||
setEditingId(correspondent.id);
|
||||
setDraftName(correspondent.name || '');
|
||||
}, []);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setDraftName('');
|
||||
setSaving(false);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!editingId) return;
|
||||
const trimmed = draftName.trim();
|
||||
if (!trimmed) {
|
||||
onNotify?.('Correspondent name cannot be empty.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onUpdate(editingId, { name: trimmed });
|
||||
cancelEdit();
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (correspondent) => {
|
||||
if (!correspondent?.id) return;
|
||||
setDeletingId(correspondent.id);
|
||||
try {
|
||||
await onDelete(correspondent.id);
|
||||
if (editingId === correspondent.id) {
|
||||
cancelEdit();
|
||||
}
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {
|
||||
return;
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
[onDelete, editingId, cancelEdit],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = createName.trim();
|
||||
if (!trimmed) {
|
||||
onNotify?.('Correspondent name cannot be empty.', 'error');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await onCreate({ name: trimmed });
|
||||
setCreateName('');
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {
|
||||
return;
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
},
|
||||
[createName, onCreate, onNotify],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
},
|
||||
[handleSave, cancelEdit],
|
||||
);
|
||||
|
||||
const renderUsage = useCallback((usage) => {
|
||||
if (!usage) {
|
||||
return '0';
|
||||
}
|
||||
const total = typeof usage.total === 'number' ? usage.total : 0;
|
||||
const entries = usage.by_role ? Object.entries(usage.by_role) : [];
|
||||
if (!entries.length) {
|
||||
return total.toString();
|
||||
}
|
||||
const roleSummary = entries
|
||||
.map(([role, count]) => `${role}: ${count}`)
|
||||
.join(', ');
|
||||
return `${total} (${roleSummary})`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="correspondents-panel column">
|
||||
<div className="column-header">
|
||||
<div className="column-header__titles">
|
||||
<h2>Correspondents</h2>
|
||||
<div className="column-subtitle">{correspondents.length} total</div>
|
||||
</div>
|
||||
<div className="header-actions correspondents-actions">
|
||||
<form className="correspondents-actions__form" onSubmit={handleCreate}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New correspondent name"
|
||||
value={createName}
|
||||
onChange={(event) => setCreateName(event.target.value)}
|
||||
disabled={creating}
|
||||
/>
|
||||
<button type="submit" disabled={creating || !createName.trim()}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={saving || creating || Boolean(deletingId)}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column-body tags-panel__body">
|
||||
{correspondents.length === 0 ? (
|
||||
<div className="empty-state">No correspondents created yet.</div>
|
||||
) : (
|
||||
<div className="tags-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col" className="numeric">
|
||||
Usage
|
||||
</th>
|
||||
<th scope="col" className="actions">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{correspondents.map((correspondent) => {
|
||||
const isEditing = editingId === correspondent.id;
|
||||
return (
|
||||
<tr key={correspondent.id} className={isEditing ? 'editing' : ''}>
|
||||
<td className="tags-table__label">
|
||||
{isEditing ? (
|
||||
<input
|
||||
className="tags-table__label-input"
|
||||
value={draftName}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={saving}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span>{correspondent.name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="numeric">{renderUsage(correspondent.usage)}</td>
|
||||
<td className="actions">
|
||||
{isEditing ? (
|
||||
<div className="tags-table__edit-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={cancelEdit}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tags-table__row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => startEdit(correspondent)}
|
||||
disabled={deletingId === correspondent.id}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => handleDelete(correspondent)}
|
||||
disabled={deletingId === correspondent.id}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CorrespondentsPanel;
|
||||
@@ -0,0 +1,926 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { DownloadIcon, EditIcon } from '../ui/icons';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { formatFileSize } from '../utils/format';
|
||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||
import { CORRESPONDENT_ROLES } from '../constants/correspondents';
|
||||
|
||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||
|
||||
const normalizeRole = (role) => (role || '').toLowerCase();
|
||||
|
||||
const formatRoleLabel = (role) => {
|
||||
const normalized = normalizeRole(role);
|
||||
if (!normalized) return 'Other';
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||
};
|
||||
|
||||
const compareCorrespondents = (a, b) => {
|
||||
const indexA = CORRESPONDENT_ROLES.indexOf(a.role);
|
||||
const indexB = CORRESPONDENT_ROLES.indexOf(b.role);
|
||||
const rankedA = indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA;
|
||||
const rankedB = indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB;
|
||||
if (rankedA !== rankedB) {
|
||||
return rankedA - rankedB;
|
||||
}
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
};
|
||||
|
||||
const sortCorrespondents = (entries = []) =>
|
||||
entries
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name || '',
|
||||
role: normalizeRole(entry.role),
|
||||
}))
|
||||
.sort(compareCorrespondents);
|
||||
|
||||
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
||||
<div className="correspondent-list">
|
||||
{entries.length ? (
|
||||
entries.map((entry) => (
|
||||
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
|
||||
<span className="correspondent-pill__label">
|
||||
<strong>{formatRoleLabel(entry.role)}</strong>
|
||||
<span>
|
||||
{entry.name}
|
||||
{showCount && entry.count ? ` (${entry.count})` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="correspondent-pill__remove"
|
||||
onClick={() => onRemove(entry)}
|
||||
aria-label={`Remove ${entry.name} as ${formatRoleLabel(entry.role)}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="meta">No correspondents yet.</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const TagSection = ({
|
||||
title,
|
||||
tags = [],
|
||||
onRemove,
|
||||
onAdd,
|
||||
emptyMessage = 'No tags yet.',
|
||||
addPlaceholder = 'Add or create tag',
|
||||
addButtonLabel = 'Add',
|
||||
datalistId,
|
||||
datalistOptions = [],
|
||||
className,
|
||||
}) => (
|
||||
<div className={className}>
|
||||
<dt>{title}</dt>
|
||||
<div className="tag-list">
|
||||
{tags.length ? (
|
||||
tags.map((tag) => {
|
||||
const key = tag.id ?? tag.label;
|
||||
const style = getTagColorStyle(tag.color);
|
||||
return (
|
||||
<span key={key} className="tag-pill" style={style || undefined}>
|
||||
{tag.label}{' '}
|
||||
{onRemove ? (
|
||||
<button type="button" onClick={() => onRemove(tag)}>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="meta">{emptyMessage}</span>
|
||||
)}
|
||||
</div>
|
||||
{onAdd ? (
|
||||
<form
|
||||
className="inline"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const input = event.currentTarget.elements.tag;
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
onAdd({ value, input });
|
||||
}}
|
||||
>
|
||||
<input name="tag" placeholder={addPlaceholder} list={datalistId} />
|
||||
<button type="submit">{addButtonLabel}</button>
|
||||
{datalistId ? (
|
||||
<datalist id={datalistId}>
|
||||
{datalistOptions.map((option) => (
|
||||
<option key={option.id || option.label || option} value={option.label || option} />
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const CorrespondentSection = ({
|
||||
title,
|
||||
entries = [],
|
||||
onRemove,
|
||||
onAdd,
|
||||
showCount = false,
|
||||
addPlaceholder = 'Add or create correspondent',
|
||||
addButtonLabel = 'Add',
|
||||
datalistId,
|
||||
datalistOptions = [],
|
||||
className,
|
||||
}) => (
|
||||
<div className={className}>
|
||||
<dt>{title}</dt>
|
||||
<CorrespondentPills entries={entries} onRemove={onRemove} showCount={showCount} />
|
||||
{onAdd ? (
|
||||
<form
|
||||
className="correspondent-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const nameInput = form.elements.correspondent;
|
||||
const roleSelect = form.elements.role;
|
||||
const value = nameInput.value.trim();
|
||||
const role = roleSelect.value;
|
||||
if (!value) return;
|
||||
onAdd({ name: value, role, input: nameInput });
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
name="correspondent"
|
||||
placeholder={addPlaceholder}
|
||||
list={datalistId}
|
||||
/>
|
||||
<select name="role" defaultValue={CORRESPONDENT_ROLES[0]}>
|
||||
{CORRESPONDENT_ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit">{addButtonLabel}</button>
|
||||
{datalistId ? (
|
||||
<datalist id={datalistId}>
|
||||
{datalistOptions.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const computeStackAngle = (docId, index) => {
|
||||
if (index === 0) return 0;
|
||||
let hash = 0;
|
||||
const source = docId || `stack-${index}`;
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
hash = (hash * 31 + source.charCodeAt(i)) % 997;
|
||||
}
|
||||
const magnitude = Math.max(3, (hash % 13) + 3);
|
||||
const sign = index % 2 === 0 ? 1 : -1;
|
||||
return magnitude * sign;
|
||||
};
|
||||
|
||||
const PreviewStack = ({
|
||||
items = [],
|
||||
maxItems = MAX_PREVIEW_STACK_ITEMS,
|
||||
emptyMessage = 'Preview unavailable',
|
||||
onItemActivate,
|
||||
onOpenPreview,
|
||||
activeItemId = null,
|
||||
}) => {
|
||||
if (!items.length) {
|
||||
return <span className="meta">{emptyMessage}</span>;
|
||||
}
|
||||
|
||||
const limited = items.slice(0, maxItems);
|
||||
const hasMultiple = limited.length > 1;
|
||||
const preparedItems = useMemo(
|
||||
() =>
|
||||
limited.map((entry, index) => ({
|
||||
entry,
|
||||
angle: index === 0 ? 0 : computeStackAngle(entry.id, index),
|
||||
})),
|
||||
[limited],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="preview-stack preview-stack--stacked">
|
||||
{preparedItems.map(({ entry, angle }, index) => {
|
||||
const transform = hasMultiple
|
||||
? `translate(-50%, -50%) rotate(${angle}deg)`
|
||||
: 'translate(-50%, -50%)';
|
||||
const isFront = index === 0;
|
||||
return (
|
||||
<div
|
||||
key={entry.id || index}
|
||||
className={`preview-stack__item orientation-${entry.orientation || 'landscape'}`}
|
||||
style={{
|
||||
zIndex: preparedItems.length - index,
|
||||
transform,
|
||||
}}
|
||||
aria-hidden={hasMultiple && !onItemActivate && !onOpenPreview ? 'true' : undefined}
|
||||
>
|
||||
<img
|
||||
src={entry.url}
|
||||
alt={entry.alt || ''}
|
||||
className="preview-stack__image"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (isFront && onOpenPreview) {
|
||||
onOpenPreview(entry.id);
|
||||
} else if (onItemActivate) {
|
||||
onItemActivate(entry.id);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!onItemActivate && !onOpenPreview) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isFront && onOpenPreview) {
|
||||
onOpenPreview(entry.id);
|
||||
} else {
|
||||
onItemActivate?.(entry.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DetailPanel = ({
|
||||
selectedDocuments = [],
|
||||
tags = [],
|
||||
tagLookupById = new Map(),
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
onRegenerateThumbnails,
|
||||
previewEntry,
|
||||
onOpenPreview,
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
onBulkReanalyze,
|
||||
onBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove,
|
||||
onPromoteSelection,
|
||||
activePreviewId = null,
|
||||
onUpdateTitle = async () => false,
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
correspondents = [],
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
resolveApiPath,
|
||||
}) => {
|
||||
const selectedCount = selectedDocuments.length;
|
||||
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
||||
|
||||
const [titleEditDocId, setTitleEditDocId] = useState(null);
|
||||
const [titleDraft, setTitleDraft] = useState('');
|
||||
const [titleSaving, setTitleSaving] = useState(false);
|
||||
const [titleError, setTitleError] = useState(null);
|
||||
const [ocrOpen, setOcrOpen] = useState(false);
|
||||
const [ocrUrl, setOcrUrl] = useState(null);
|
||||
const [ocrLoading, setOcrLoading] = useState(false);
|
||||
const [ocrError, setOcrError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!singleDoc) {
|
||||
setTitleEditDocId(null);
|
||||
setTitleDraft('');
|
||||
setTitleError(null);
|
||||
setTitleSaving(false);
|
||||
setOcrOpen(false);
|
||||
setOcrLoading(false);
|
||||
setOcrError(null);
|
||||
setOcrUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (titleEditDocId && titleEditDocId !== singleDoc.id) {
|
||||
setTitleEditDocId(null);
|
||||
setTitleDraft('');
|
||||
setTitleError(null);
|
||||
setTitleSaving(false);
|
||||
}
|
||||
}, [singleDoc, titleEditDocId]);
|
||||
|
||||
useEffect(() => {
|
||||
setOcrOpen(false);
|
||||
setOcrLoading(false);
|
||||
setOcrError(null);
|
||||
setOcrUrl(null);
|
||||
}, [singleDoc?.id]);
|
||||
|
||||
const startTitleEdit = useCallback(() => {
|
||||
if (!singleDoc) return;
|
||||
setTitleEditDocId(singleDoc.id);
|
||||
setTitleDraft(singleDoc.title || singleDoc.original_name || '');
|
||||
setTitleError(null);
|
||||
}, [singleDoc]);
|
||||
|
||||
const cancelTitleEdit = useCallback(() => {
|
||||
setTitleEditDocId(null);
|
||||
setTitleDraft('');
|
||||
setTitleError(null);
|
||||
setTitleSaving(false);
|
||||
}, []);
|
||||
|
||||
const submitTitleEdit = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
if (!singleDoc) return;
|
||||
const trimmed = titleDraft.trim();
|
||||
if (!trimmed) {
|
||||
setTitleError('Title cannot be empty.');
|
||||
return;
|
||||
}
|
||||
setTitleSaving(true);
|
||||
try {
|
||||
const ok = await onUpdateTitle(singleDoc.id, trimmed);
|
||||
if (ok) {
|
||||
setTitleEditDocId(null);
|
||||
setTitleDraft('');
|
||||
setTitleError(null);
|
||||
} else {
|
||||
setTitleError('Failed to update title.');
|
||||
}
|
||||
} finally {
|
||||
setTitleSaving(false);
|
||||
}
|
||||
},
|
||||
[singleDoc, titleDraft, onUpdateTitle],
|
||||
);
|
||||
|
||||
const handlePreviewActivate = useCallback(
|
||||
(docId) => {
|
||||
if (!docId) return;
|
||||
onPromoteSelection?.(docId);
|
||||
},
|
||||
[onPromoteSelection],
|
||||
);
|
||||
|
||||
const hasOcrAsset = useMemo(
|
||||
() => Boolean(singleDoc && getDocumentAsset(singleDoc, 'ocr-text')),
|
||||
[singleDoc, getDocumentAsset],
|
||||
);
|
||||
|
||||
const loadOcrUrl = useCallback(async () => {
|
||||
if (!singleDoc) {
|
||||
return;
|
||||
}
|
||||
const asset = getDocumentAsset(singleDoc, 'ocr-text');
|
||||
if (!asset) {
|
||||
setOcrError('No OCR text available for this document.');
|
||||
setOcrUrl(null);
|
||||
return;
|
||||
}
|
||||
setOcrLoading(true);
|
||||
setOcrError(null);
|
||||
try {
|
||||
let entry = asset;
|
||||
let url = entry?.url ||
|
||||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
if (!url && typeof ensureAssetUrl === 'function') {
|
||||
const ensured = await ensureAssetUrl(singleDoc.id, asset, { force: false });
|
||||
if (ensured) {
|
||||
entry = ensured;
|
||||
}
|
||||
url = entry?.url ||
|
||||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
}
|
||||
if (!url) {
|
||||
throw new Error('OCR text URL is unavailable.');
|
||||
}
|
||||
setOcrUrl(url);
|
||||
} catch (error) {
|
||||
setOcrError(error.message || 'Failed to load OCR text.');
|
||||
setOcrUrl(null);
|
||||
} finally {
|
||||
setOcrLoading(false);
|
||||
}
|
||||
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
|
||||
|
||||
const openOcrModal = useCallback(() => {
|
||||
if (!singleDoc) {
|
||||
return;
|
||||
}
|
||||
setOcrOpen(true);
|
||||
loadOcrUrl();
|
||||
}, [singleDoc, loadOcrUrl]);
|
||||
|
||||
const closeOcrModal = useCallback(() => {
|
||||
setOcrOpen(false);
|
||||
}, []);
|
||||
|
||||
const makePreviewItem = useCallback(
|
||||
(doc) => {
|
||||
if (!doc) return null;
|
||||
const url = resolveDocumentAssetUrl(doc, 'preview', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
const asset = getDocumentAsset(doc, 'preview');
|
||||
const width = Number(asset?.metadata?.width) || 0;
|
||||
const height = Number(asset?.metadata?.height) || 0;
|
||||
const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape';
|
||||
return {
|
||||
id: doc.id,
|
||||
url,
|
||||
orientation,
|
||||
alt: doc.title || doc.original_name || 'Document preview',
|
||||
};
|
||||
},
|
||||
[ensureAssetUrl, getDocumentAsset],
|
||||
);
|
||||
|
||||
const stackDocuments = useMemo(() => {
|
||||
if (!selectedDocuments.length) return [];
|
||||
const seen = new Set();
|
||||
const ordered = [];
|
||||
for (let index = selectedDocuments.length - 1; index >= 0; index -= 1) {
|
||||
const doc = selectedDocuments[index];
|
||||
if (!doc?.id || seen.has(doc.id)) continue;
|
||||
seen.add(doc.id);
|
||||
ordered.push(doc);
|
||||
if (ordered.length >= MAX_PREVIEW_STACK_ITEMS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
}, [selectedDocuments]);
|
||||
|
||||
const singlePreviewItems = useMemo(() => {
|
||||
if (!singleDoc) return [];
|
||||
const item = makePreviewItem(singleDoc);
|
||||
return item ? [item] : [];
|
||||
}, [singleDoc, makePreviewItem]);
|
||||
|
||||
const stackPreviews = useMemo(
|
||||
() =>
|
||||
stackDocuments
|
||||
.map((doc) => makePreviewItem(doc))
|
||||
.filter(Boolean),
|
||||
[stackDocuments, makePreviewItem],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ensureAssetUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
stackDocuments.forEach((doc) => {
|
||||
resolveDocumentAssetUrl(doc, 'preview', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
});
|
||||
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]);
|
||||
|
||||
const bulkTagUnion = useMemo(() => {
|
||||
if (!selectedDocuments.length) return [];
|
||||
const tagMap = new Map();
|
||||
selectedDocuments.forEach((doc) => {
|
||||
(doc.tags || []).forEach((tag) => {
|
||||
const label = (tag?.label || '').trim();
|
||||
if (!label) return;
|
||||
if (!tagMap.has(label)) {
|
||||
const fallback = tagLookupById.get(tag.id) || {};
|
||||
tagMap.set(label, {
|
||||
id: tag.id,
|
||||
label,
|
||||
color: tag.color || fallback.color,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return [...tagMap.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [selectedDocuments, tagLookupById]);
|
||||
|
||||
const stackTotalSizeBytes = useMemo(() => {
|
||||
if (!stackPreviews.length) return 0;
|
||||
const byId = new Map(selectedDocuments.map((doc) => [doc.id, doc]));
|
||||
return stackPreviews.reduce((sum, item) => {
|
||||
const source = byId.get(item.id);
|
||||
const bytes = source?.current_version?.size_bytes;
|
||||
return sum + (typeof bytes === 'number' ? bytes : 0);
|
||||
}, 0);
|
||||
}, [stackPreviews, selectedDocuments]);
|
||||
|
||||
const availableCorrespondents = useMemo(
|
||||
() => (Array.isArray(correspondents) ? correspondents : []),
|
||||
[correspondents],
|
||||
);
|
||||
|
||||
const correspondentOptions = useMemo(() => {
|
||||
const seen = new Set();
|
||||
return availableCorrespondents
|
||||
.map((entry) => (entry?.name || '').trim())
|
||||
.filter((name) => {
|
||||
if (!name) return false;
|
||||
const lower = name.toLowerCase();
|
||||
if (seen.has(lower)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(lower);
|
||||
return true;
|
||||
});
|
||||
}, [availableCorrespondents]);
|
||||
|
||||
const singleCorrespondents = useMemo(() => {
|
||||
if (!singleDoc) return [];
|
||||
return sortCorrespondents(singleDoc.correspondents || []);
|
||||
}, [singleDoc]);
|
||||
|
||||
const bulkCorrespondents = useMemo(() => {
|
||||
if (selectedDocuments.length <= 1) {
|
||||
const doc = selectedDocuments[0];
|
||||
return doc ? sortCorrespondents(doc.correspondents || []) : [];
|
||||
}
|
||||
|
||||
const map = new Map();
|
||||
selectedDocuments.forEach((doc) => {
|
||||
if (!doc?.id) return;
|
||||
(doc.correspondents || []).forEach((entry) => {
|
||||
if (!entry?.id) return;
|
||||
const normalizedRole = normalizeRole(entry.role);
|
||||
const key = `${entry.id}:${normalizedRole}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
id: entry.id,
|
||||
name: entry.name || '',
|
||||
role: normalizedRole,
|
||||
documentIds: new Set(),
|
||||
});
|
||||
}
|
||||
map.get(key).documentIds.add(doc.id);
|
||||
});
|
||||
});
|
||||
|
||||
return [...map.values()]
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
role: entry.role,
|
||||
documentIds: [...entry.documentIds],
|
||||
count: entry.documentIds.size,
|
||||
}))
|
||||
.sort(compareCorrespondents);
|
||||
}, [selectedDocuments]);
|
||||
|
||||
const handleBulkCorrespondentRemove = useCallback(
|
||||
(entry) => {
|
||||
if (!entry?.id) return;
|
||||
const normalizedRole = normalizeRole(entry.role);
|
||||
if (onBulkCorrespondentRemove) {
|
||||
return onBulkCorrespondentRemove({
|
||||
assignments: [
|
||||
{
|
||||
correspondent_id: entry.id,
|
||||
role: normalizedRole,
|
||||
},
|
||||
],
|
||||
documentIds: entry.documentIds,
|
||||
});
|
||||
}
|
||||
|
||||
if (!onCorrespondentRemove) return;
|
||||
const targets = entry.documentIds && entry.documentIds.length
|
||||
? entry.documentIds
|
||||
: selectedDocuments
|
||||
.filter((doc) =>
|
||||
(doc.correspondents || []).some(
|
||||
(item) => item.id === entry.id && normalizeRole(item.role) === normalizedRole,
|
||||
),
|
||||
)
|
||||
.map((doc) => doc.id);
|
||||
|
||||
return Promise.all(
|
||||
targets.map((documentId) =>
|
||||
onCorrespondentRemove({
|
||||
documentId,
|
||||
correspondentId: entry.id,
|
||||
role: normalizedRole,
|
||||
}),
|
||||
),
|
||||
).catch(() => {});
|
||||
},
|
||||
[bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
||||
);
|
||||
|
||||
const renderSingle = () => {
|
||||
if (!singleDoc) {
|
||||
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||||
}
|
||||
|
||||
const displayName = singleDoc.title || singleDoc.original_name;
|
||||
const downloadHref = singleDoc.current_version?.download_path
|
||||
? resolveApiPath?.(singleDoc.current_version.download_path)
|
||||
: null;
|
||||
const isEditingTitle = titleEditDocId === singleDoc.id;
|
||||
const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0;
|
||||
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
|
||||
const issuedAt = singleDoc.issued_at
|
||||
? new Date(singleDoc.issued_at).toLocaleString()
|
||||
: '—';
|
||||
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
|
||||
const metadata =
|
||||
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="preview-pane preview-pane--stack">
|
||||
<PreviewStack
|
||||
items={singlePreviewItems}
|
||||
maxItems={1}
|
||||
emptyMessage="Preview loading…"
|
||||
onItemActivate={handlePreviewActivate}
|
||||
onOpenPreview={onOpenPreview}
|
||||
activeItemId={activePreviewId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="doc-title-row">
|
||||
{isEditingTitle ? (
|
||||
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
|
||||
<input
|
||||
value={titleDraft}
|
||||
onChange={(event) => {
|
||||
setTitleDraft(event.target.value);
|
||||
if (titleError) {
|
||||
setTitleError(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelTitleEdit();
|
||||
}
|
||||
}}
|
||||
aria-label="Document title"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={titleSaving}>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={cancelTitleEdit}
|
||||
disabled={titleSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<h3 style={{ margin: 0 }}>{displayName}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={startTitleEdit}
|
||||
aria-label="Edit title"
|
||||
title="Edit title"
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||
<div className="meta">
|
||||
<div>
|
||||
<strong>Uploaded:</strong>{' '}
|
||||
{singleDoc.uploaded_at ? new Date(singleDoc.uploaded_at).toLocaleString() : '—'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Size:</strong>{' '}
|
||||
{sizeLabel}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Type:</strong> {singleDoc.content_type || 'Unknown'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Issued:</strong> {issuedAt}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Original filename:</strong>{' '}
|
||||
{singleDoc.original_name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-actions">
|
||||
<a
|
||||
className="button-link with-icon"
|
||||
href={downloadHref || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-disabled={!downloadHref}
|
||||
onClick={(event) => {
|
||||
if (!downloadHref) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="icon-inline" />
|
||||
<span>Download</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => onOpenPreview(singleDoc.id)}
|
||||
>
|
||||
Open preview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => onRegenerateThumbnails(singleDoc.id)}
|
||||
>
|
||||
Re-run analysis
|
||||
</button>
|
||||
</div>
|
||||
{hasOcrAsset ? (
|
||||
<div className="detail-ocr-trigger">
|
||||
<button type="button" className="secondary" onClick={openOcrModal}>
|
||||
View OCR text
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<TagSection
|
||||
title="Tags"
|
||||
tags={tagsForDoc.map((tag) => ({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color || tagLookupById.get(tag.id)?.color,
|
||||
}))}
|
||||
onRemove={(tag) => onTagRemove(singleDoc.id, tag.id)}
|
||||
onAdd={({ value, input }) => onTagAdd(singleDoc, value, input)}
|
||||
datalistId="tag-catalog-single"
|
||||
datalistOptions={tags}
|
||||
/>
|
||||
<CorrespondentSection
|
||||
title="Correspondents"
|
||||
entries={singleCorrespondents}
|
||||
onRemove={(entry) =>
|
||||
onCorrespondentRemove?.({
|
||||
documentId: singleDoc.id,
|
||||
correspondentId: entry.id,
|
||||
role: entry.role,
|
||||
})
|
||||
}
|
||||
onAdd={({ name, role, input }) =>
|
||||
onCorrespondentAdd?.({
|
||||
document: singleDoc,
|
||||
name,
|
||||
role,
|
||||
input,
|
||||
})
|
||||
}
|
||||
datalistId="correspondent-catalog-single"
|
||||
datalistOptions={correspondentOptions}
|
||||
/>
|
||||
{metadata && (
|
||||
<div>
|
||||
<dt>Metadata</dt>
|
||||
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
{hasOcrAsset && ocrOpen
|
||||
? createPortal(
|
||||
<div className="modal-backdrop" role="presentation" onClick={closeOcrModal}>
|
||||
<div
|
||||
className="modal modal--ocr"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="OCR text"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="modal__header">
|
||||
<h3>OCR Text</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={closeOcrModal}
|
||||
aria-label="Close OCR text"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal__body detail-ocr__content">
|
||||
{ocrLoading ? (
|
||||
<div className="meta">Loading OCR text…</div>
|
||||
) : ocrError ? (
|
||||
<div className="status-inline error">{ocrError}</div>
|
||||
) : ocrUrl ? (
|
||||
<iframe
|
||||
title="OCR text"
|
||||
src={ocrUrl}
|
||||
className="detail-ocr__frame"
|
||||
/>
|
||||
) : (
|
||||
<div className="meta">OCR text empty.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBulk = () => {
|
||||
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="preview-pane preview-pane--stack">
|
||||
<PreviewStack
|
||||
items={stackPreviews}
|
||||
emptyMessage="No previews available."
|
||||
onItemActivate={handlePreviewActivate}
|
||||
onOpenPreview={onOpenPreview}
|
||||
activeItemId={activePreviewId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
||||
<div className="meta">
|
||||
<div>
|
||||
<strong>Total size (stack):</strong> {sizeLabel}
|
||||
</div>
|
||||
</div>
|
||||
<TagSection
|
||||
title="Tags"
|
||||
tags={bulkTagUnion}
|
||||
emptyMessage="No tags assigned."
|
||||
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label })}
|
||||
onAdd={({ value, input }) => onBulkTagAdd?.({ label: value, input })}
|
||||
addPlaceholder="Add tag to selection"
|
||||
addButtonLabel="Add tag"
|
||||
datalistId="tag-catalog-bulk"
|
||||
datalistOptions={tags}
|
||||
className="bulk-tags"
|
||||
/>
|
||||
<CorrespondentSection
|
||||
title="Correspondents"
|
||||
entries={bulkCorrespondents}
|
||||
onRemove={handleBulkCorrespondentRemove}
|
||||
onAdd={({ name, role, input }) =>
|
||||
onBulkCorrespondentAdd?.({ name, role, input })
|
||||
}
|
||||
addPlaceholder="Add correspondent to selection"
|
||||
datalistId="correspondent-catalog-bulk"
|
||||
datalistOptions={correspondentOptions}
|
||||
showCount
|
||||
className="bulk-correspondents"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => onBulkReanalyze?.()}
|
||||
>
|
||||
Re-analyze selection
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="detail-panel column">
|
||||
<div className="column-body scrollable">
|
||||
{selectedCount <= 1 ? renderSingle() : renderBulk()}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailPanel;
|
||||
@@ -0,0 +1,786 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon } from '../ui/icons';
|
||||
|
||||
const FOLDER_ICON_SRC = '/folder.svg';
|
||||
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
|
||||
const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
|
||||
const url = useMemo(
|
||||
() =>
|
||||
resolveDocumentAssetUrl(document, 'thumbnail', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
}),
|
||||
[document, ensureAssetUrl, getDocumentAsset],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="document-thumbnail-wrapper">
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt || ''}
|
||||
className="document-thumbnail"
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
/>
|
||||
) : (
|
||||
<div className="thumb-placeholder">DOC</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentsTable = ({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace = () => {},
|
||||
onRequestCreateFolder,
|
||||
creatingFolder = false,
|
||||
subfolders,
|
||||
documents,
|
||||
searchResults,
|
||||
isFilterActive,
|
||||
onFolderSelect,
|
||||
onFolderDrop,
|
||||
onFolderDragOver,
|
||||
onFolderDragLeave,
|
||||
onFolderDragStart,
|
||||
onFolderDragEnd,
|
||||
draggedFolderId,
|
||||
onFolderDelete,
|
||||
selectedFolderIds = [],
|
||||
onFolderRowClick,
|
||||
onDocumentRowClick,
|
||||
onDocumentOpen,
|
||||
selectedDocumentIds,
|
||||
focusedDocumentId,
|
||||
focusedRowKey,
|
||||
draggingDocumentIds = [],
|
||||
onDocumentDragStart,
|
||||
onDocumentDragEnd,
|
||||
onDocumentDelete,
|
||||
onFolderRename,
|
||||
onDocumentRename,
|
||||
tagLookupById,
|
||||
onDocumentListFocus,
|
||||
onDocumentListKeyDown,
|
||||
onFocusedRowChange,
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
getDownloadHref,
|
||||
onTagClick,
|
||||
isSearchLoading = false,
|
||||
onDocumentTagDrop,
|
||||
viewMode = 'list',
|
||||
onViewModeChange,
|
||||
onClearSelection,
|
||||
}) => {
|
||||
const showingSearchResults = searchResults !== null;
|
||||
const rows = showingSearchResults ? searchResults : documents;
|
||||
|
||||
const selectedSet = useMemo(
|
||||
() => new Set(selectedDocumentIds),
|
||||
[selectedDocumentIds],
|
||||
);
|
||||
const selectedFolderSet = useMemo(
|
||||
() => new Set(selectedFolderIds || []),
|
||||
[selectedFolderIds],
|
||||
);
|
||||
const draggingSet = useMemo(
|
||||
() => new Set(draggingDocumentIds || []),
|
||||
[draggingDocumentIds],
|
||||
);
|
||||
const scrollRef = useRef(null);
|
||||
const isGridView = viewMode === 'grid';
|
||||
const handleSetViewMode = useCallback(
|
||||
(nextMode) => {
|
||||
if (!onViewModeChange) {
|
||||
return;
|
||||
}
|
||||
onViewModeChange(nextMode);
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
[onViewModeChange],
|
||||
);
|
||||
const isTagDragEvent = useCallback((event) => {
|
||||
const types = Array.from(event.dataTransfer?.types || []);
|
||||
return TAG_MIME_TYPES.some((type) => types.includes(type));
|
||||
}, []);
|
||||
const ensureFocusedRowVisible = useCallback(() => {
|
||||
if (!focusedRowKey) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
let selector = null;
|
||||
if (focusedRowKey.startsWith('document:')) {
|
||||
selector = `#document-row-${focusedRowKey.slice('document:'.length)}`;
|
||||
} else if (focusedRowKey.startsWith('folder:')) {
|
||||
selector = `#folder-row-${focusedRowKey.slice('folder:'.length)}`;
|
||||
}
|
||||
if (!selector) {
|
||||
return;
|
||||
}
|
||||
const row = container.querySelector(selector);
|
||||
if (!row || !container.contains(row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const header = container.querySelector('thead');
|
||||
const headerHeight = header ? header.getBoundingClientRect().height : 0;
|
||||
const rowTop = row.offsetTop;
|
||||
const rowBottom = rowTop + row.offsetHeight;
|
||||
const visibleTop = container.scrollTop + headerHeight;
|
||||
const visibleBottom = container.scrollTop + container.clientHeight;
|
||||
|
||||
if (rowTop < visibleTop) {
|
||||
container.scrollTop = Math.max(rowTop - headerHeight, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowBottom > visibleBottom) {
|
||||
const nextScrollTop = rowBottom - container.clientHeight;
|
||||
container.scrollTop = Math.max(nextScrollTop, 0);
|
||||
}
|
||||
}, [focusedRowKey]);
|
||||
|
||||
useEffect(() => {
|
||||
ensureFocusedRowVisible();
|
||||
}, [ensureFocusedRowVisible]);
|
||||
|
||||
const activeDescendantId = useMemo(() => {
|
||||
if (!focusedRowKey) return undefined;
|
||||
if (focusedRowKey.startsWith('document:')) {
|
||||
return `document-row-${focusedRowKey.slice('document:'.length)}`;
|
||||
}
|
||||
if (focusedRowKey.startsWith('folder:')) {
|
||||
return `folder-row-${focusedRowKey.slice('folder:'.length)}`;
|
||||
}
|
||||
return undefined;
|
||||
}, [focusedRowKey]);
|
||||
|
||||
const handleDocumentTagDragOver = useCallback(
|
||||
(event) => {
|
||||
if (!isTagDragEvent(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
event.currentTarget.classList.add('tag-drop-target');
|
||||
},
|
||||
[isTagDragEvent],
|
||||
);
|
||||
|
||||
const handleDocumentTagDragLeave = useCallback(
|
||||
(event) => {
|
||||
if (!isTagDragEvent(event)) {
|
||||
return;
|
||||
}
|
||||
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget.classList.remove('tag-drop-target');
|
||||
},
|
||||
[isTagDragEvent],
|
||||
);
|
||||
|
||||
const handleDocumentTagDrop = useCallback(
|
||||
(event, documentId) => {
|
||||
if (!isTagDragEvent(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.classList.remove('tag-drop-target');
|
||||
const payload =
|
||||
event.dataTransfer.getData('application/x-papercrate-tag') ||
|
||||
event.dataTransfer.getData('text/papercrate-tag');
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
if (parsed?.id && onDocumentTagDrop) {
|
||||
onDocumentTagDrop(documentId, parsed);
|
||||
}
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
},
|
||||
[isTagDragEvent, onDocumentTagDrop],
|
||||
);
|
||||
|
||||
const handleGridBackgroundClick = useCallback(
|
||||
(event) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
onClearSelection?.();
|
||||
},
|
||||
[onClearSelection],
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
||||
>
|
||||
<div className="column-header">
|
||||
<div className="column-header__titles">
|
||||
<nav className="breadcrumb" aria-label="Folder breadcrumbs">
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1;
|
||||
return (
|
||||
<span key={crumb.id} className="breadcrumb-item">
|
||||
{isLast ? (
|
||||
<span className="breadcrumb-current">{crumb.name}</span>
|
||||
) : (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect(crumb.id);
|
||||
}}
|
||||
>
|
||||
{crumb.name}
|
||||
</a>
|
||||
)}
|
||||
{!isLast && <span className="breadcrumb-separator">›</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{showingSearchResults && (
|
||||
<div className="column-subtitle">Search results</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className="view-toggle" role="group" aria-label="Change view">
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
||||
onClick={() => handleSetViewMode('list')}
|
||||
aria-pressed={!isGridView}
|
||||
title="List view"
|
||||
>
|
||||
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||
onClick={() => handleSetViewMode('grid')}
|
||||
aria-pressed={isGridView}
|
||||
title="Icons view"
|
||||
>
|
||||
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRequestCreateFolder}
|
||||
disabled={creatingFolder}
|
||||
>
|
||||
{creatingFolder ? 'Creating…' : 'New folder'}
|
||||
</button>
|
||||
<button className="secondary" onClick={onRefresh}>
|
||||
Refresh
|
||||
</button>
|
||||
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
||||
Desk View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column-body">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="documents-scroll"
|
||||
tabIndex={0}
|
||||
onFocus={(event) => {
|
||||
if (event.target === scrollRef.current) {
|
||||
onDocumentListFocus?.();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== scrollRef.current) {
|
||||
return;
|
||||
}
|
||||
if (onDocumentListKeyDown) {
|
||||
onDocumentListKeyDown(event);
|
||||
}
|
||||
}}
|
||||
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
||||
>
|
||||
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
Drop files anywhere or onto a folder to upload documents.
|
||||
</div>
|
||||
) : isGridView ? (
|
||||
<div
|
||||
className="documents-grid"
|
||||
role="list"
|
||||
onClick={handleGridBackgroundClick}
|
||||
>
|
||||
{!showingSearchResults &&
|
||||
subfolders.map((folder) => {
|
||||
const canDragFolder = folder.id !== 'root';
|
||||
const isDraggingFolder = draggedFolderId === folder.id;
|
||||
const isSelectedFolder = selectedFolderSet.has(folder.id);
|
||||
const classes = ['document-card', 'folder-card'];
|
||||
if (isDraggingFolder) classes.push('is-dragging');
|
||||
if (isSelectedFolder) classes.push('selected');
|
||||
return (
|
||||
<div
|
||||
key={folder.id}
|
||||
className={classes.join(' ')}
|
||||
role="listitem"
|
||||
id={`folder-card-${folder.id}`}
|
||||
draggable={canDragFolder}
|
||||
onClick={(event) => {
|
||||
onFolderRowClick?.(folder.id, event);
|
||||
const shouldNavigate =
|
||||
!event.defaultPrevented &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey;
|
||||
if (shouldNavigate) {
|
||||
onFolderSelect(folder.id);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect(folder.id);
|
||||
}}
|
||||
onDragOver={(event) => onFolderDragOver(event, folder.id)}
|
||||
onDragLeave={onFolderDragLeave}
|
||||
onDrop={(event) => onFolderDrop(event, folder.id)}
|
||||
onDragStart={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragStart(event, folder.id);
|
||||
}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragEnd(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="folder-card__icon">
|
||||
<img
|
||||
src={FOLDER_ICON_SRC}
|
||||
alt="Folder"
|
||||
className="folder-card__icon-svg"
|
||||
/>
|
||||
</div>
|
||||
<div className="folder-card__meta">
|
||||
<div className="folder-card__name" title={folder.name}>
|
||||
{folder.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{rows.map((doc) => {
|
||||
const isSelected = selectedSet.has(doc.id);
|
||||
const isDraggingDoc = draggingSet.has(doc.id);
|
||||
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
|
||||
const visibleTags = tagList.slice(0, 3);
|
||||
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
||||
const cardClasses = ['document-card', 'document'];
|
||||
if (isSelected) cardClasses.push('selected');
|
||||
if (isDraggingDoc) cardClasses.push('is-dragging');
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className={cardClasses.join(' ')}
|
||||
role="listitem"
|
||||
id={`document-card-${doc.id}`}
|
||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||
draggable
|
||||
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
||||
onDragEnd={onDocumentDragEnd}
|
||||
onDragOver={(event) => handleDocumentTagDragOver(event)}
|
||||
onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
|
||||
onDragLeave={handleDocumentTagDragLeave}
|
||||
onDragLeaveCapture={handleDocumentTagDragLeave}
|
||||
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
|
||||
onDropCapture={(event) => handleDocumentTagDrop(event, doc.id)}
|
||||
>
|
||||
<DocumentThumbnailImage
|
||||
document={doc}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||
/>
|
||||
<div className="document-card__meta">
|
||||
<div
|
||||
className="document-card__title"
|
||||
title={doc.title || doc.original_name}
|
||||
>
|
||||
{doc.title || doc.original_name}
|
||||
</div>
|
||||
{visibleTags.length > 0 && (
|
||||
<div className="document-card__tags">
|
||||
{visibleTags.map((tag) => {
|
||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
return (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="badge tag-chip"
|
||||
style={style || undefined}
|
||||
title={tag.label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (onTagClick) {
|
||||
onTagClick(tag.id);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
}
|
||||
const payload = JSON.stringify({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
sourceDocId: doc.id,
|
||||
});
|
||||
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
|
||||
event.dataTransfer?.setData('text/papercrate-tag', payload);
|
||||
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (onTagClick) {
|
||||
onTagClick(tag.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{remainingTagCount > 0 && (
|
||||
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<table aria-multiselectable="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="thumb-column">Preview</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Updated</th>
|
||||
<th className="actions-column">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!showingSearchResults &&
|
||||
subfolders.map((folder) => {
|
||||
const canDragFolder = folder.id !== 'root';
|
||||
const isDraggingFolder = draggedFolderId === folder.id;
|
||||
const isSelectedFolder = selectedFolderSet.has(folder.id);
|
||||
return (
|
||||
<tr
|
||||
key={folder.id}
|
||||
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
|
||||
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
|
||||
}${isSelectedFolder ? ' selected' : ''}`}
|
||||
id={`folder-row-${folder.id}`}
|
||||
onClick={(event) => {
|
||||
onFolderRowClick?.(folder.id, event);
|
||||
const shouldNavigate =
|
||||
!event.defaultPrevented &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey;
|
||||
if (shouldNavigate) {
|
||||
onFolderSelect(folder.id);
|
||||
}
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
onFocusedRowChange?.(`folder:${folder.id}`);
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect(folder.id);
|
||||
}}
|
||||
onDragOver={(event) => onFolderDragOver(event, folder.id)}
|
||||
onDragLeave={onFolderDragLeave}
|
||||
onDrop={(event) => onFolderDrop(event, folder.id)}
|
||||
draggable={canDragFolder}
|
||||
onDragStart={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragStart(event, folder.id);
|
||||
}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragEnd(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="thumb-cell">
|
||||
<div className="thumb-icon">
|
||||
<img
|
||||
src={FOLDER_ICON_SRC}
|
||||
alt="Folder"
|
||||
className="thumb-icon__image"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-list__name-content">
|
||||
<span>{folder.name}</span>
|
||||
{folder.id !== 'root' && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost doc-list__icon-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!onFolderRename) return;
|
||||
const nextName = window.prompt('Rename folder', folder.name || '');
|
||||
if (!nextName) {
|
||||
return;
|
||||
}
|
||||
const trimmed = nextName.trim();
|
||||
if (!trimmed || trimmed === folder.name) {
|
||||
return;
|
||||
}
|
||||
onFolderRename(folder.id, trimmed);
|
||||
}}
|
||||
title="Rename folder"
|
||||
aria-label={`Rename folder ${folder.name}`}
|
||||
>
|
||||
<EditIcon className="doc-list__icon" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>Folder</td>
|
||||
<td>—</td>
|
||||
<td className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onFolderDelete(folder.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{rows.map((doc) => {
|
||||
const isSelected = selectedSet.has(doc.id);
|
||||
const isDraggingDoc = draggingSet.has(doc.id);
|
||||
const rowClasses = ['document'];
|
||||
if (isSelected) rowClasses.push('selected');
|
||||
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||||
const downloadHref = getDownloadHref?.(doc) || null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={doc.id}
|
||||
className={rowClasses.join(' ')}
|
||||
id={`document-row-${doc.id}`}
|
||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||
draggable
|
||||
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
||||
onDragEnd={onDocumentDragEnd}
|
||||
onDragOver={handleDocumentTagDragOver}
|
||||
onDragLeave={handleDocumentTagDragLeave}
|
||||
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
|
||||
>
|
||||
<td className="thumb-cell">
|
||||
<DocumentThumbnailImage
|
||||
document={doc}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-name">
|
||||
<div className="doc-list__name-content">
|
||||
<span className="doc-name__title">{doc.title || doc.original_name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost doc-list__icon-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!onDocumentRename) return;
|
||||
const nextName = window.prompt(
|
||||
'Rename document',
|
||||
doc.title || doc.original_name || '',
|
||||
);
|
||||
if (!nextName) {
|
||||
return;
|
||||
}
|
||||
const trimmed = nextName.trim();
|
||||
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
|
||||
return;
|
||||
}
|
||||
onDocumentRename(doc.id, trimmed);
|
||||
}}
|
||||
title="Rename document"
|
||||
aria-label={`Rename document ${doc.title || doc.original_name}`}
|
||||
>
|
||||
<EditIcon className="doc-list__icon" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{(doc.tags || []).length > 0 && (
|
||||
<div className="doc-name__tags">
|
||||
{(doc.tags || []).map((tag) => {
|
||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
return (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="badge tag-chip"
|
||||
style={style || undefined}
|
||||
title={tag.label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (onTagClick) {
|
||||
onTagClick(tag.id);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
}
|
||||
const payload = JSON.stringify({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
sourceDocId: doc.id,
|
||||
});
|
||||
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
|
||||
event.dataTransfer?.setData('text/papercrate-tag', payload);
|
||||
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (onTagClick) {
|
||||
onTagClick(tag.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{doc.content_type || 'Document'}</td>
|
||||
<td>
|
||||
{doc.updated_at
|
||||
? new Date(doc.updated_at).toLocaleString()
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="actions">
|
||||
<div className="action-buttons">
|
||||
{downloadHref ? (
|
||||
<a
|
||||
className="button-link with-icon"
|
||||
href={downloadHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onAuxClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DownloadIcon className="icon-inline" />
|
||||
<span>Download</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="meta">No download</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDocumentDelete?.(doc.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
{rows.length === 0 && showingSearchResults && !isGridView && !isSearchLoading && (
|
||||
<div className="empty-state">No documents match the current filters.</div>
|
||||
)}
|
||||
{showingSearchResults && rows.length > 0 && (
|
||||
<div className="search-hint">
|
||||
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading && (
|
||||
<div className="empty-state empty-state--global">
|
||||
No documents match the current filters.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsTable;
|
||||
export { DocumentThumbnailImage };
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const normalizeMessage = (error) => {
|
||||
if (!error) return 'Something went wrong.';
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
const { response, message } = error;
|
||||
if (response?.data?.error) return response.data.error;
|
||||
if (response?.data?.message) return response.data.message;
|
||||
return message || 'Something went wrong.';
|
||||
};
|
||||
|
||||
const useApiError = ({
|
||||
logger = console,
|
||||
onReport = noop,
|
||||
} = {}) => {
|
||||
return useCallback(
|
||||
(error, { message, variant = 'error', retry = null } = {}) => {
|
||||
const normalizedMessage = message || normalizeMessage(error);
|
||||
if (logger && typeof logger.error === 'function') {
|
||||
logger.error('[API]', normalizedMessage, error);
|
||||
}
|
||||
onReport({ message: normalizedMessage, variant, retry, error });
|
||||
return normalizedMessage;
|
||||
},
|
||||
[logger, onReport],
|
||||
);
|
||||
};
|
||||
|
||||
export default useApiError;
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Papercrate</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="/config.js"></script>
|
||||
<main id="app"></main>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,303 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { ChevronIcon, TrashIcon, EditIcon } from '../ui/icons';
|
||||
|
||||
const FOLDER_ICON_SRC = '/folder.svg';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
|
||||
const FolderNode = ({
|
||||
node,
|
||||
depth,
|
||||
isSelected,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onDrop,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDelete,
|
||||
onRename,
|
||||
renderChildren,
|
||||
onFolderDragStart,
|
||||
onFolderDragEnd,
|
||||
draggingFolderId,
|
||||
}) => {
|
||||
const isRoot = node.id === 'root';
|
||||
const hasChildren = node.children.length > 0;
|
||||
const canToggle = !isRoot && (hasChildren || !node.loaded);
|
||||
const showChevron = !isRoot && hasChildren;
|
||||
const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null;
|
||||
const canDrag = !isRoot;
|
||||
const isDragging = draggingFolderId === node.id;
|
||||
const isExpanded = isRoot ? true : Boolean(node.expanded);
|
||||
const rowClasses = ['folder-row'];
|
||||
if (isSelected) {
|
||||
rowClasses.push('active');
|
||||
}
|
||||
|
||||
const handleToggleClick = (event) => {
|
||||
event.stopPropagation();
|
||||
if (canToggle) {
|
||||
onToggle(node.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className={`folder-node${isDragging ? ' is-dragging' : ''}`}>
|
||||
<div
|
||||
className={rowClasses.join(' ')}
|
||||
draggable={canDrag}
|
||||
onClick={() => onSelect(node.id)}
|
||||
onDragOver={(event) => onDragOver(event, node.id)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(event) => onDrop(event, node.id)}
|
||||
onDragStart={(event) => {
|
||||
if (!canDrag || !onFolderDragStart) return;
|
||||
onFolderDragStart(event, node.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
if (onFolderDragEnd) {
|
||||
onFolderDragEnd(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isRoot && (
|
||||
<span
|
||||
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
|
||||
onClick={handleToggleClick}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="name">
|
||||
<img src={FOLDER_ICON_SRC} alt="Folder" className="folder-icon-image" />
|
||||
{node.name}
|
||||
</span>
|
||||
{node.id !== 'root' && (
|
||||
<div className="folder-row__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!onRename) return;
|
||||
const nextName = window.prompt('Rename folder', node.name || '');
|
||||
if (!nextName) {
|
||||
return;
|
||||
}
|
||||
const trimmed = nextName.trim();
|
||||
if (!trimmed || trimmed === node.name) {
|
||||
return;
|
||||
}
|
||||
onRename(node.id, trimmed);
|
||||
}}
|
||||
title="Rename folder"
|
||||
aria-label={`Rename folder ${node.name}`}
|
||||
>
|
||||
<EditIcon className="icon-edit" size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(node.id);
|
||||
}}
|
||||
title="Delete folder"
|
||||
aria-label={`Delete folder ${node.name}`}
|
||||
>
|
||||
<TrashIcon className="icon-trash" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && node.children.length > 0 && (
|
||||
<ul className={`folder-children${depth === 0 ? ' folder-children--level1' : ''}`}>
|
||||
{renderChildren(node.children, depth + 1)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const Sidebar = ({
|
||||
folderNodes,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onDrop,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDeleteFolder,
|
||||
onRenameFolder,
|
||||
selectedFolder,
|
||||
onFolderDragStart,
|
||||
onFolderDragEnd,
|
||||
draggedFolderId,
|
||||
tags = [],
|
||||
activeTagIds = [],
|
||||
onToggleTagFilter,
|
||||
correspondents = [],
|
||||
activeCorrespondentIds = [],
|
||||
onToggleCorrespondentFilter,
|
||||
}) => {
|
||||
const sortedCorrespondents = useMemo(
|
||||
() =>
|
||||
[...correspondents].sort((a, b) =>
|
||||
(a?.name || '').localeCompare(b?.name || '', undefined, { sensitivity: 'base' }),
|
||||
),
|
||||
[correspondents],
|
||||
);
|
||||
const activeCorrespondentSet = useMemo(
|
||||
() => new Set(activeCorrespondentIds || []),
|
||||
[activeCorrespondentIds],
|
||||
);
|
||||
const handleToggleTag = onToggleTagFilter || (() => {});
|
||||
const activeTagSet = new Set(activeTagIds);
|
||||
|
||||
const renderNodes = useCallback(
|
||||
(ids, depth) =>
|
||||
ids.map((id) => {
|
||||
const node = folderNodes.get(id);
|
||||
if (!node) return null;
|
||||
return (
|
||||
<FolderNode
|
||||
key={id}
|
||||
node={node}
|
||||
depth={depth}
|
||||
isSelected={selectedFolder === id}
|
||||
onToggle={() => onToggle(id)}
|
||||
onSelect={onSelect}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDelete={onDeleteFolder}
|
||||
onRename={onRenameFolder}
|
||||
renderChildren={renderNodes}
|
||||
onFolderDragStart={onFolderDragStart}
|
||||
onFolderDragEnd={onFolderDragEnd}
|
||||
draggingFolderId={draggedFolderId}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
[
|
||||
folderNodes,
|
||||
selectedFolder,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onDrop,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDeleteFolder,
|
||||
onRenameFolder,
|
||||
onFolderDragStart,
|
||||
onFolderDragEnd,
|
||||
draggedFolderId,
|
||||
],
|
||||
);
|
||||
|
||||
const rootNode = folderNodes.get('root');
|
||||
|
||||
return (
|
||||
<aside className="sidebar column">
|
||||
<div className="sidebar-section sidebar-section--folders">
|
||||
<div className="sidebar-section__header">
|
||||
<h3>Folders</h3>
|
||||
</div>
|
||||
<ul className="folder-tree">
|
||||
{rootNode && renderNodes([rootNode.id], 0)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section__header">
|
||||
<h3>Tags</h3>
|
||||
<span className="meta">{tags.length}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`sidebar-tag-cloud${
|
||||
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
|
||||
}`}
|
||||
role="list"
|
||||
>
|
||||
{tags.length ? (
|
||||
tags.map((tag) => {
|
||||
const isActive = activeTagSet.has(tag.id);
|
||||
const style = getTagColorStyle(tag.color);
|
||||
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={className}
|
||||
style={style || undefined}
|
||||
onClick={() => handleToggleTag(tag.id)}
|
||||
aria-pressed={isActive}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color || null,
|
||||
});
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('application/x-papercrate-tag', payload);
|
||||
event.dataTransfer.setData('text/papercrate-tag', payload);
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="meta">No tags yet</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section__header">
|
||||
<h3>Correspondents</h3>
|
||||
<span className="meta">{correspondents.length}</span>
|
||||
</div>
|
||||
<ul className="sidebar-correspondent-list">
|
||||
{sortedCorrespondents.length ? (
|
||||
sortedCorrespondents.map((correspondent) => {
|
||||
const isActive = activeCorrespondentSet.has(correspondent.id);
|
||||
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
||||
const label = correspondent.name || 'Unnamed';
|
||||
const handleSelect = () => {
|
||||
const nextId = isActive ? null : correspondent.id;
|
||||
onToggleCorrespondentFilter?.(nextId);
|
||||
};
|
||||
return (
|
||||
<li key={correspondent.id}>
|
||||
<span
|
||||
className={className}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleSelect}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<li className="meta">No correspondents yet</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
export { FolderNode };
|
||||
@@ -0,0 +1,317 @@
|
||||
/* Skeuomorphic workspace styles */
|
||||
.skeuo-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.skeuo-shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background-color: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.skeuo-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem 0.5rem;
|
||||
background-color: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.skeuo-header__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.skeuo-header__meta h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skeuo-breadcrumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.skeuo-crumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.skeuo-crumb.is-current {
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skeuo-crumb-separator {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.skeuo-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skeuo-canvas {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.skeuo-empty {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.skeuo-item {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.28s ease, box-shadow 0.16s ease;
|
||||
outline: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.skeuo-item__body {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transition: width 0.28s ease, height 0.28s ease;
|
||||
}
|
||||
|
||||
.skeuo-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.skeuo-item.is-dragging {
|
||||
cursor: grabbing;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.skeuo-item.is-zoomed {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.skeuo-item.is-tag-target .skeuo-item__card {
|
||||
outline: 1em dashed var(--accent);
|
||||
outline-offset: 1.41em;
|
||||
}
|
||||
|
||||
.skeuo-item.is-tag-pending .skeuo-item__card {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.skeuo-item.is-filtered-out {
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
filter: blur(15px) grayscale(100%);
|
||||
transition: opacity 0.6s ease, filter 0.28s ease;
|
||||
z-index: 0 !important;
|
||||
}
|
||||
|
||||
.skeuo-item__tags {
|
||||
--tag-scale: 1;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
align-items: flex-end;
|
||||
transform-origin: top right;
|
||||
transform: scale(var(--tag-scale)) translate(-0.5em, 0.5em);
|
||||
transition: transform 0.28s ease;
|
||||
}
|
||||
|
||||
.skeuo-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0.28rem 0.7rem;
|
||||
border-radius: 1px;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
|
||||
color: var(--fg);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.18);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .skeuo-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
transition: transform 0.16s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.skeuo-tag.is-drag-hidden {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skeuo-tag span {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .skeuo-tag.is-tear-pending {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf {
|
||||
position: absolute;
|
||||
top: 2.25rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
align-items: flex-end;
|
||||
max-height: calc(100% - 4rem);
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
z-index: 100000;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf .skeuo-tag {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf .skeuo-tag:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf .skeuo-tag.is-inactive {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf .skeuo-tag.is-active {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.skeuo-tag-add {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
color: var(--fg);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin-top: 0.4rem;
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.skeuo-tag-add:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.skeuo-tag-add:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.skeuo-tag-add:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
body.skeuo-cursor-remove,
|
||||
body.skeuo-cursor-remove * {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.skeuo-item__shadow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skeuo-item__card {
|
||||
position: relative;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeuo-item__card--empty {
|
||||
background:
|
||||
radial-gradient(circle at 28% 24%, rgba(255, 255, 255, 0.32), transparent 60%),
|
||||
radial-gradient(circle at 72% 78%, rgba(0, 0, 0, 0.08), transparent 65%),
|
||||
linear-gradient(135deg, #e6e1d6 0%, #d2cdc2 100%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skeuo-item__placeholder {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: normal;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.skeuo-item__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.skeuo-item__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #3b3b3b;
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
import { generateRandomTagColor } from './utils/colors';
|
||||
|
||||
class TagManager {
|
||||
constructor({ colorGenerator = generateRandomTagColor } = {}) {
|
||||
this.colorGenerator = typeof colorGenerator === 'function' ? colorGenerator : generateRandomTagColor;
|
||||
}
|
||||
|
||||
normalizeLabel(label) {
|
||||
if (typeof label !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return label.trim();
|
||||
}
|
||||
|
||||
buildPayload({ label, color } = {}) {
|
||||
const normalizedLabel = this.normalizeLabel(label);
|
||||
if (!normalizedLabel) {
|
||||
throw new Error('Tag label is required.');
|
||||
}
|
||||
const payload = { label: normalizedLabel };
|
||||
const trimmedColor = typeof color === 'string' && color.trim().length ? color.trim() : null;
|
||||
payload.color = trimmedColor || this.colorGenerator();
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
export default TagManager;
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { getTagColorStyle, HEX_COLOR_PATTERN } from '../utils/colors';
|
||||
|
||||
function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [draftLabel, setDraftLabel] = useState('');
|
||||
const [draftColor, setDraftColor] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
|
||||
const startEdit = useCallback((tag) => {
|
||||
setEditingId(tag.id);
|
||||
setDraftLabel(tag.label || '');
|
||||
setDraftColor(tag.color || '');
|
||||
}, []);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setDraftLabel('');
|
||||
setDraftColor('');
|
||||
setSaving(false);
|
||||
}, []);
|
||||
|
||||
const colorPickerValue = useMemo(() => {
|
||||
if (!draftColor) {
|
||||
return '#3366ff';
|
||||
}
|
||||
const match = HEX_COLOR_PATTERN.exec(draftColor.trim());
|
||||
if (!match) {
|
||||
return '#3366ff';
|
||||
}
|
||||
return `#${match[1].toLowerCase()}`;
|
||||
}, [draftColor]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!editingId) return;
|
||||
|
||||
const trimmedLabel = draftLabel.trim();
|
||||
if (!trimmedLabel) {
|
||||
onNotify?.('Tag label cannot be empty.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedColor = draftColor.trim();
|
||||
const colorPattern = /^#([0-9a-fA-F]{6})$/;
|
||||
if (trimmedColor && !colorPattern.test(trimmedColor)) {
|
||||
onNotify?.('Colors must use the #RRGGBB format.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onUpdateTag(editingId, {
|
||||
label: trimmedLabel,
|
||||
color: trimmedColor ? trimmedColor : null,
|
||||
});
|
||||
cancelEdit();
|
||||
} catch (updateError) {
|
||||
const message = updateError?.message || 'Failed to update tag.';
|
||||
onNotify?.(message, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editingId, draftLabel, draftColor, onUpdateTag, cancelEdit, onNotify]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
},
|
||||
[handleSave, cancelEdit],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (tag) => {
|
||||
if (!tag?.id || typeof onDeleteTag !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingId(tag.id);
|
||||
try {
|
||||
await onDeleteTag(tag.id);
|
||||
if (editingId === tag.id) {
|
||||
cancelEdit();
|
||||
}
|
||||
} catch (deleteError) {
|
||||
const message = deleteError?.message || 'Failed to delete tag.';
|
||||
onNotify?.(message, 'error');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
[onDeleteTag, editingId, cancelEdit, onNotify],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="tags-panel column">
|
||||
<div className="column-header">
|
||||
<div className="column-header__titles">
|
||||
<h2>Tags</h2>
|
||||
<div className="column-subtitle">{tags.length} total</div>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={saving || Boolean(deletingId)}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column-body tags-panel__body">
|
||||
{tags.length === 0 ? (
|
||||
<div className="empty-state">No tags created yet.</div>
|
||||
) : (
|
||||
<div className="tags-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Tag</th>
|
||||
<th scope="col">Color</th>
|
||||
<th scope="col" className="numeric">
|
||||
Documents
|
||||
</th>
|
||||
<th scope="col" className="actions">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tags.map((tag) => {
|
||||
const isEditing = editingId === tag.id;
|
||||
return (
|
||||
<tr key={tag.id} className={isEditing ? 'editing' : ''}>
|
||||
<td className="tags-table__label">
|
||||
{isEditing ? (
|
||||
<input
|
||||
className="tags-table__label-input"
|
||||
value={draftLabel}
|
||||
onChange={(event) => setDraftLabel(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={saving || deletingId === tag.id}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="badge tag-chip"
|
||||
style={getTagColorStyle(tag.color) || undefined}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{isEditing ? (
|
||||
<div className="tags-table__color-editor">
|
||||
<input
|
||||
type="color"
|
||||
className="tags-table__color-picker"
|
||||
value={colorPickerValue}
|
||||
onChange={(event) => setDraftColor(event.target.value)}
|
||||
disabled={saving || deletingId === tag.id}
|
||||
aria-label="Pick tag color"
|
||||
/>
|
||||
{draftColor && (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => setDraftColor('')}
|
||||
disabled={saving || deletingId === tag.id}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : tag.color ? (
|
||||
<span
|
||||
className="tags-table__swatch"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
aria-label={`Tag color ${tag.color}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="meta">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="numeric">{tag.usage_count ?? 0}</td>
|
||||
<td className="actions">
|
||||
{isEditing ? (
|
||||
<div className="tags-table__edit-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={handleSave}
|
||||
disabled={saving || deletingId === tag.id}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={cancelEdit}
|
||||
disabled={saving || deletingId === tag.id}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => handleDelete(tag)}
|
||||
disabled={deletingId === tag.id}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tags-table__row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => startEdit(tag)}
|
||||
disabled={deletingId === tag.id}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => handleDelete(tag)}
|
||||
disabled={deletingId === tag.id}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagsPanel;
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
IconChevronRight as TablerChevronRight,
|
||||
IconDownload as TablerDownload,
|
||||
IconFolderFilled,
|
||||
IconPencil,
|
||||
IconTagFilled,
|
||||
IconUserFilled,
|
||||
IconTrash,
|
||||
IconLayoutList,
|
||||
IconLayoutGrid,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
|
||||
|
||||
export const ChevronIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<TablerChevronRight
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const TrashIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconTrash
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const EditIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconPencil
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const FolderIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
||||
<IconFolderFilled
|
||||
className={composeClassName('icon icon--fill', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
||||
<IconTagFilled
|
||||
className={composeClassName('icon icon--fill', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CorrespondentIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
||||
<IconUserFilled
|
||||
className={composeClassName('icon icon--fill', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<TablerDownload
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ViewListIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconLayoutList
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ViewGridIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconLayoutGrid
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export default {
|
||||
ChevronIcon,
|
||||
TrashIcon,
|
||||
EditIcon,
|
||||
FolderIcon,
|
||||
TagIcon,
|
||||
CorrespondentIcon,
|
||||
DownloadIcon,
|
||||
ViewListIcon,
|
||||
ViewGridIcon,
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
|
||||
|
||||
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
||||
|
||||
const gammaEncode = (channel) =>
|
||||
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
|
||||
|
||||
const oklchToHex = (l, c, h) => {
|
||||
const hr = (h * Math.PI) / 180;
|
||||
const a = Math.cos(hr) * c;
|
||||
const b = Math.sin(hr) * c;
|
||||
|
||||
const l1 = l + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m1 = l - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s1 = l - 0.0894841775 * a - 1.291485548 * b;
|
||||
|
||||
const l3 = l1 ** 3;
|
||||
const m3 = m1 ** 3;
|
||||
const s3 = s1 ** 3;
|
||||
|
||||
const r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
|
||||
const g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
|
||||
const bLin = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3;
|
||||
|
||||
if ([r, g, bLin].some((channel) => channel < 0 || channel > 1)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sr = Math.round(clamp01(gammaEncode(r)) * 255);
|
||||
const sg = Math.round(clamp01(gammaEncode(g)) * 255);
|
||||
const sb = Math.round(clamp01(gammaEncode(bLin)) * 255);
|
||||
|
||||
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
|
||||
};
|
||||
|
||||
const hslToHex = (h, s, l) => {
|
||||
const normalizedH = ((h % 360) + 360) % 360;
|
||||
const sat = clamp01(s);
|
||||
const light = clamp01(l);
|
||||
|
||||
const chroma = (1 - Math.abs(2 * light - 1)) * sat;
|
||||
const hPrime = normalizedH / 60;
|
||||
const x = chroma * (1 - Math.abs((hPrime % 2) - 1));
|
||||
|
||||
let r1 = 0;
|
||||
let g1 = 0;
|
||||
let b1 = 0;
|
||||
|
||||
if (hPrime >= 0 && hPrime < 1) {
|
||||
r1 = chroma;
|
||||
g1 = x;
|
||||
} else if (hPrime >= 1 && hPrime < 2) {
|
||||
r1 = x;
|
||||
g1 = chroma;
|
||||
} else if (hPrime >= 2 && hPrime < 3) {
|
||||
g1 = chroma;
|
||||
b1 = x;
|
||||
} else if (hPrime >= 3 && hPrime < 4) {
|
||||
g1 = x;
|
||||
b1 = chroma;
|
||||
} else if (hPrime >= 4 && hPrime < 5) {
|
||||
r1 = x;
|
||||
b1 = chroma;
|
||||
} else {
|
||||
r1 = chroma;
|
||||
b1 = x;
|
||||
}
|
||||
|
||||
const m = light - chroma / 2;
|
||||
const r = clamp01(r1 + m);
|
||||
const g = clamp01(g1 + m);
|
||||
const b = clamp01(b1 + m);
|
||||
|
||||
const sr = Math.round(r * 255);
|
||||
const sg = Math.round(g * 255);
|
||||
const sb = Math.round(b * 255);
|
||||
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
|
||||
};
|
||||
|
||||
export const hexToRgb = (input) => {
|
||||
if (!input) return null;
|
||||
const match = HEX_COLOR_PATTERN.exec(input.trim());
|
||||
if (!match) return null;
|
||||
const value = parseInt(match[1], 16);
|
||||
return {
|
||||
r: (value >> 16) & 0xff,
|
||||
g: (value >> 8) & 0xff,
|
||||
b: value & 0xff,
|
||||
hex: `#${match[1].toLowerCase()}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const relativeLuminance = ({ r, g, b }) => {
|
||||
const toLinear = (channel) => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.03928
|
||||
? normalized / 12.92
|
||||
: ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
const [red, green, blue] = [toLinear(r), toLinear(g), toLinear(b)];
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
};
|
||||
|
||||
export const getReadableTextColor = (hex, { light = '#1f1f1f', dark = '#ffffff' } = {}) => {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return dark;
|
||||
const luminance = relativeLuminance(rgb);
|
||||
return luminance > 0.6 ? light : dark;
|
||||
};
|
||||
|
||||
export const getTagColorStyle = (hex) => {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return null;
|
||||
return {
|
||||
backgroundColor: rgb.hex,
|
||||
borderColor: rgb.hex,
|
||||
color: getReadableTextColor(rgb.hex),
|
||||
};
|
||||
};
|
||||
|
||||
export const generateRandomTagColor = () => {
|
||||
const bucketCount = 8;
|
||||
const bucketWidth = 360 / bucketCount;
|
||||
const bucket = Math.floor(Math.random() * bucketCount);
|
||||
const baseHue = bucket * bucketWidth;
|
||||
const hueJitter = bucketWidth * 0.35;
|
||||
const hue = baseHue + (Math.random() * 2 - 1) * hueJitter;
|
||||
const saturation = 0.45 + Math.random() * 0.2; // 0.45 - 0.65
|
||||
const lightness = 0.55 + Math.random() * 0.1; // 0.55 - 0.65
|
||||
return hslToHex(hue, saturation, lightness);
|
||||
};
|
||||
|
||||
export { HEX_COLOR_PATTERN };
|
||||
@@ -0,0 +1,18 @@
|
||||
export const formatFileSize = (value) => {
|
||||
const bytes = Number(value);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
||||
let index = 0;
|
||||
let amount = bytes;
|
||||
|
||||
while (amount >= 1024 && index < units.length - 1) {
|
||||
amount /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return `${amount.toFixed(2)} ${units[index]}`;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
const env = dotenv.config({ path: path.resolve(__dirname, '.env.local') }).parsed || {};
|
||||
|
||||
const DEFAULT_DEV_API = 'http://127.0.0.1:3000';
|
||||
|
||||
const API_BASE_URL = env.API_BASE_URL || process.env.API_BASE_URL || DEFAULT_DEV_API;
|
||||
|
||||
module.exports = {
|
||||
entry: './src/index.jsx',
|
||||
output: {
|
||||
filename: 'bundle.[contenthash].js',
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
clean: true,
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
template: path.resolve(__dirname, 'src/index.html'),
|
||||
favicon: false,
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.API_BASE_URL': JSON.stringify(API_BASE_URL),
|
||||
}),
|
||||
],
|
||||
devServer: {
|
||||
static: {
|
||||
directory: path.join(__dirname, 'public'),
|
||||
},
|
||||
compress: true,
|
||||
port: 5173,
|
||||
historyApiFallback: true,
|
||||
open: true,
|
||||
},
|
||||
devtool: 'source-map',
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user