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