diff --git a/packages/web-vite/LABEL_SYSTEM.md b/packages/web-vite/LABEL_SYSTEM.md new file mode 100644 index 000000000..70ac8055d --- /dev/null +++ b/packages/web-vite/LABEL_SYSTEM.md @@ -0,0 +1,286 @@ +# Label System Architecture + +## Overview +The label system allows users to organize library items by applying custom labels. Labels can be created, edited, deleted, and applied to library items. + +## Backend (NestJS API) + +### Database Schema + +**`omnivore.labels` table:** +- `id` (uuid, primary key) +- `user_id` (uuid, references users) +- `name` (text, unique per user) +- `color` (text, hex color code, default: #000000) +- `description` (text, nullable) +- `position` (integer, for ordering) +- `internal` (boolean, system labels cannot be modified/deleted) +- `created_at` (timestamptz) +- `updated_at` (timestamptz, with DEFAULT current_timestamp) + +**`omnivore.entity_labels` table:** +- `id` (uuid, primary key) +- `label_id` (uuid, references labels) +- `library_item_id` (uuid, references library items) +- `source` (text, 'user' or 'system') +- `created_at` (timestamptz) + +### GraphQL API + +**Queries:** +- `labels: [Label!]!` - Get all labels for current user +- `label(id: String!): Label` - Get single label by ID + +**Mutations:** +- `createLabel(input: CreateLabelInput!): Label!` - Create new label +- `updateLabel(id: String!, input: UpdateLabelInput!): Label!` - Update existing label +- `deleteLabel(id: String!): DeleteResult!` - Delete label +- `setLibraryItemLabels(itemId: String!, labelIds: [String!]!): [Label!]!` - Set labels on a library item + +**Types:** +```graphql +type Label { + id: ID! + name: String! + color: String! + description: String + position: Int! + internal: Boolean! + createdAt: DateTime! + updatedAt: DateTime! +} + +input CreateLabelInput { + name: String! # 1-100 chars + color: String # Hex color (e.g., #FF5733) + description: String # 0-500 chars +} + +input UpdateLabelInput { + name: String # 1-100 chars + color: String # Hex color + description: String # 0-500 chars +} +``` + +### Service Layer (`LabelService`) + +**Key Methods:** +- `findAll(userId)` - Retrieve all labels for a user +- `findOne(userId, labelId)` - Get single label +- `create(userId, input)` - Create new label with validation +- `update(userId, labelId, input)` - Update label (protects internal labels) +- `delete(userId, labelId)` - Delete label (protects internal labels) +- `setLibraryItemLabels(userId, libraryItemId, labelIds)` - Replace all labels on an item +- `getLibraryItemLabels(userId, libraryItemId)` - Get labels for an item + +**Validation:** +- Label names must be unique per user +- Internal (system) labels cannot be modified or deleted +- Color format must be valid hex code +- Name length: 1-100 characters +- Description length: 0-500 characters + +## Frontend (Vite/React) + +### Components + +**`/pages/LabelsPage.tsx`** - Label management interface +- View all labels in a grid layout +- Create new labels with color picker +- Edit existing labels (except internal labels) +- Delete labels with confirmation + +**`/components/LabelPicker.tsx`** - Label assignment UI +- Dropdown with checkbox list of available labels +- Select/deselect labels for a library item +- Save/Cancel actions +- Converts label names to IDs before API call + +### GraphQL Client (`/lib/graphql-client.ts`) + +**Label Hooks:** +- `useLabels()` - Fetch all labels with refetch capability +- `useCreateLabel()` - Create new label +- `useUpdateLabel()` - Update existing label +- `useDeleteLabel()` - Delete label +- `useSetLibraryItemLabels()` - Set labels on library item + +**Mutation Pattern:** +```typescript +const { setLibraryItemLabels, loading, error } = useSetLibraryItemLabels() + +// Usage: +await setLibraryItemLabels(itemId, ['label-uuid-1', 'label-uuid-2']) +``` + +### Library Integration + +**LibraryPage (`/pages/LibraryPage.tsx`):** +- Displays label chips on each library item +- Label picker button opens LabelPicker component +- Label filter dropdown to filter items by labels +- Server-side filtering via GraphQL query + +**Label Display:** +```tsx +{item.labels && item.labels.length > 0 && ( +
+ {item.labels.map((label) => ( + + {label.name} + + ))} +
+)} +``` + +**Label Assignment:** +```tsx + l.name) || []} + onUpdate={(labelNames) => handleLabelsUpdate(item.id, labelNames)} +/> +``` + +## Data Flow + +### Creating a Label +1. User fills out form in LabelsPage +2. Frontend calls `createLabel` mutation +3. Backend validates input +4. Backend creates label in database +5. Frontend refetches labels list + +### Assigning Labels to Library Item +1. User opens LabelPicker on a library item +2. User selects/deselects labels +3. User clicks "Save" +4. Frontend converts label names → label IDs +5. Frontend calls `setLibraryItemLabels(itemId, labelIds)` +6. Backend: + - Verifies all labels belong to user + - Deletes existing label associations + - Creates new label associations + - Returns updated labels +7. Frontend updates UI optimistically +8. Frontend shows success toast + +### Filtering by Labels +1. User selects labels in filter dropdown +2. Frontend adds `labels: [labelName1, labelName2]` to search params +3. Backend filters library items with matching labels +4. Results displayed in LibraryPage + +## Migration History + +**Migration 0191: Fix labels updated_at default** +- Added DEFAULT current_timestamp to `updated_at` column +- Backfilled NULL values with `created_at` +- Added NOT NULL constraint +- Ensures GraphQL non-nullable field returns valid timestamp + +## Known Issues & Future Improvements + +### Current Issues: +1. **No optimistic updates** - Labels don't appear immediately after assignment +2. **No cache invalidation** - Library items list doesn't refresh after label changes +3. **No label validation on frontend** - Duplicate names not checked before API call + +### Recommended Improvements: +1. **Add optimistic UI updates** - Show labels immediately while API call is in progress +2. **Implement cache invalidation** - Refresh library items after label changes +3. **Add label autocomplete** - Match legacy behavior with create-on-the-fly +4. **Add keyboard shortcuts** - Quick label assignment via keyboard +5. **Add label analytics** - Track label usage and suggest cleanup +6. **Add bulk label operations** - Apply labels to multiple items at once +7. **Add label groups/categories** - Organize labels hierarchically + +## Comparison with Legacy System + +### Legacy (`/packages/web`): +- Uses `pageId` parameter name +- Modal-based UI with autocomplete +- Throttled saves (2 second debounce) +- Optimistic cache updates +- Query invalidation on mutation success + +### New System (`/packages/web-vite`): +- Uses `itemId` parameter name +- Dropdown-based UI with checkboxes +- Immediate saves +- No optimistic updates yet +- No cache invalidation yet + +## Testing + +### Manual Test Flow: +1. Navigate to `/labels` +2. Create a new label "Test Label" with color #FF5733 +3. Navigate to library page +4. Click "🏷️ Labels" on a library item +5. Select "Test Label" +6. Click "Save" +7. Verify label appears on the item +8. Use label filter to find items with "Test Label" + +### E2E Test Coverage: +See `/packages/api-nest/test/label.e2e-spec.ts` for backend tests + +## API Examples + +### Create Label +```graphql +mutation { + createLabel(input: { + name: "Important" + color: "#FF5733" + description: "High priority items" + }) { + id + name + color + } +} +``` + +### Assign Labels to Item +```graphql +mutation { + setLibraryItemLabels( + itemId: "abc-123" + labelIds: ["label-uuid-1", "label-uuid-2"] + ) { + id + name + color + } +} +``` + +### Filter Library Items by Label +```graphql +query { + libraryItems( + first: 50 + search: { + labels: ["Important", "Work"] + } + ) { + items { + id + title + labels { + id + name + color + } + } + } +} +``` diff --git a/packages/web-vite/src/components/LabelPicker.tsx b/packages/web-vite/src/components/LabelPicker.tsx new file mode 100644 index 000000000..eca1362f5 --- /dev/null +++ b/packages/web-vite/src/components/LabelPicker.tsx @@ -0,0 +1,152 @@ +import { useState, useEffect, useRef } from 'react' +import { useLabels, useSetLibraryItemLabels, type Label } from '../lib/graphql-client' +import '../styles/LabelPicker.css' + +interface LabelPickerProps { + itemId: string + currentLabels: string[] + onUpdate?: (labels: string[]) => void +} + +export function LabelPicker({ itemId, currentLabels, onUpdate }: LabelPickerProps) { + const { data: allLabels, loading: loadingLabels, fetchLabels } = useLabels() + const { setLibraryItemLabels, loading: updating } = useSetLibraryItemLabels() + const [isOpen, setIsOpen] = useState(false) + const [selectedLabels, setSelectedLabels] = useState>(new Set(currentLabels)) + const dropdownRef = useRef(null) + + useEffect(() => { + if (isOpen && !allLabels) { + fetchLabels() + } + }, [isOpen, allLabels, fetchLabels]) + + useEffect(() => { + setSelectedLabels(new Set(currentLabels)) + }, [currentLabels]) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) + + const toggleLabel = (labelName: string) => { + setSelectedLabels((prev) => { + const newSet = new Set(prev) + if (newSet.has(labelName)) { + newSet.delete(labelName) + } else { + newSet.add(labelName) + } + return newSet + }) + } + + const handleSave = async () => { + try { + const labelNames = Array.from(selectedLabels) + + // Convert label names to label IDs + const labelIds = allLabels + ?.filter((label) => labelNames.includes(label.name)) + .map((label) => label.id) || [] + + await setLibraryItemLabels(itemId, labelIds) + + if (onUpdate) { + onUpdate(labelNames) + } + + setIsOpen(false) + } catch (err) { + console.error('Failed to update labels:', err) + // Revert to original labels on error + setSelectedLabels(new Set(currentLabels)) + } + } + + const handleCancel = () => { + setSelectedLabels(new Set(currentLabels)) + setIsOpen(false) + } + + return ( +
+ + + {isOpen && ( +
+
+

Select Labels

+
+ + {loadingLabels ? ( +
Loading labels...
+ ) : allLabels && allLabels.length === 0 ? ( +
+ No labels available. Create labels from the Labels page. +
+ ) : ( +
+ {allLabels?.map((label: Label) => ( + + ))} +
+ )} + +
+ + +
+
+ )} +
+ ) +} + +export default LabelPicker diff --git a/packages/web-vite/src/lib/graphql-client.ts b/packages/web-vite/src/lib/graphql-client.ts new file mode 100644 index 000000000..1f2ef8121 --- /dev/null +++ b/packages/web-vite/src/lib/graphql-client.ts @@ -0,0 +1,663 @@ +// Minimal GraphQL helper targeting the NestJS `/api/graphql` endpoint +// Mirrors the behaviour of the legacy web package's fetcher but keeps dependencies light + +import { useState, useCallback } from 'react' + +const DEFAULT_GRAPHQL_PATH = '/api/graphql' +const TOKEN_STORAGE_KEY = 'omnivore-auth-token' + +const resolveGraphqlUrl = (): string => { + const rawBase = import.meta.env.VITE_API_URL as string | undefined + const normalizedBase = + rawBase && rawBase.trim().length > 0 + ? rawBase.trim().replace(/\/$/, '') + : '' + + if (!normalizedBase) { + return DEFAULT_GRAPHQL_PATH + } + + // Handle env values that point at `/api/v2` (REST base) by trimming the suffix + if (normalizedBase.endsWith('/api/v2')) { + return `${normalizedBase.slice( + 0, + -'/api/v2'.length + )}${DEFAULT_GRAPHQL_PATH}` + } + + if (normalizedBase.endsWith('/api')) { + return `${normalizedBase}${DEFAULT_GRAPHQL_PATH.replace('/api', '')}` + } + + return `${normalizedBase}${DEFAULT_GRAPHQL_PATH}` +} + +const isBrowser = typeof window !== 'undefined' + +export interface GraphqlResponse { + data?: T + errors?: Array<{ message: string }> +} + +export async function graphqlRequest( + query: string, + variables?: Record +): Promise { + const endpoint = resolveGraphqlUrl() + const token = isBrowser + ? window.localStorage.getItem(TOKEN_STORAGE_KEY) + : null + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + 'X-OmnivoreClient': 'web', + }, + credentials: 'include', + body: JSON.stringify({ query, variables }), + }) + + if (!response.ok) { + throw new Error(`GraphQL request failed (${response.status})`) + } + + const payload = (await response.json()) as GraphqlResponse + if (payload.errors?.length) { + throw new Error(payload.errors.map((error) => error.message).join(', ')) + } + + if (!payload.data) { + throw new Error('GraphQL response missing data') + } + + return payload.data +} + +// ==================== MUTATIONS ==================== + +const ARCHIVE_LIBRARY_ITEM_MUTATION = ` + mutation ArchiveLibraryItem($id: String!, $archived: Boolean!) { + archiveLibraryItem(id: $id, archived: $archived) { + id + state + folder + updatedAt + } + } +` + +const DELETE_LIBRARY_ITEM_MUTATION = ` + mutation DeleteLibraryItem($id: String!) { + deleteLibraryItem(id: $id) { + success + message + itemId + } + } +` + +const UPDATE_READING_PROGRESS_MUTATION = ` + mutation UpdateReadingProgress($id: String!, $progress: ReadingProgressInput!) { + updateReadingProgress(id: $id, progress: $progress) { + id + readingProgressTopPercent + readingProgressBottomPercent + readAt + updatedAt + } + } +` + +const MOVE_LIBRARY_ITEM_TO_FOLDER_MUTATION = ` + mutation MoveLibraryItemToFolder($id: String!, $folder: String!) { + moveLibraryItemToFolder(id: $id, folder: $folder) { + id + folder + state + updatedAt + } + } +` + +const BULK_ARCHIVE_ITEMS_MUTATION = ` + mutation BulkArchiveItems($itemIds: [String!]!, $archived: Boolean!) { + bulkArchiveItems(itemIds: $itemIds, archived: $archived) { + success + successCount + failureCount + errors + message + } + } +` + +const BULK_DELETE_ITEMS_MUTATION = ` + mutation BulkDeleteItems($itemIds: [String!]!) { + bulkDeleteItems(itemIds: $itemIds) { + success + successCount + failureCount + errors + message + } + } +` + +const BULK_MOVE_TO_FOLDER_MUTATION = ` + mutation BulkMoveToFolder($itemIds: [String!]!, $folder: String!) { + bulkMoveToFolder(itemIds: $itemIds, folder: $folder) { + success + successCount + failureCount + errors + message + } + } +` + +const BULK_MARK_AS_READ_MUTATION = ` + mutation BulkMarkAsRead($itemIds: [String!]!) { + bulkMarkAsRead(itemIds: $itemIds) { + success + successCount + failureCount + errors + message + } + } +` + +// ==================== HOOKS ==================== + +interface MutationState { + loading: boolean + error: Error | null + data: T | null +} + +export function useArchiveItem() { + const [state, setState] = useState>({ + loading: false, + error: null, + data: null, + }) + + const archiveItem = useCallback(async (id: string, archived: boolean) => { + setState({ loading: true, error: null, data: null }) + try { + const data = await graphqlRequest(ARCHIVE_LIBRARY_ITEM_MUTATION, { + id, + archived, + }) + setState({ loading: false, error: null, data }) + return data + } catch (error) { + const err = error instanceof Error ? error : new Error('Archive failed') + setState({ loading: false, error: err, data: null }) + throw err + } + }, []) + + return { ...state, archiveItem } +} + +export function useDeleteItem() { + const [state, setState] = useState>({ + loading: false, + error: null, + data: null, + }) + + const deleteItem = useCallback(async (id: string) => { + setState({ loading: true, error: null, data: null }) + try { + const data = await graphqlRequest(DELETE_LIBRARY_ITEM_MUTATION, { id }) + setState({ loading: false, error: null, data }) + return data + } catch (error) { + const err = error instanceof Error ? error : new Error('Delete failed') + setState({ loading: false, error: err, data: null }) + throw err + } + }, []) + + return { ...state, deleteItem } +} + +export function useUpdateReadingProgress() { + const [state, setState] = useState>({ + loading: false, + error: null, + data: null, + }) + + const updateProgress = useCallback( + async ( + id: string, + progress: { + readingProgressTopPercent: number + readingProgressBottomPercent: number + readingProgressAnchorIndex?: number + readingProgressHighestAnchor?: number + } + ) => { + setState({ loading: true, error: null, data: null }) + try { + const data = await graphqlRequest(UPDATE_READING_PROGRESS_MUTATION, { + id, + progress, + }) + setState({ loading: false, error: null, data }) + return data + } catch (error) { + const err = + error instanceof Error ? error : new Error('Update progress failed') + setState({ loading: false, error: err, data: null }) + throw err + } + }, + [] + ) + + return { ...state, updateProgress } +} + +export function useMoveToFolder() { + const [state, setState] = useState>({ + loading: false, + error: null, + data: null, + }) + + const moveToFolder = useCallback(async (id: string, folder: string) => { + setState({ loading: true, error: null, data: null }) + try { + const data = await graphqlRequest(MOVE_LIBRARY_ITEM_TO_FOLDER_MUTATION, { + id, + folder, + }) + setState({ loading: false, error: null, data }) + return data + } catch (error) { + const err = + error instanceof Error ? error : new Error('Move to folder failed') + setState({ loading: false, error: err, data: null }) + throw err + } + }, []) + + return { ...state, moveToFolder } +} + +// ==================== BULK OPERATION HOOKS ==================== + +interface BulkActionResult { + success: boolean + successCount: number + failureCount: number + errors?: string[] + message?: string +} + +export function useBulkArchive() { + const [state, setState] = useState>({ + loading: false, + error: null, + data: null, + }) + + const bulkArchive = useCallback( + async (itemIds: string[], archived: boolean) => { + setState({ loading: true, error: null, data: null }) + try { + const result = await graphqlRequest<{ + bulkArchiveItems: BulkActionResult + }>(BULK_ARCHIVE_ITEMS_MUTATION, { itemIds, archived }) + setState({ loading: false, error: null, data: result.bulkArchiveItems }) + return result.bulkArchiveItems + } catch (error) { + const err = + error instanceof Error ? error : new Error('Bulk archive failed') + setState({ loading: false, error: err, data: null }) + throw err + } + }, + [] + ) + + return { ...state, bulkArchive } +} + +export function useBulkDelete() { + const [state, setState] = useState>({ + loading: false, + error: null, + data: null, + }) + + const bulkDelete = useCallback(async (itemIds: string[]) => { + setState({ loading: true, error: null, data: null }) + try { + const result = await graphqlRequest<{ bulkDeleteItems: BulkActionResult }>( + BULK_DELETE_ITEMS_MUTATION, + { itemIds } + ) + setState({ loading: false, error: null, data: result.bulkDeleteItems }) + return result.bulkDeleteItems + } catch (error) { + const err = error instanceof Error ? error : new Error('Bulk delete failed') + setState({ loading: false, error: err, data: null }) + throw err + } + }, []) + + return { ...state, bulkDelete } +} + +export function useBulkMoveToFolder() { + const [state, setState] = useState>({ + loading: false, + error: null, + data: null, + }) + + const bulkMoveToFolder = useCallback( + async (itemIds: string[], folder: string) => { + setState({ loading: true, error: null, data: null }) + try { + const result = await graphqlRequest<{ + bulkMoveToFolder: BulkActionResult + }>(BULK_MOVE_TO_FOLDER_MUTATION, { itemIds, folder }) + setState({ loading: false, error: null, data: result.bulkMoveToFolder }) + return result.bulkMoveToFolder + } catch (error) { + const err = + error instanceof Error ? error : new Error('Bulk move to folder failed') + setState({ loading: false, error: err, data: null }) + throw err + } + }, + [] + ) + + return { ...state, bulkMoveToFolder } +} + +export function useBulkMarkAsRead() { + const [state, setState] = useState>({ + loading: false, + error: null, + data: null, + }) + + const bulkMarkAsRead = useCallback(async (itemIds: string[]) => { + setState({ loading: true, error: null, data: null }) + try { + const result = await graphqlRequest<{ + bulkMarkAsRead: BulkActionResult + }>(BULK_MARK_AS_READ_MUTATION, { itemIds }) + setState({ loading: false, error: null, data: result.bulkMarkAsRead }) + return result.bulkMarkAsRead + } catch (error) { + const err = + error instanceof Error ? error : new Error('Bulk mark as read failed') + setState({ loading: false, error: err, data: null }) + throw err + } + }, []) + + return { ...state, bulkMarkAsRead } +} + +// ==================== LABEL TYPES ==================== + +export interface Label { + id: string + name: string + color: string + description?: string | null + position: number + internal: boolean + createdAt: string + updatedAt: string +} + +export interface CreateLabelInput { + name: string + color?: string + description?: string +} + +export interface UpdateLabelInput { + name?: string + color?: string + description?: string +} + +// ==================== LABEL QUERIES ==================== + +const GET_LABELS_QUERY = ` + query GetLabels { + labels { + id + name + color + description + position + internal + createdAt + updatedAt + } + } +` + +const GET_LABEL_QUERY = ` + query GetLabel($id: String!) { + label(id: $id) { + id + name + color + description + position + internal + createdAt + updatedAt + } + } +` + +// ==================== LABEL MUTATIONS ==================== + +const CREATE_LABEL_MUTATION = ` + mutation CreateLabel($input: CreateLabelInput!) { + createLabel(input: $input) { + id + name + color + description + position + internal + createdAt + updatedAt + } + } +` + +const UPDATE_LABEL_MUTATION = ` + mutation UpdateLabel($id: String!, $input: UpdateLabelInput!) { + updateLabel(id: $id, input: $input) { + id + name + color + description + position + internal + updatedAt + } + } +` + +const DELETE_LABEL_MUTATION = ` + mutation DeleteLabel($id: String!) { + deleteLabel(id: $id) { + success + message + itemId + } + } +` + +const SET_LIBRARY_ITEM_LABELS_MUTATION = ` + mutation SetLibraryItemLabels($itemId: String!, $labelIds: [String!]!) { + setLibraryItemLabels(itemId: $itemId, labelIds: $labelIds) { + id + name + color + } + } +` + +// ==================== LABEL HOOKS ==================== + +export function useLabels() { + const [state, setState] = useState<{ + loading: boolean + error: Error | null + data: Label[] | null + }>({ + loading: false, + error: null, + data: null, + }) + + const fetchLabels = useCallback(async () => { + setState({ loading: true, error: null, data: null }) + try { + const result = await graphqlRequest<{ labels: Label[] }>(GET_LABELS_QUERY) + setState({ loading: false, error: null, data: result.labels }) + return result.labels + } catch (error) { + const err = error instanceof Error ? error : new Error('Failed to fetch labels') + setState({ loading: false, error: err, data: null }) + throw err + } + }, []) + + return { ...state, fetchLabels, refetch: fetchLabels } +} + +export function useCreateLabel() { + const [state, setState] = useState>({ + loading: false, + error: null, + data: null, + }) + + const createLabel = useCallback(async (input: CreateLabelInput) => { + setState({ loading: true, error: null, data: null }) + try { + const result = await graphqlRequest<{ createLabel: Label }>( + CREATE_LABEL_MUTATION, + { input } + ) + setState({ loading: false, error: null, data: result.createLabel }) + return result.createLabel + } catch (error) { + const err = error instanceof Error ? error : new Error('Failed to create label') + setState({ loading: false, error: err, data: null }) + throw err + } + }, []) + + return { ...state, createLabel } +} + +export function useUpdateLabel() { + const [state, setState] = useState>({ + loading: false, + error: null, + data: null, + }) + + const updateLabel = useCallback( + async (id: string, input: UpdateLabelInput) => { + setState({ loading: true, error: null, data: null }) + try { + const result = await graphqlRequest<{ updateLabel: Label }>( + UPDATE_LABEL_MUTATION, + { id, input } + ) + setState({ loading: false, error: null, data: result.updateLabel }) + return result.updateLabel + } catch (error) { + const err = error instanceof Error ? error : new Error('Failed to update label') + setState({ loading: false, error: err, data: null }) + throw err + } + }, + [] + ) + + return { ...state, updateLabel } +} + +export function useDeleteLabel() { + const [state, setState] = useState>({ + loading: false, + error: null, + data: null, + }) + + const deleteLabel = useCallback(async (id: string) => { + setState({ loading: true, error: null, data: null }) + try { + const result = await graphqlRequest<{ deleteLabel: DeleteResult }>( + DELETE_LABEL_MUTATION, + { id } + ) + setState({ loading: false, error: null, data: result.deleteLabel }) + return result.deleteLabel + } catch (error) { + const err = error instanceof Error ? error : new Error('Failed to delete label') + setState({ loading: false, error: err, data: null }) + throw err + } + }, []) + + return { ...state, deleteLabel } +} + +export function useSetLibraryItemLabels() { + const [state, setState] = useState>({ + loading: false, + error: null, + data: null, + }) + + const setLibraryItemLabels = useCallback( + async (itemId: string, labelIds: string[]) => { + setState({ loading: true, error: null, data: null }) + try { + const result = await graphqlRequest<{ + setLibraryItemLabels: Label[] + }>(SET_LIBRARY_ITEM_LABELS_MUTATION, { itemId, labelIds }) + setState({ + loading: false, + error: null, + data: result.setLibraryItemLabels, + }) + return result.setLibraryItemLabels + } catch (error) { + const err = + error instanceof Error ? error : new Error('Failed to set item labels') + setState({ loading: false, error: err, data: null }) + throw err + } + }, + [] + ) + + return { ...state, setLibraryItemLabels } +} diff --git a/packages/web-vite/src/pages/LabelsPage.tsx b/packages/web-vite/src/pages/LabelsPage.tsx new file mode 100644 index 000000000..30e4ec670 --- /dev/null +++ b/packages/web-vite/src/pages/LabelsPage.tsx @@ -0,0 +1,292 @@ +import { useEffect, useState } from 'react' +import { + useLabels, + useCreateLabel, + useUpdateLabel, + useDeleteLabel, + type Label, + type CreateLabelInput, + type UpdateLabelInput, +} from '../lib/graphql-client' +import '../styles/LabelsPage.css' + +export function LabelsPage() { + const { data: labels, loading, error, fetchLabels } = useLabels() + const { createLabel, loading: creating } = useCreateLabel() + const { updateLabel, loading: updating } = useUpdateLabel() + const { deleteLabel, loading: deleting } = useDeleteLabel() + + const [showCreateForm, setShowCreateForm] = useState(false) + const [editingLabel, setEditingLabel] = useState