Kaynağa Gözat

feat(file-manager): user-authored tags for cross-cutting filtering (#1268)

  Third and final piece of #1268, alongside the recursive-search +
  README-panel commit that landed earlier in 0.2.5b1. Folders express
  hierarchy (one home per file); tags are orthogonal labels — "toy",
  "kid-safe", "petg-only" — and a single file can carry as many as the
  user wants. Reporter wanted to find "every toy regardless of which
  folder it lives in"; folders alone can't do that without forcing the
  file into one bucket.

  Design decisions locked with maziggy before code:

    - file-only (folders already express hierarchy)
    - multi-tag filter = AND
    - tag filter IGNORES the selected folder (cross-cutting by design)
    - bulk-tagging from multi-select toolbar in v1
    - no auto-tags from 3MF metadata (user-authored only)
    - label-only chips, no color/icon

  Backend

    - LibraryTag (id, name, name_key UNIQUE = LOWER(TRIM(name)))
      in backend/app/models/library.py. Case-insensitive UNIQUE
      collapses "Toys"/"toys"/"TOYS  " into one row, so the route
      returns 409 instead of silently fragmenting the catalog.
    - LibraryFileTag(file_id, tag_id) association, composite PK,
      ON DELETE CASCADE both directions. Deleting a tag drops every
      chip; files survive. Deleting a file drops its tag links; the
      catalog row survives.
    - Both tables auto-create via Base.metadata.create_all — no
      explicit run_migrations step needed for new tables.
    - New router at backend/app/api/routes/library_tags.py with:
        GET /library/tags         (list + per-tag file_count)
        POST /library/tags        (create, 409 on case-insensitive dup)
        PATCH /library/tags/{id}  (rename, 409 on collision, self-rename OK)
        DELETE /library/tags/{id} (cascade)
        POST /library/tags/bulk-assign  (add | remove | replace)
    - Bulk-assign add is idempotent; replace with empty tag_ids clears
      the file's tag set. Per-file ownership enforced — *_OWN callers
      can only modify their own files; unknown file_ids quietly
      skipped (matches library_trash bulk shape).
    - list_files gains tag_ids: list[int] query param. AND semantics
      via JOIN + GROUP BY + HAVING COUNT(DISTINCT) — portable across
      SQLite and Postgres. When tag_ids is non-empty, folder_id /
      project_id / include_root / recursive are all bypassed so the
      result is cross-cutting.
    - FileListResponse gains tags: list[{id, name}] via
      selectinload(LibraryFile.tags) — N+1-free chip render.
    - Permissions reuse existing constants: LIBRARY_UPDATE_ALL for
      catalog mutations (global catalog, ownership-aware update isn't
      meaningful), LIBRARY_UPDATE_ALL/OWN pair for bulk-assign,
      LIBRARY_READ_ALL/OWN for list — file_count projection narrows
      for *_OWN callers so chip counts match what they actually see.

  Frontend

    - LibraryTagsModal (catalog CRUD) opens from the toolbar's new
      Tags button. max-w-4xl so multi-language subtitles don't wrap.
      Delete-with-warning when file_count > 0 ("removes the chip from
      all of them; files themselves are untouched").
    - BulkTagsPickerModal opens from the multi-select toolbar (new
      Tag button between Move and Delete). Add/Remove radio,
      checkbox list, inline "create new tag" disabled on dup.
      Apply disabled until at least one tag is selected. The replace
      action is exposed in the API but deliberately NOT in this UI —
      arbitrary multi-file replace is destructive and confusing.
    - FileManagerPage integration:
        * selectedTagIds state, sorted into the useQuery key so the
          cache hits are stable regardless of toggle order
        * filter rail above the file list lists EVERY catalog tag as
          a togglable chip — inactive outlined, active filled green
          with an X. Clear all when 1+ active. Bar hidden entirely
          when catalog is empty.
        * useEffect prunes selectedTagIds when a tag is deleted from
          the catalog so the filter never strands on a phantom id
        * dedicated Tags column in list view at minmax(0,200px)
          between Prints and Actions
        * grid view chips render below the metadata block
        * chip clicks stop propagation so they don't toggle file
          selection
    - libraryTagsQueryKey extracted to frontend/src/utils/
      libraryTagsQuery.ts so component files export only components
      (Vite react-refresh rule).
    - LibraryFileListItem.tags is OPTIONAL even though the backend
      always emits an empty array — legacy msw mocks in pre-existing
      tests construct partial file shapes without the field. Without
      the ? the FileCard renderer crashed on .length and broke 49
      unrelated tests across FileManagerPage + FileManagerExternalFolder.
      Read sites use file.tags ?? [].
maziggy 2 ay önce
ebeveyn
işleme
bb42b423af

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
CHANGELOG.md


+ 29 - 5
backend/app/api/routes/library.py

@@ -16,7 +16,7 @@ from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
 from fastapi.responses import FileResponse as FastAPIFileResponse
-from sqlalchemy import func, select
+from sqlalchemy import distinct, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -31,7 +31,7 @@ from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
-from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.project import Project
 from backend.app.models.user import User
@@ -58,6 +58,7 @@ from backend.app.schemas.library import (
     FolderResponse,
     FolderTreeItem,
     FolderUpdate,
+    TagSummary,
     ZipExtractError,
     ZipExtractResponse,
     ZipExtractResult,
@@ -1790,6 +1791,7 @@ async def list_files(
     internal_only: bool = False,
     external_only: bool = False,
     recursive: bool = False,
+    tag_ids: list[int] = Query(default_factory=list),
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -1816,6 +1818,11 @@ async def list_files(
                    that walks ``library_folders.parent_id``. Default off so
                    existing callers (folder browsing, etc.) keep their narrow
                    single-folder semantics.
+        tag_ids: Restrict the listing to files carrying ALL of these tags
+                 (AND semantics, #1268). When non-empty the folder filter is
+                 intentionally bypassed — tags are cross-cutting and the user
+                 wants "every file with this tag" regardless of where it lives.
+                 ``recursive`` becomes irrelevant in that case.
     """
     if internal_only and external_only:
         raise HTTPException(
@@ -1824,11 +1831,27 @@ async def list_files(
         )
 
     user, can_read_all = auth_result
-    query = LibraryFile.active().options(selectinload(LibraryFile.created_by))
+    query = LibraryFile.active().options(
+        selectinload(LibraryFile.created_by),
+        selectinload(LibraryFile.tags),
+    )
     if user is not None and not can_read_all:
         query = query.where(LibraryFile.created_by_id == user.id)
 
-    if folder_id is not None and recursive:
+    if tag_ids:
+        # Cross-cutting filter — every requested tag must be present on the
+        # file. JOIN + GROUP BY + HAVING COUNT(DISTINCT) is portable across
+        # SQLite and Postgres without dialect tricks. We deliberately skip
+        # the folder / project / include_root scoping below so the result
+        # is the global "all files carrying these tags".
+        unique_tag_ids = list(dict.fromkeys(tag_ids))
+        query = (
+            query.join(LibraryFileTag, LibraryFileTag.file_id == LibraryFile.id)
+            .where(LibraryFileTag.tag_id.in_(unique_tag_ids))
+            .group_by(LibraryFile.id)
+            .having(func.count(distinct(LibraryFileTag.tag_id)) == len(unique_tag_ids))
+        )
+    elif folder_id is not None and recursive:
         # Walk the subtree starting at folder_id and collect every descendant
         # id. Recursive CTE works on both SQLite (>=3.8.3, shipped 2014) and
         # Postgres without dialect branching.
@@ -1853,7 +1876,7 @@ async def list_files(
 
     query = query.order_by(LibraryFile.filename)
     result = await db.execute(query)
-    files = result.scalars().all()
+    files = result.scalars().unique().all() if tag_ids else result.scalars().all()
 
     # Get duplicate counts
     hash_counts = {}
@@ -1901,6 +1924,7 @@ async def list_files(
                 print_time_seconds=print_time,
                 filament_used_grams=filament_grams,
                 sliced_for_model=sliced_for_model,
+                tags=[TagSummary(id=t.id, name=t.name) for t in f.tags],
             )
         )
 

+ 300 - 0
backend/app/api/routes/library_tags.py

@@ -0,0 +1,300 @@
+"""Library tag catalog + per-file assignment endpoints (#1268).
+
+Tags are global cross-cutting labels for library files — one catalog per
+install, no per-user partitioning. Designed as the orthogonal complement to
+folders: folders express hierarchy, tags express attributes ("toy",
+"kid-safe", "petg-only"). The reporter (#1268) and at least one upvoter
+asked for them; the design decisions were locked with @maziggy:
+
+* tags apply to files only (folders already express hierarchy)
+* the tag filter on the file list intentionally IGNORES the selected folder
+  so "show me every toy regardless of where it lives" works (multi-tag = AND)
+* bulk-tagging from the multi-select toolbar ships in v1
+* no auto-tags from 3MF metadata; user-authored only
+* no color, no icon — label-only chips
+
+Permission model:
+
+* **Catalog mutations** (POST / PATCH / DELETE on ``/library/tags``) require
+  :attr:`Permission.LIBRARY_UPDATE_ALL` because the catalog is global —
+  ownership-aware update isn't meaningful for a row no user owns.
+* **Bulk assignment** is gated by the existing
+  :attr:`Permission.LIBRARY_UPDATE_ALL` / :attr:`Permission.LIBRARY_UPDATE_OWN`
+  pair so a ``*_OWN`` user can only re-tag files they created.
+* **GET** is gated by :attr:`Permission.LIBRARY_READ_ALL` /
+  :attr:`Permission.LIBRARY_READ_OWN` — ``*_OWN`` callers see every catalog
+  row (it's just labels), but ``file_count`` is filtered to their own files.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import delete, distinct, func, select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryTag
+from backend.app.models.user import User
+from backend.app.schemas.library import (
+    TagBulkAssignRequest,
+    TagBulkAssignResponse,
+    TagCreate,
+    TagResponse,
+    TagUpdate,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/library/tags", tags=["library-tags"])
+
+
+def _name_key(name: str) -> str:
+    """Case-insensitive uniqueness key — LOWER(TRIM(name)).
+
+    Mirrors the same convention used by Locations (#1505) so the catalog
+    can't end up with "Toys" + "toys" + " TOYS " as separate rows. Empty
+    string after stripping is rejected by Pydantic min_length, so this
+    helper trusts its input.
+    """
+    return name.strip().lower()
+
+
+@router.get("", response_model=list[TagResponse])
+@router.get("/", response_model=list[TagResponse])
+async def list_tags(
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+) -> list[TagResponse]:
+    """List every tag in the catalog with the count of files using it.
+
+    Catalog rows are global, so a ``read_own`` caller still sees every tag
+    name — that's just the chip set the rest of the UI offers. But the
+    ``file_count`` projection is filtered to their own files so the number
+    matches what they'd see when they filter the listing by that tag.
+    """
+    user, can_read_all = auth_result
+
+    # Count distinct file_ids per tag via the association table joined back
+    # to LibraryFile so soft-deleted (trashed) files don't inflate the chip
+    # counts shown in the management modal.
+    file_filter = LibraryFile.deleted_at.is_(None)
+    if user is not None and not can_read_all:
+        file_filter = file_filter & (LibraryFile.created_by_id == user.id)
+
+    count_subq = (
+        select(
+            LibraryFileTag.tag_id.label("tag_id"),
+            func.count(distinct(LibraryFile.id)).label("file_count"),
+        )
+        .join(LibraryFile, LibraryFile.id == LibraryFileTag.file_id)
+        .where(file_filter)
+        .group_by(LibraryFileTag.tag_id)
+        .subquery()
+    )
+
+    query = (
+        select(LibraryTag, func.coalesce(count_subq.c.file_count, 0))
+        .outerjoin(count_subq, count_subq.c.tag_id == LibraryTag.id)
+        .order_by(func.lower(LibraryTag.name))
+    )
+    rows = (await db.execute(query)).all()
+    return [
+        TagResponse(
+            id=t.id,
+            name=t.name,
+            file_count=int(count),
+            created_at=t.created_at,
+            updated_at=t.updated_at,
+        )
+        for t, count in rows
+    ]
+
+
+@router.post("", response_model=TagResponse, status_code=201)
+@router.post("/", response_model=TagResponse, status_code=201)
+async def create_tag(
+    payload: TagCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
+) -> TagResponse:
+    """Create a tag. Case-insensitive dup → 409."""
+    key = _name_key(payload.name)
+    tag = LibraryTag(name=payload.name.strip(), name_key=key)
+    db.add(tag)
+    try:
+        await db.commit()
+    except IntegrityError:
+        # Race condition or actual dup — re-fetch the existing row so the
+        # caller can recover by reading the id from the 409 detail string
+        # if they want to. The body is consistent regardless of cause.
+        await db.rollback()
+        raise HTTPException(status_code=409, detail="Tag with this name already exists") from None
+    await db.refresh(tag)
+    return TagResponse(id=tag.id, name=tag.name, file_count=0, created_at=tag.created_at, updated_at=tag.updated_at)
+
+
+@router.patch("/{tag_id}", response_model=TagResponse)
+async def update_tag(
+    tag_id: int,
+    payload: TagUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
+) -> TagResponse:
+    """Rename a tag. Case-insensitive dup → 409 (own-name no-op is allowed)."""
+    tag = (await db.execute(select(LibraryTag).where(LibraryTag.id == tag_id))).scalar_one_or_none()
+    if tag is None:
+        raise HTTPException(status_code=404, detail="Tag not found")
+
+    new_key = _name_key(payload.name)
+    if new_key != tag.name_key:
+        # Pre-check so the user gets a clean 409 instead of an IntegrityError
+        # that we'd then have to translate. The post-commit IntegrityError
+        # branch still catches the concurrent-create race.
+        existing = (await db.execute(select(LibraryTag).where(LibraryTag.name_key == new_key))).scalar_one_or_none()
+        if existing is not None and existing.id != tag.id:
+            raise HTTPException(status_code=409, detail="Tag with this name already exists")
+    tag.name = payload.name.strip()
+    tag.name_key = new_key
+    try:
+        await db.commit()
+    except IntegrityError:
+        await db.rollback()
+        raise HTTPException(status_code=409, detail="Tag with this name already exists") from None
+    await db.refresh(tag)
+
+    # Re-count files for the projection so the caller's modal shows the
+    # right number after the rename.
+    file_count = (
+        await db.execute(select(func.count(LibraryFileTag.file_id)).where(LibraryFileTag.tag_id == tag.id))
+    ).scalar_one()
+    return TagResponse(
+        id=tag.id,
+        name=tag.name,
+        file_count=int(file_count or 0),
+        created_at=tag.created_at,
+        updated_at=tag.updated_at,
+    )
+
+
+@router.delete("/{tag_id}", status_code=204)
+async def delete_tag(
+    tag_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
+) -> None:
+    """Delete a tag. Association rows ON DELETE CASCADE — files are untouched."""
+    tag = (await db.execute(select(LibraryTag).where(LibraryTag.id == tag_id))).scalar_one_or_none()
+    if tag is None:
+        raise HTTPException(status_code=404, detail="Tag not found")
+    await db.delete(tag)
+    await db.commit()
+
+
+@router.post("/bulk-assign", response_model=TagBulkAssignResponse)
+async def bulk_assign(
+    payload: TagBulkAssignRequest,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> TagBulkAssignResponse:
+    """Add / remove / replace tag assignments across multiple files.
+
+    Implemented as set-style operations against the association table —
+    cheaper than re-doing the M2M list per file and idempotent on retries.
+    A caller without ``*_UPDATE_ALL`` can only modify files they created
+    (per the existing ownership pair); silently-skipped files are
+    excluded from the response counts so the UI can detect partial
+    application.
+    """
+    user, can_update_all = auth_result
+
+    # Resolve the file scope FIRST — anything not visible to the caller is
+    # quietly dropped, so a malicious or buggy client can't tag files it
+    # doesn't own. This is the same posture as bulk-delete in
+    # library_trash.py.
+    file_q = select(LibraryFile.id).where(
+        LibraryFile.id.in_(payload.file_ids),
+        LibraryFile.deleted_at.is_(None),
+    )
+    if user is not None and not can_update_all:
+        file_q = file_q.where(LibraryFile.created_by_id == user.id)
+    file_ids = list((await db.execute(file_q)).scalars().all())
+    if not file_ids:
+        return TagBulkAssignResponse(files_updated=0, associations_added=0, associations_removed=0)
+
+    # Validate tag ids exist. Unknown tag_ids are silently dropped from
+    # the operation rather than raising — matches the bulk-trash shape
+    # and keeps a partial-success result usable.
+    tag_ids: list[int] = []
+    if payload.tag_ids:
+        tag_ids = list(
+            (await db.execute(select(LibraryTag.id).where(LibraryTag.id.in_(payload.tag_ids)))).scalars().all()
+        )
+
+    added = 0
+    removed = 0
+
+    if payload.action == "add":
+        if not tag_ids:
+            return TagBulkAssignResponse(files_updated=0, associations_added=0, associations_removed=0)
+        # Insert (file_id, tag_id) for every pair that doesn't already exist.
+        # We could use INSERT ... ON CONFLICT DO NOTHING for Postgres + SQLite
+        # 3.24+ but the explicit pre-check keeps the SQLAlchemy core dialect
+        # neutral and lets us count what actually got added.
+        existing = set(
+            (
+                await db.execute(
+                    select(LibraryFileTag.file_id, LibraryFileTag.tag_id).where(
+                        LibraryFileTag.file_id.in_(file_ids),
+                        LibraryFileTag.tag_id.in_(tag_ids),
+                    )
+                )
+            ).all()
+        )
+        to_insert = [
+            {"file_id": fid, "tag_id": tid} for fid in file_ids for tid in tag_ids if (fid, tid) not in existing
+        ]
+        if to_insert:
+            await db.execute(LibraryFileTag.__table__.insert(), to_insert)
+            added = len(to_insert)
+    elif payload.action == "remove":
+        if not tag_ids:
+            return TagBulkAssignResponse(files_updated=0, associations_added=0, associations_removed=0)
+        result = await db.execute(
+            delete(LibraryFileTag).where(
+                LibraryFileTag.file_id.in_(file_ids),
+                LibraryFileTag.tag_id.in_(tag_ids),
+            )
+        )
+        removed = int(result.rowcount or 0)
+    elif payload.action == "replace":
+        # Strip everything currently on these files, then INSERT the new set.
+        del_result = await db.execute(delete(LibraryFileTag).where(LibraryFileTag.file_id.in_(file_ids)))
+        removed = int(del_result.rowcount or 0)
+        if tag_ids:
+            await db.execute(
+                LibraryFileTag.__table__.insert(),
+                [{"file_id": fid, "tag_id": tid} for fid in file_ids for tid in tag_ids],
+            )
+            added = len(file_ids) * len(tag_ids)
+
+    await db.commit()
+    return TagBulkAssignResponse(
+        files_updated=len(file_ids),
+        associations_added=added,
+        associations_removed=removed,
+    )

+ 2 - 0
backend/app/main.py

@@ -37,6 +37,7 @@ from backend.app.api.routes import (
     kprofiles,
     labels,
     library,
+    library_tags,
     library_trash,
     local_backup,
     local_presets,
@@ -6614,6 +6615,7 @@ app.include_router(camera.router, prefix=app_settings.api_prefix)
 app.include_router(external_links.router, prefix=app_settings.api_prefix)
 app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(library.router, prefix=app_settings.api_prefix)
+app.include_router(library_tags.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
 app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
 app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)

+ 48 - 0
backend/app/models/library.py

@@ -106,6 +106,13 @@ class LibraryFile(Base):
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     project: Mapped["Project | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
+    # Tags (#1268). M2M via library_file_tags. Loaded explicitly via
+    # ``selectinload`` in list_files so each row in the listing carries its
+    # chip set without N+1 fetches.
+    tags: Mapped[list["LibraryTag"]] = relationship(
+        secondary="library_file_tags",
+        back_populates="files",
+    )
 
     @classmethod
     def active(cls) -> "Select[tuple[LibraryFile]]":
@@ -119,6 +126,47 @@ class LibraryFile(Base):
         return select(cls).where(cls.deleted_at.is_(None))
 
 
+class LibraryTag(Base):
+    """User-authored cross-cutting label for library files (#1268).
+
+    Folders express hierarchy; tags express orthogonal attributes ("toy",
+    "kid-safe", "petg-only"). Catalog is global (one tag set per install)
+    — the multi-user "private tags" case is not in v1 scope. ``name_key``
+    is ``LOWER(TRIM(name))`` so "Toys" / "toys" / "  TOYS  " all collide
+    on the UNIQUE index and the route returns 409 instead of silently
+    creating a duplicate.
+    """
+
+    __tablename__ = "library_tags"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(64), nullable=False)
+    name_key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    files: Mapped[list["LibraryFile"]] = relationship(
+        secondary="library_file_tags",
+        back_populates="tags",
+    )
+
+
+class LibraryFileTag(Base):
+    """Association between library files and tags (#1268).
+
+    Composite PK so the same (file, tag) pair can't be inserted twice. Both
+    sides ON DELETE CASCADE: deleting a tag drops every association row,
+    deleting a file drops its tag links, and the catalog row survives so
+    other files keep their chip.
+    """
+
+    __tablename__ = "library_file_tags"
+
+    file_id: Mapped[int] = mapped_column(ForeignKey("library_files.id", ondelete="CASCADE"), primary_key=True)
+    tag_id: Mapped[int] = mapped_column(ForeignKey("library_tags.id", ondelete="CASCADE"), primary_key=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+
 from backend.app.models.archive import PrintArchive  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811
 from backend.app.models.user import User  # noqa: E402, F811

+ 65 - 0
backend/app/schemas/library.py

@@ -179,6 +179,16 @@ class FileResponse(BaseModel):
         from_attributes = True
 
 
+class TagSummary(BaseModel):
+    """Compact tag projection — embedded in file listings (#1268)."""
+
+    id: int
+    name: str
+
+    class Config:
+        from_attributes = True
+
+
 class FileListResponse(BaseModel):
     """Schema for file list item (lighter than full response)."""
 
@@ -202,10 +212,65 @@ class FileListResponse(BaseModel):
     filament_used_grams: float | None = None
     sliced_for_model: str | None = None
 
+    # Tags assigned to this file (#1268). Empty list when the file has none —
+    # never null, so the FE can iterate without a guard.
+    tags: list[TagSummary] = []
+
+    class Config:
+        from_attributes = True
+
+
+# ============ Tag Schemas (#1268) ============
+
+
+class TagResponse(BaseModel):
+    """Tag with the count of files currently using it."""
+
+    id: int
+    name: str
+    file_count: int
+    created_at: datetime
+    updated_at: datetime
+
     class Config:
         from_attributes = True
 
 
+class TagCreate(BaseModel):
+    """Create a new tag (catalog row)."""
+
+    name: str = Field(..., min_length=1, max_length=64)
+
+
+class TagUpdate(BaseModel):
+    """Rename a tag. ``name`` is required — there's nothing else to update."""
+
+    name: str = Field(..., min_length=1, max_length=64)
+
+
+class TagBulkAssignRequest(BaseModel):
+    """Bulk tag assignment payload.
+
+    ``action='add'``      → append tags to every listed file (idempotent on dup).
+    ``action='remove'``   → strip the listed tags from every listed file.
+    ``action='replace'``  → REPLACE the tag set on every listed file with the
+                            exact set in ``tag_ids`` (omitting tag_ids clears
+                            them all).
+    """
+
+    file_ids: list[int] = Field(..., min_length=1)
+    tag_ids: list[int] = Field(default_factory=list)
+    action: str = Field("add", pattern="^(add|remove|replace)$")
+
+
+class TagBulkAssignResponse(BaseModel):
+    """Result of a bulk-assign call."""
+
+    files_updated: int
+    associations_added: int
+    associations_removed: int
+
+
 class FileMoveRequest(BaseModel):
     """Schema for moving files to a folder."""
 

+ 288 - 0
backend/tests/integration/test_library_tags_api.py

@@ -0,0 +1,288 @@
+"""Integration tests for the library tag catalog + bulk-assign (#1268)."""
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.fixture
+async def folder_factory(db_session):
+    """Minimal folder factory shared across the tests in this module."""
+    _counter = [0]
+
+    async def _create_folder(**kwargs):
+        from backend.app.models.library import LibraryFolder
+
+        _counter[0] += 1
+        defaults = {"name": f"Folder {_counter[0]}"}
+        defaults.update(kwargs)
+        f = LibraryFolder(**defaults)
+        db_session.add(f)
+        await db_session.commit()
+        await db_session.refresh(f)
+        return f
+
+    return _create_folder
+
+
+@pytest.fixture
+async def file_factory(db_session):
+    """Minimal file factory shared across the tests in this module."""
+    _counter = [0]
+
+    async def _create_file(**kwargs):
+        from backend.app.models.library import LibraryFile
+
+        _counter[0] += 1
+        defaults = {
+            "filename": f"file_{_counter[0]}.3mf",
+            "file_path": f"/test/file_{_counter[0]}.3mf",
+            "file_size": 100,
+            "file_type": "3mf",
+        }
+        defaults.update(kwargs)
+        f = LibraryFile(**defaults)
+        db_session.add(f)
+        await db_session.commit()
+        await db_session.refresh(f)
+        return f
+
+    return _create_file
+
+
+class TestLibraryTagCRUD:
+    """Catalog CRUD: create / list / rename / delete."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_tag_and_list(self, async_client: AsyncClient):
+        r = await async_client.post("/api/v1/library/tags", json={"name": "toy"})
+        assert r.status_code == 201
+        body = r.json()
+        assert body["name"] == "toy"
+        assert body["file_count"] == 0
+
+        r = await async_client.get("/api/v1/library/tags")
+        assert r.status_code == 200
+        names = [t["name"] for t in r.json()]
+        assert "toy" in names
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_tag_strips_whitespace(self, async_client: AsyncClient):
+        r = await async_client.post("/api/v1/library/tags", json={"name": "  kid-safe  "})
+        assert r.status_code == 201
+        assert r.json()["name"] == "kid-safe"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_duplicate_case_insensitive_409(self, async_client: AsyncClient):
+        """'Toys' / 'toys' / 'TOYS  ' all collide on name_key."""
+        r1 = await async_client.post("/api/v1/library/tags", json={"name": "Toys"})
+        assert r1.status_code == 201
+        for dup in ("toys", "TOYS", "  ToYs  "):
+            r = await async_client.post("/api/v1/library/tags", json={"name": dup})
+            assert r.status_code == 409, dup
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag(self, async_client: AsyncClient):
+        r = await async_client.post("/api/v1/library/tags", json={"name": "kidsafe"})
+        tag_id = r.json()["id"]
+        r = await async_client.patch(f"/api/v1/library/tags/{tag_id}", json={"name": "kid-safe"})
+        assert r.status_code == 200
+        assert r.json()["name"] == "kid-safe"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_collision_409(self, async_client: AsyncClient):
+        a = (await async_client.post("/api/v1/library/tags", json={"name": "a"})).json()
+        b = (await async_client.post("/api/v1/library/tags", json={"name": "b"})).json()
+        # Renaming b → A (case-insensitive collision with a) must fail.
+        r = await async_client.patch(f"/api/v1/library/tags/{b['id']}", json={"name": "A"})
+        assert r.status_code == 409
+        # Renaming a row to its own current name (round-trip with the same key)
+        # must NOT 409 — the pre-check excludes the tag itself.
+        r = await async_client.patch(f"/api/v1/library/tags/{a['id']}", json={"name": "a"})
+        assert r.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_tag_cascades_associations_keeps_file(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        tag = (await async_client.post("/api/v1/library/tags", json={"name": "x"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [tag["id"]], "action": "add"},
+        )
+        r = await async_client.delete(f"/api/v1/library/tags/{tag['id']}")
+        assert r.status_code == 204
+
+        # Tag list no longer contains it.
+        names = [t["name"] for t in (await async_client.get("/api/v1/library/tags")).json()]
+        assert "x" not in names
+        # File still listed (CASCADE only dropped the association row).
+        r = await async_client.get(
+            f"/api/v1/library/files?folder_id={f.folder_id}" if f.folder_id else "/api/v1/library/files"
+        )
+        assert any(item["id"] == f.id for item in r.json())
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_unknown_tag_404(self, async_client: AsyncClient):
+        r = await async_client.delete("/api/v1/library/tags/999999")
+        assert r.status_code == 404
+
+
+class TestLibraryTagBulkAssign:
+    """Bulk-assign: add / remove / replace + per-action assertions."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_is_idempotent(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        t = (await async_client.post("/api/v1/library/tags", json={"name": "t"})).json()
+        payload = {"file_ids": [f.id], "tag_ids": [t["id"]], "action": "add"}
+        r1 = await async_client.post("/api/v1/library/tags/bulk-assign", json=payload)
+        assert r1.status_code == 200
+        assert r1.json()["associations_added"] == 1
+        r2 = await async_client.post("/api/v1/library/tags/bulk-assign", json=payload)
+        # Second call adds 0 — pair already exists; route remains 200 not 409.
+        assert r2.status_code == 200
+        assert r2.json()["associations_added"] == 0
+        # And the file_count for the tag is still exactly 1.
+        tags = (await async_client.get("/api/v1/library/tags")).json()
+        assert next(x["file_count"] for x in tags if x["id"] == t["id"]) == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_remove_drops_only_listed_tags(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        a = (await async_client.post("/api/v1/library/tags", json={"name": "a"})).json()
+        b = (await async_client.post("/api/v1/library/tags", json={"name": "b"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [a["id"], b["id"]], "action": "add"},
+        )
+        # Remove only `a`. `b` should still be on the file.
+        r = await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [a["id"]], "action": "remove"},
+        )
+        assert r.status_code == 200
+        assert r.json()["associations_removed"] == 1
+        # Tag-filter listing by `b` still returns the file.
+        r = await async_client.get(f"/api/v1/library/files?tag_ids={b['id']}")
+        assert {x["id"] for x in r.json()} == {f.id}
+        r = await async_client.get(f"/api/v1/library/files?tag_ids={a['id']}")
+        assert {x["id"] for x in r.json()} == set()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_replace_with_empty_tag_set_clears(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        a = (await async_client.post("/api/v1/library/tags", json={"name": "a"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [a["id"]], "action": "add"},
+        )
+        # Replace with [] → file ends up with no tags.
+        r = await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [], "action": "replace"},
+        )
+        assert r.status_code == 200
+        assert r.json()["associations_removed"] == 1
+        # File listing shows empty tags array.
+        r = await async_client.get("/api/v1/library/files?include_root=false")
+        item = next(x for x in r.json() if x["id"] == f.id)
+        assert item["tags"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_file_ids_silently_skipped(self, async_client: AsyncClient, file_factory):
+        """Unknown / inaccessible file ids must not 404 the whole call — the
+        caller may be racing a delete or have a stale selection. Counts reflect
+        what actually happened."""
+        f = await file_factory()
+        t = (await async_client.post("/api/v1/library/tags", json={"name": "t"})).json()
+        r = await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id, 999999], "tag_ids": [t["id"]], "action": "add"},
+        )
+        assert r.status_code == 200
+        body = r.json()
+        assert body["files_updated"] == 1
+        assert body["associations_added"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_action_422(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        r = await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [], "action": "nuke"},
+        )
+        assert r.status_code == 422
+
+
+class TestLibraryTagFilter:
+    """list_files?tag_ids=… — AND semantics + folder bypass."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_and_semantics(self, async_client: AsyncClient, file_factory):
+        a_only = await file_factory(filename="a_only.3mf")
+        b_only = await file_factory(filename="b_only.3mf")
+        ab = await file_factory(filename="ab.3mf")
+        a = (await async_client.post("/api/v1/library/tags", json={"name": "A"})).json()
+        b = (await async_client.post("/api/v1/library/tags", json={"name": "B"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [a_only.id, ab.id], "tag_ids": [a["id"]], "action": "add"},
+        )
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [b_only.id, ab.id], "tag_ids": [b["id"]], "action": "add"},
+        )
+        # Filter by A alone → a_only + ab
+        r = await async_client.get(f"/api/v1/library/files?tag_ids={a['id']}")
+        assert {x["id"] for x in r.json()} == {a_only.id, ab.id}
+        # Filter by A AND B → only ab
+        r = await async_client.get(f"/api/v1/library/files?tag_ids={a['id']}&tag_ids={b['id']}")
+        assert {x["id"] for x in r.json()} == {ab.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_filter_ignores_folder_id(self, async_client: AsyncClient, folder_factory, file_factory):
+        """Tag filter is cross-cutting — passing folder_id must NOT narrow the
+        result. Confirms decision #2 from the design discussion."""
+        folder_a = await folder_factory(name="A")
+        folder_b = await folder_factory(name="B")
+        in_a = await file_factory(folder_id=folder_a.id, filename="in_a.3mf")
+        in_b = await file_factory(folder_id=folder_b.id, filename="in_b.3mf")
+        tag = (await async_client.post("/api/v1/library/tags", json={"name": "x"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={
+                "file_ids": [in_a.id, in_b.id],
+                "tag_ids": [tag["id"]],
+                "action": "add",
+            },
+        )
+        # Pass folder_id=folder_a alongside tag_ids — file from folder_b must
+        # STILL appear because the tag filter overrides folder scoping.
+        r = await async_client.get(f"/api/v1/library/files?folder_id={folder_a.id}&tag_ids={tag['id']}")
+        assert {x["id"] for x in r.json()} == {in_a.id, in_b.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_listing_includes_tags_array(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        tag = (await async_client.post("/api/v1/library/tags", json={"name": "petg"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [tag["id"]], "action": "add"},
+        )
+        r = await async_client.get("/api/v1/library/files?include_root=false")
+        item = next(x for x in r.json() if x["id"] == f.id)
+        assert item["tags"] == [{"id": tag["id"], "name": "petg"}]

+ 107 - 0
frontend/src/__tests__/components/BulkTagsPickerModal.test.tsx

@@ -0,0 +1,107 @@
+/**
+ * Tests for BulkTagsPickerModal (#1268).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BulkTagsPickerModal } from '../../components/BulkTagsPickerModal';
+import { api } from '../../api/client';
+
+const mockShowToast = vi.fn();
+const mockOnClose = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getLibraryTags: vi.fn(),
+    createLibraryTag: vi.fn(),
+    bulkAssignLibraryTags: vi.fn(),
+  },
+}));
+
+vi.mock('../../contexts/ToastContext', () => ({
+  useToast: () => ({ showToast: mockShowToast }),
+}));
+
+const tags = [
+  { id: 1, name: 'toy', file_count: 2, created_at: '2026-01-01', updated_at: '2026-01-01' },
+  { id: 2, name: 'petg', file_count: 7, created_at: '2026-01-01', updated_at: '2026-01-01' },
+];
+
+function renderModal(fileIds: number[] = [10, 11, 12]) {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  return render(
+    <QueryClientProvider client={client}>
+      <MemoryRouter>
+        <BulkTagsPickerModal open fileIds={fileIds} onClose={mockOnClose} />
+      </MemoryRouter>
+    </QueryClientProvider>,
+  );
+}
+
+describe('BulkTagsPickerModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    (api.getLibraryTags as ReturnType<typeof vi.fn>).mockResolvedValue(tags);
+  });
+
+  it('lists existing tags from the catalog', async () => {
+    renderModal();
+    expect(await screen.findByText('toy')).toBeInTheDocument();
+    expect(screen.getByText('petg')).toBeInTheDocument();
+  });
+
+  it('checking a tag and clicking Add applies it via bulkAssignLibraryTags', async () => {
+    (api.bulkAssignLibraryTags as ReturnType<typeof vi.fn>).mockResolvedValue({
+      files_updated: 3,
+      associations_added: 3,
+      associations_removed: 0,
+    });
+    const user = userEvent.setup();
+    renderModal([10, 11, 12]);
+    await screen.findByText('toy');
+
+    const toyCheckbox = screen
+      .getAllByRole('checkbox')
+      .find((el) => el.parentElement?.textContent?.includes('toy'));
+    expect(toyCheckbox).toBeDefined();
+    await user.click(toyCheckbox!);
+
+    await user.click(screen.getByRole('button', { name: /Add tags/i }));
+    await waitFor(() => {
+      expect(api.bulkAssignLibraryTags).toHaveBeenCalledWith([10, 11, 12], [1], 'add');
+    });
+  });
+
+  it('switching to Remove changes the apply action', async () => {
+    (api.bulkAssignLibraryTags as ReturnType<typeof vi.fn>).mockResolvedValue({
+      files_updated: 3,
+      associations_added: 0,
+      associations_removed: 3,
+    });
+    const user = userEvent.setup();
+    renderModal([10, 11, 12]);
+    await screen.findByText('toy');
+
+    // Pick the Remove radio.
+    await user.click(screen.getByRole('radio', { name: /Remove from selected files/i }));
+
+    const petgCheckbox = screen
+      .getAllByRole('checkbox')
+      .find((el) => el.parentElement?.textContent?.includes('petg'));
+    await user.click(petgCheckbox!);
+
+    await user.click(screen.getByRole('button', { name: /Remove tags/i }));
+    await waitFor(() => {
+      expect(api.bulkAssignLibraryTags).toHaveBeenCalledWith([10, 11, 12], [2], 'remove');
+    });
+  });
+
+  it('apply is disabled when no tag is selected', async () => {
+    renderModal();
+    await screen.findByText('toy');
+    expect(screen.getByRole('button', { name: /Add tags/i })).toBeDisabled();
+  });
+});

+ 97 - 0
frontend/src/__tests__/components/LibraryTagsModal.test.tsx

@@ -0,0 +1,97 @@
+/**
+ * Tests for LibraryTagsModal (#1268).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { LibraryTagsModal } from '../../components/LibraryTagsModal';
+import { api } from '../../api/client';
+
+const mockShowToast = vi.fn();
+const mockOnClose = vi.fn();
+const mockOnPick = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getLibraryTags: vi.fn(),
+    createLibraryTag: vi.fn(),
+    updateLibraryTag: vi.fn(),
+    deleteLibraryTag: vi.fn(),
+  },
+}));
+
+vi.mock('../../contexts/ToastContext', () => ({
+  useToast: () => ({ showToast: mockShowToast }),
+}));
+
+const tags = [
+  { id: 1, name: 'toy', file_count: 4, created_at: '2026-01-01', updated_at: '2026-01-01' },
+  { id: 2, name: 'kid-safe', file_count: 0, created_at: '2026-01-01', updated_at: '2026-01-01' },
+];
+
+function renderModal() {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  return render(
+    <QueryClientProvider client={client}>
+      <MemoryRouter>
+        <LibraryTagsModal open onClose={mockOnClose} onPickTag={mockOnPick} />
+      </MemoryRouter>
+    </QueryClientProvider>,
+  );
+}
+
+describe('LibraryTagsModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    (api.getLibraryTags as ReturnType<typeof vi.fn>).mockResolvedValue(tags);
+  });
+
+  it('renders the catalog with file counts', async () => {
+    renderModal();
+    expect(await screen.findByText('toy')).toBeInTheDocument();
+    expect(screen.getByText('kid-safe')).toBeInTheDocument();
+    expect(screen.getByText('4')).toBeInTheDocument();
+  });
+
+  it('opens the editor and calls createLibraryTag on save', async () => {
+    (api.createLibraryTag as ReturnType<typeof vi.fn>).mockResolvedValue({
+      id: 3,
+      name: 'new',
+      file_count: 0,
+      created_at: '2026-01-01',
+      updated_at: '2026-01-01',
+    });
+    const user = userEvent.setup();
+    renderModal();
+    await screen.findByText('toy');
+    // Header has both "New tag" and "Manage tag catalog" — click the one
+    // that opens the editor (the button with Plus icon).
+    await user.click(screen.getByRole('button', { name: /New tag/i }));
+    const input = await screen.findByLabelText(/Name/i);
+    await user.type(input, 'new');
+    await user.click(screen.getByRole('button', { name: /Save/i }));
+    await waitFor(() => {
+      expect(api.createLibraryTag).toHaveBeenCalledWith('new');
+    });
+  });
+
+  it('clicking a row invokes onPickTag and closes the modal', async () => {
+    const user = userEvent.setup();
+    renderModal();
+    await user.click(await screen.findByText('toy'));
+    expect(mockOnPick).toHaveBeenCalledWith(1);
+    expect(mockOnClose).toHaveBeenCalled();
+  });
+
+  it('confirm dialog warns when deleting an in-use tag', async () => {
+    const user = userEvent.setup();
+    renderModal();
+    await screen.findByText('toy');
+    await user.click(screen.getByLabelText('Delete toy'));
+    // In-use message — substring match keeps the test robust to whitespace.
+    expect(await screen.findByText(/{{count}}|on 4|4 file/i)).toBeInTheDocument();
+  });
+});

+ 58 - 0
frontend/src/api/client.ts

@@ -5788,6 +5788,7 @@ export const api = {
     projectId?: number,
     scope?: 'internal' | 'external',
     recursive = false,
+    tagIds: number[] = [],
   ) => {
     const params = new URLSearchParams();
     if (folderId !== undefined && folderId !== null) {
@@ -5803,12 +5804,44 @@ export const api = {
     // folder (#1268). Only meaningful when folder_id is set; ignored server-side
     // otherwise. Off by default so non-search callers keep folder-scoped behavior.
     if (recursive) params.set('recursive', 'true');
+    // Tag filter (#1268). Repeated ?tag_ids=N&tag_ids=M form for AND semantics
+    // — backend joins the association table and HAVING COUNT(DISTINCT) matches
+    // the array length. Tag filter intentionally bypasses folder scoping
+    // server-side (cross-cutting design decision).
+    for (const tagId of tagIds) {
+      params.append('tag_ids', String(tagId));
+    }
     return request<LibraryFileListItem[]>(`/library/files?${params}`);
   },
   getLibraryFolderReadme: (folderId: number) =>
     request<{ filename: string; content: string; truncated: boolean }>(
       `/library/folders/${folderId}/readme`,
     ),
+
+  // ============ Library tag catalog (#1268) ============
+  getLibraryTags: () =>
+    request<LibraryTag[]>('/library/tags'),
+  createLibraryTag: (name: string) =>
+    request<LibraryTag>('/library/tags', {
+      method: 'POST',
+      body: JSON.stringify({ name }),
+    }),
+  updateLibraryTag: (id: number, name: string) =>
+    request<LibraryTag>(`/library/tags/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify({ name }),
+    }),
+  deleteLibraryTag: (id: number) =>
+    request<void>(`/library/tags/${id}`, { method: 'DELETE' }),
+  bulkAssignLibraryTags: (
+    fileIds: number[],
+    tagIds: number[],
+    action: 'add' | 'remove' | 'replace',
+  ) =>
+    request<LibraryTagBulkAssignResult>('/library/tags/bulk-assign', {
+      method: 'POST',
+      body: JSON.stringify({ file_ids: fileIds, tag_ids: tagIds, action }),
+    }),
   getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),
   uploadLibraryFile: async (
     file: File,
@@ -6411,6 +6444,11 @@ export interface LibraryFile {
   sliced_for_model: string | null;
 }
 
+export interface LibraryTagSummary {
+  id: number;
+  name: string;
+}
+
 export interface LibraryFileListItem {
   id: number;
   folder_id: number | null;
@@ -6429,6 +6467,26 @@ export interface LibraryFileListItem {
   print_time_seconds: number | null;
   filament_used_grams: number | null;
   sliced_for_model: string | null;
+  // Tags assigned to this file (#1268). The backend always emits an empty
+  // array when a file has no tags, but the field is typed optional so any
+  // legacy code path (or mock) that constructs a LibraryFileListItem without
+  // it doesn't crash the renderer. Read sites use `file.tags ?? []`.
+  tags?: LibraryTagSummary[];
+}
+
+// Library tag catalog (#1268)
+export interface LibraryTag {
+  id: number;
+  name: string;
+  file_count: number;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface LibraryTagBulkAssignResult {
+  files_updated: number;
+  associations_added: number;
+  associations_removed: number;
 }
 
 export interface LibraryFileUpdate {

+ 260 - 0
frontend/src/components/BulkTagsPickerModal.tsx

@@ -0,0 +1,260 @@
+import { useState, useEffect, useMemo } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Tag, Loader2, Plus, X } from 'lucide-react';
+
+import { api, type LibraryTag } from '../api/client';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+import { libraryTagsQueryKey } from '../utils/libraryTagsQuery';
+
+interface BulkTagsPickerModalProps {
+  open: boolean;
+  fileIds: number[];
+  onClose: () => void;
+}
+
+type Action = 'add' | 'remove';
+
+/**
+ * Multi-file tag application modal (#1268). Opens from the File Manager's
+ * multi-select toolbar. Checkbox-list of catalog tags + inline "create new" so
+ * the user doesn't have to leave the flow to add a tag they forgot to make.
+ *
+ * Replace mode is omitted from the UI — it's a destructive op that the user
+ * would rarely want for arbitrary multi-selections. The API still exposes it
+ * for callers that need it (e.g. a future bulk-edit screen).
+ */
+export function BulkTagsPickerModal({ open, fileIds, onClose }: BulkTagsPickerModalProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [action, setAction] = useState<Action>('add');
+  const [selected, setSelected] = useState<Set<number>>(new Set());
+  const [filter, setFilter] = useState('');
+  const [newTagName, setNewTagName] = useState('');
+
+  // Reset state on close so re-opening the modal doesn't keep stale selection.
+  useEffect(() => {
+    if (!open) {
+      setAction('add');
+      setSelected(new Set());
+      setFilter('');
+      setNewTagName('');
+    }
+  }, [open]);
+
+  const { data: tags = [], isLoading } = useQuery({
+    queryKey: libraryTagsQueryKey,
+    queryFn: api.getLibraryTags,
+    enabled: open,
+  });
+
+  const filteredTags = useMemo<LibraryTag[]>(() => {
+    const q = filter.trim().toLowerCase();
+    if (!q) return tags;
+    return tags.filter((t) => t.name.toLowerCase().includes(q));
+  }, [tags, filter]);
+
+  const toggleTag = (id: number) => {
+    setSelected((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) {
+        next.delete(id);
+      } else {
+        next.add(id);
+      }
+      return next;
+    });
+  };
+
+  const createTagMutation = useMutation({
+    mutationFn: (name: string) => api.createLibraryTag(name),
+    onSuccess: (tag) => {
+      setSelected((prev) => new Set(prev).add(tag.id));
+      setNewTagName('');
+      queryClient.invalidateQueries({ queryKey: libraryTagsQueryKey });
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('fileManager.tags.saveFailed'), 'error');
+    },
+  });
+
+  const applyMutation = useMutation({
+    mutationFn: () =>
+      api.bulkAssignLibraryTags(fileIds, Array.from(selected), action),
+    onSuccess: (result) => {
+      showToast(
+        action === 'add'
+          ? t('fileManager.tags.applyAddSuccess', {
+              count: result.associations_added,
+              files: result.files_updated,
+            })
+          : t('fileManager.tags.applyRemoveSuccess', {
+              count: result.associations_removed,
+              files: result.files_updated,
+            }),
+        'success',
+      );
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: libraryTagsQueryKey });
+      onClose();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('fileManager.tags.applyFailed'), 'error');
+    },
+  });
+
+  useEffect(() => {
+    if (!open) return;
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !applyMutation.isPending && !createTagMutation.isPending) {
+        onClose();
+      }
+    };
+    document.addEventListener('keydown', onKey);
+    return () => document.removeEventListener('keydown', onKey);
+  }, [open, onClose, applyMutation.isPending, createTagMutation.isPending]);
+
+  if (!open) return null;
+
+  const createDisabled =
+    !newTagName.trim() ||
+    createTagMutation.isPending ||
+    tags.some((tg) => tg.name.toLowerCase() === newTagName.trim().toLowerCase());
+
+  const titleId = 'bulk-tags-picker-title';
+
+  return (
+    <div className="fixed inset-0 z-[60] flex items-center justify-center">
+      <div className="absolute inset-0 bg-black/60" onClick={() => !applyMutation.isPending && onClose()} />
+      <div
+        className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby={titleId}
+      >
+        <div className="flex items-center justify-between gap-4 px-5 py-4 border-b border-bambu-dark-tertiary">
+          <h3 id={titleId} className="text-base font-semibold text-white flex items-center gap-2">
+            <Tag className="w-4 h-4 text-bambu-green" />
+            {t('fileManager.tags.bulkTitle', { count: fileIds.length })}
+          </h3>
+          <button
+            type="button"
+            className="p-1.5 text-bambu-gray hover:text-white rounded"
+            onClick={onClose}
+            aria-label={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <div className="px-5 py-3 border-b border-bambu-dark-tertiary flex gap-4 text-sm">
+          <label className="flex items-center gap-2 cursor-pointer">
+            <input
+              type="radio"
+              name="bulk-action"
+              checked={action === 'add'}
+              onChange={() => setAction('add')}
+              className="accent-bambu-green"
+            />
+            <span className="text-white">{t('fileManager.tags.actionAdd')}</span>
+          </label>
+          <label className="flex items-center gap-2 cursor-pointer">
+            <input
+              type="radio"
+              name="bulk-action"
+              checked={action === 'remove'}
+              onChange={() => setAction('remove')}
+              className="accent-bambu-green"
+            />
+            <span className="text-white">{t('fileManager.tags.actionRemove')}</span>
+          </label>
+        </div>
+
+        <div className="px-5 py-3 border-b border-bambu-dark-tertiary">
+          <input
+            type="text"
+            value={filter}
+            onChange={(e) => setFilter(e.target.value)}
+            placeholder={t('fileManager.tags.searchPlaceholder')}
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+          />
+        </div>
+
+        <div className="overflow-y-auto flex-1 min-h-[8rem] max-h-[24rem]">
+          {isLoading ? (
+            <div className="flex items-center justify-center py-12 text-bambu-gray">
+              <Loader2 className="w-5 h-5 animate-spin mr-2" />
+              {t('common.loading')}
+            </div>
+          ) : filteredTags.length === 0 ? (
+            <div className="py-12 text-center text-bambu-gray text-sm">
+              {tags.length === 0 ? t('fileManager.tags.empty') : t('fileManager.tags.noMatches')}
+            </div>
+          ) : (
+            <ul className="divide-y divide-bambu-dark-tertiary/40">
+              {filteredTags.map((tg) => (
+                <li key={tg.id}>
+                  <label className="flex items-center gap-3 px-5 py-2 hover:bg-bambu-dark-tertiary/30 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={selected.has(tg.id)}
+                      onChange={() => toggleTag(tg.id)}
+                      className="accent-bambu-green"
+                    />
+                    <span className="text-sm text-white truncate flex-1">{tg.name}</span>
+                    <span className="text-xs text-bambu-gray">{tg.file_count}</span>
+                  </label>
+                </li>
+              ))}
+            </ul>
+          )}
+        </div>
+
+        {action === 'add' && (
+          <div className="px-5 py-3 border-t border-bambu-dark-tertiary flex gap-2">
+            <input
+              type="text"
+              value={newTagName}
+              onChange={(e) => setNewTagName(e.target.value)}
+              placeholder={t('fileManager.tags.createPlaceholder')}
+              maxLength={64}
+              className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+              onKeyDown={(e) => {
+                if (e.key === 'Enter' && !createDisabled) {
+                  e.preventDefault();
+                  createTagMutation.mutate(newTagName.trim());
+                }
+              }}
+            />
+            <Button
+              type="button"
+              variant="secondary"
+              onClick={() => createTagMutation.mutate(newTagName.trim())}
+              disabled={createDisabled}
+            >
+              {createTagMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
+              {t('fileManager.tags.createButton')}
+            </Button>
+          </div>
+        )}
+
+        <div className="px-5 py-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
+          <Button type="button" variant="secondary" onClick={onClose} disabled={applyMutation.isPending}>
+            {t('common.cancel')}
+          </Button>
+          <Button
+            type="button"
+            onClick={() => applyMutation.mutate()}
+            disabled={selected.size === 0 || applyMutation.isPending || fileIds.length === 0}
+          >
+            {applyMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
+            {action === 'add' ? t('fileManager.tags.applyAdd') : t('fileManager.tags.applyRemove')}
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 283 - 0
frontend/src/components/LibraryTagsModal.tsx

@@ -0,0 +1,283 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Tag, Plus, Loader2, Pencil, Trash2, X } from 'lucide-react';
+
+import { api, type LibraryTag } from '../api/client';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+import { libraryTagsQueryKey } from '../utils/libraryTagsQuery';
+
+interface LibraryTagsModalProps {
+  open: boolean;
+  onClose: () => void;
+  /** Optional callback when the user clicks a row to pick a tag for filtering. */
+  onPickTag?: (tagId: number) => void;
+}
+
+/**
+ * Catalog CRUD for #1268 library tags. Same shape as LocationsModal but tags
+ * are deletable while in use — the backend's ON DELETE CASCADE drops the
+ * association rows, files keep their identity. The confirm dialog warns the
+ * user when file_count > 0 so accidental deletion of a heavily-used tag isn't
+ * silent.
+ */
+export function LibraryTagsModal({ open, onClose, onPickTag }: LibraryTagsModalProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [editorOpen, setEditorOpen] = useState(false);
+  const [editing, setEditing] = useState<LibraryTag | null>(null);
+  const [name, setName] = useState('');
+  const [deleteTarget, setDeleteTarget] = useState<LibraryTag | null>(null);
+
+  const { data: tags = [], isLoading } = useQuery({
+    queryKey: libraryTagsQueryKey,
+    queryFn: api.getLibraryTags,
+    enabled: open,
+  });
+
+  const invalidate = () => {
+    queryClient.invalidateQueries({ queryKey: libraryTagsQueryKey });
+    // File listings carry the tags array — bump those too so chips refresh
+    // immediately after a rename/delete.
+    queryClient.invalidateQueries({ queryKey: ['library-files'] });
+  };
+
+  const saveMutation = useMutation({
+    mutationFn: async () => {
+      const trimmed = name.trim();
+      if (!trimmed) throw new Error(t('fileManager.tags.nameRequired'));
+      if (editing) {
+        return api.updateLibraryTag(editing.id, trimmed);
+      }
+      return api.createLibraryTag(trimmed);
+    },
+    onSuccess: () => {
+      showToast(t(editing ? 'fileManager.tags.updated' : 'fileManager.tags.created'), 'success');
+      setEditorOpen(false);
+      setEditing(null);
+      setName('');
+      invalidate();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('fileManager.tags.saveFailed'), 'error');
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteLibraryTag(id),
+    onSuccess: () => {
+      showToast(t('fileManager.tags.deleted'), 'success');
+      setDeleteTarget(null);
+      invalidate();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('fileManager.tags.deleteFailed'), 'error');
+    },
+  });
+
+  const openCreate = () => {
+    setEditing(null);
+    setName('');
+    setEditorOpen(true);
+  };
+
+  const openEdit = (tag: LibraryTag) => {
+    setEditing(tag);
+    setName(tag.name);
+    setEditorOpen(true);
+  };
+
+  const closeEditor = useCallback(() => {
+    if (saveMutation.isPending) return;
+    setEditorOpen(false);
+    setEditing(null);
+    setName('');
+  }, [saveMutation.isPending]);
+
+  useEffect(() => {
+    if (!open) return;
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key !== 'Escape') return;
+      if (saveMutation.isPending || deleteMutation.isPending) return;
+      if (editorOpen) {
+        closeEditor();
+      } else if (!deleteTarget) {
+        onClose();
+      }
+    };
+    document.addEventListener('keydown', handleKeyDown);
+    return () => document.removeEventListener('keydown', handleKeyDown);
+  }, [open, editorOpen, deleteTarget, saveMutation.isPending, deleteMutation.isPending, closeEditor, onClose]);
+
+  const handleSave = (e: React.FormEvent) => {
+    e.preventDefault();
+    saveMutation.mutate();
+  };
+
+  if (!open) return null;
+
+  const modalTitleId = 'library-tags-modal-title';
+  const editorTitleId = 'library-tag-editor-title';
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center">
+      <div
+        className="absolute inset-0 bg-black/60"
+        onClick={() => {
+          if (saveMutation.isPending || deleteMutation.isPending) return;
+          onClose();
+        }}
+      />
+      <div
+        className="relative w-full max-w-4xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby={modalTitleId}
+      >
+        <div className="flex items-center justify-between gap-4 px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="min-w-0 flex-1">
+            <h2 id={modalTitleId} className="text-lg font-semibold text-white flex items-center gap-2">
+              <Tag className="w-5 h-5 text-bambu-green" />
+              {t('fileManager.tags.title')}
+            </h2>
+            <p className="text-bambu-gray text-sm mt-0.5">{t('fileManager.tags.subtitle')}</p>
+          </div>
+          <div className="flex items-center gap-2">
+            <Button onClick={openCreate}>
+              <Plus className="w-4 h-4" />
+              {t('fileManager.tags.add')}
+            </Button>
+            <button
+              type="button"
+              className="p-1.5 text-bambu-gray hover:text-white rounded"
+              onClick={onClose}
+              aria-label={t('common.close')}
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+        </div>
+
+        <div className="overflow-y-auto">
+          {isLoading ? (
+            <div className="flex items-center justify-center py-16 text-bambu-gray">
+              <Loader2 className="w-6 h-6 animate-spin mr-2" />
+              {t('common.loading')}
+            </div>
+          ) : tags.length === 0 ? (
+            <div className="py-16 text-center text-bambu-gray">{t('fileManager.tags.empty')}</div>
+          ) : (
+            <table className="w-full text-sm">
+              <thead>
+                <tr className="border-b border-bambu-dark-tertiary text-left text-bambu-gray">
+                  <th className="px-4 py-3 font-medium">{t('fileManager.tags.name')}</th>
+                  <th className="px-4 py-3 font-medium text-right">{t('fileManager.tags.fileCount')}</th>
+                  <th className="px-4 py-3 font-medium text-right w-32">{t('common.actions')}</th>
+                </tr>
+              </thead>
+              <tbody>
+                {tags.map((tag) => (
+                  <tr
+                    key={tag.id}
+                    className={`border-b border-bambu-dark-tertiary/60 hover:bg-bambu-dark-tertiary/30 ${onPickTag ? 'cursor-pointer' : ''}`}
+                    onClick={() => {
+                      if (onPickTag) {
+                        onPickTag(tag.id);
+                        onClose();
+                      }
+                    }}
+                  >
+                    <td className="px-4 py-3 text-white font-medium">{tag.name}</td>
+                    <td className="px-4 py-3 text-right text-bambu-gray">{tag.file_count}</td>
+                    <td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
+                      <div className="flex items-center justify-end gap-1">
+                        <button
+                          type="button"
+                          className="p-1.5 text-bambu-gray hover:text-bambu-green rounded"
+                          onClick={() => openEdit(tag)}
+                          title={t('common.edit')}
+                          aria-label={t('fileManager.tags.editAria', { name: tag.name })}
+                        >
+                          <Pencil className="w-4 h-4" />
+                        </button>
+                        <button
+                          type="button"
+                          className="p-1.5 text-bambu-gray hover:text-red-400 rounded"
+                          onClick={() => setDeleteTarget(tag)}
+                          title={t('common.delete')}
+                          aria-label={t('fileManager.tags.deleteAria', { name: tag.name })}
+                        >
+                          <Trash2 className="w-4 h-4" />
+                        </button>
+                      </div>
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+        </div>
+      </div>
+
+      {editorOpen && (
+        <div className="fixed inset-0 z-[60] flex items-center justify-center">
+          <div className="absolute inset-0 bg-black/60" onClick={closeEditor} />
+          <div
+            className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-6 shadow-2xl"
+            role="dialog"
+            aria-modal="true"
+            aria-labelledby={editorTitleId}
+          >
+            <h3 id={editorTitleId} className="text-lg font-semibold text-white mb-4">
+              {editing ? t('fileManager.tags.edit') : t('fileManager.tags.add')}
+            </h3>
+            <form onSubmit={handleSave}>
+              <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="library-tag-name">
+                {t('fileManager.tags.name')}
+              </label>
+              <input
+                id="library-tag-name"
+                type="text"
+                maxLength={64}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green mb-4"
+                placeholder={t('fileManager.tags.createPlaceholder')}
+                value={name}
+                onChange={(e) => setName(e.target.value)}
+                autoFocus
+              />
+              <div className="flex justify-end gap-2">
+                <Button type="button" variant="secondary" onClick={closeEditor}>
+                  {t('common.cancel')}
+                </Button>
+                <Button type="submit" disabled={saveMutation.isPending || !name.trim()}>
+                  {saveMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
+                  {t('common.save')}
+                </Button>
+              </div>
+            </form>
+          </div>
+        </div>
+      )}
+
+      {deleteTarget && (
+        <ConfirmModal
+          title={t('fileManager.tags.confirmDelete', { name: deleteTarget.name })}
+          message={
+            deleteTarget.file_count > 0
+              ? t('fileManager.tags.confirmDeleteInUseMessage', { count: deleteTarget.file_count })
+              : t('fileManager.tags.confirmDeleteMessage')
+          }
+          confirmText={t('common.delete')}
+          variant="danger"
+          isLoading={deleteMutation.isPending}
+          onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
+          onCancel={() => setDeleteTarget(null)}
+        />
+      )}
+    </div>
+  );
+}

+ 39 - 0
frontend/src/i18n/locales/de.ts

@@ -3344,6 +3344,45 @@ export default {
     readme: {
       truncated: 'Gekürzt',
     },
+    tags: {
+      title: 'Tags',
+      subtitle: 'Dateien mit Labels versehen — Spielzeug, kindersicher, nur PETG, was immer du willst.',
+      manage: 'Tags',
+      manageTitle: 'Tag-Katalog verwalten',
+      add: 'Neuer Tag',
+      edit: 'Tag umbenennen',
+      name: 'Name',
+      fileCount: 'Dateien',
+      empty: 'Noch keine Tags. Erstelle einen, um Dateien zu kennzeichnen.',
+      noMatches: 'Keine passenden Tags.',
+      createPlaceholder: 'z. B. Spielzeug, kindersicher, petg',
+      createButton: 'Erstellen',
+      nameRequired: 'Name ist erforderlich.',
+      searchPlaceholder: 'Tags filtern...',
+      created: 'Tag erstellt.',
+      updated: 'Tag umbenannt.',
+      deleted: 'Tag entfernt.',
+      saveFailed: 'Tag konnte nicht gespeichert werden.',
+      deleteFailed: 'Tag konnte nicht entfernt werden.',
+      applyFailed: 'Tags konnten nicht angewendet werden.',
+      applyAdd: 'Tags hinzufügen',
+      applyRemove: 'Tags entfernen',
+      applyAddSuccess: '{{count}} Tag(s) zu {{files}} Datei(en) hinzugefügt.',
+      applyRemoveSuccess: '{{count}} Tag(s) von {{files}} Datei(en) entfernt.',
+      actionAdd: 'Zu ausgewählten Dateien hinzufügen',
+      actionRemove: 'Von ausgewählten Dateien entfernen',
+      tagAction: 'Tag',
+      bulkTitle: '{{count}} ausgewählte Datei(en) taggen',
+      bulkTooltip: 'Tags für alle ausgewählten Dateien hinzufügen oder entfernen.',
+      noPermission: 'Du hast keine Berechtigung, Dateien zu taggen.',
+      filterLabel: 'Filtern nach:',
+      clearAll: 'Alle entfernen',
+      confirmDelete: 'Tag "{{name}}" löschen?',
+      confirmDeleteMessage: 'Entfernt den Tag aus dem Katalog. Dateien behalten ihre übrigen Tags.',
+      confirmDeleteInUseMessage: 'Dieser Tag ist auf {{count}} Datei(en). Beim Löschen verschwindet er von allen; die Dateien selbst bleiben unverändert.',
+      editAria: '{{name}} bearbeiten',
+      deleteAria: '{{name}} löschen',
+    },
     allTypes: 'Alle Typen',
     prints: 'Drucke',
     ascending: 'Aufsteigend',

+ 39 - 0
frontend/src/i18n/locales/en.ts

@@ -3359,6 +3359,45 @@ export default {
     readme: {
       truncated: 'Truncated',
     },
+    tags: {
+      title: 'Tags',
+      subtitle: 'Label files for cross-cutting filtering — toys, kid-safe, PETG-only, anything.',
+      manage: 'Tags',
+      manageTitle: 'Manage tag catalog',
+      add: 'New tag',
+      edit: 'Rename tag',
+      name: 'Name',
+      fileCount: 'Files',
+      empty: 'No tags yet. Create one to start labelling files.',
+      noMatches: 'No matching tags.',
+      createPlaceholder: 'e.g. toys, kid-safe, petg',
+      createButton: 'Create',
+      nameRequired: 'Name is required.',
+      searchPlaceholder: 'Filter tags...',
+      created: 'Tag created.',
+      updated: 'Tag renamed.',
+      deleted: 'Tag removed.',
+      saveFailed: 'Could not save tag.',
+      deleteFailed: 'Could not remove tag.',
+      applyFailed: 'Could not apply tags.',
+      applyAdd: 'Add tags',
+      applyRemove: 'Remove tags',
+      applyAddSuccess: 'Added {{count}} tag(s) across {{files}} file(s).',
+      applyRemoveSuccess: 'Removed {{count}} tag(s) across {{files}} file(s).',
+      actionAdd: 'Add to selected files',
+      actionRemove: 'Remove from selected files',
+      tagAction: 'Tag',
+      bulkTitle: 'Tag {{count}} selected file(s)',
+      bulkTooltip: 'Add or remove tags on every selected file.',
+      noPermission: 'You do not have permission to tag files.',
+      filterLabel: 'Filtering by:',
+      clearAll: 'Clear all',
+      confirmDelete: 'Delete tag "{{name}}"?',
+      confirmDeleteMessage: 'This removes the tag from the catalog. Files keep their other tags.',
+      confirmDeleteInUseMessage: 'This tag is on {{count}} file(s). Deleting removes the chip from all of them; files themselves are untouched.',
+      editAria: 'Edit {{name}}',
+      deleteAria: 'Delete {{name}}',
+    },
     allTypes: 'All types',
     prints: 'Prints',
     ascending: 'Ascending',

+ 39 - 0
frontend/src/i18n/locales/es.ts

@@ -3347,6 +3347,45 @@ export default {
     readme: {
       truncated: 'Truncado',
     },
+    tags: {
+      title: 'Etiquetas',
+      subtitle: 'Etiqueta archivos para filtros transversales — juguetes, apto para niños, solo PETG, lo que necesites.',
+      manage: 'Etiquetas',
+      manageTitle: 'Gestionar el catálogo de etiquetas',
+      add: 'Nueva etiqueta',
+      edit: 'Renombrar etiqueta',
+      name: 'Nombre',
+      fileCount: 'Archivos',
+      empty: 'Aún no hay etiquetas. Crea una para empezar a etiquetar archivos.',
+      noMatches: 'No hay etiquetas que coincidan.',
+      createPlaceholder: 'p. ej. juguetes, apto-niños, petg',
+      createButton: 'Crear',
+      nameRequired: 'El nombre es obligatorio.',
+      searchPlaceholder: 'Filtrar etiquetas...',
+      created: 'Etiqueta creada.',
+      updated: 'Etiqueta renombrada.',
+      deleted: 'Etiqueta eliminada.',
+      saveFailed: 'No se pudo guardar la etiqueta.',
+      deleteFailed: 'No se pudo eliminar la etiqueta.',
+      applyFailed: 'No se pudieron aplicar las etiquetas.',
+      applyAdd: 'Añadir etiquetas',
+      applyRemove: 'Quitar etiquetas',
+      applyAddSuccess: 'Se añadieron {{count}} etiqueta(s) en {{files}} archivo(s).',
+      applyRemoveSuccess: 'Se quitaron {{count}} etiqueta(s) en {{files}} archivo(s).',
+      actionAdd: 'Añadir a los archivos seleccionados',
+      actionRemove: 'Quitar de los archivos seleccionados',
+      tagAction: 'Etiquetar',
+      bulkTitle: 'Etiquetar {{count}} archivo(s) seleccionado(s)',
+      bulkTooltip: 'Añadir o quitar etiquetas en cada archivo seleccionado.',
+      noPermission: 'No tienes permiso para etiquetar archivos.',
+      filterLabel: 'Filtrando por:',
+      clearAll: 'Limpiar todo',
+      confirmDelete: '¿Eliminar la etiqueta "{{name}}"?',
+      confirmDeleteMessage: 'Esto elimina la etiqueta del catálogo. Los archivos conservan sus otras etiquetas.',
+      confirmDeleteInUseMessage: 'Esta etiqueta está en {{count}} archivo(s). Al eliminarla desaparece de todos; los archivos en sí no se tocan.',
+      editAria: 'Editar {{name}}',
+      deleteAria: 'Eliminar {{name}}',
+    },
     allTypes: 'Todos los tipos',
     prints: 'Impresiones',
     ascending: 'Ascendente',

+ 39 - 0
frontend/src/i18n/locales/fr.ts

@@ -3333,6 +3333,45 @@ export default {
     readme: {
       truncated: 'Tronqué',
     },
+    tags: {
+      title: 'Étiquettes',
+      subtitle: 'Étiquetez les fichiers pour des filtres transversaux — jouets, adapté aux enfants, PETG uniquement, etc.',
+      manage: 'Étiquettes',
+      manageTitle: 'Gérer le catalogue d\'étiquettes',
+      add: 'Nouvelle étiquette',
+      edit: 'Renommer l\'étiquette',
+      name: 'Nom',
+      fileCount: 'Fichiers',
+      empty: 'Aucune étiquette pour le moment. Créez-en une pour commencer.',
+      noMatches: 'Aucune étiquette correspondante.',
+      createPlaceholder: 'ex. jouets, kid-safe, petg',
+      createButton: 'Créer',
+      nameRequired: 'Le nom est requis.',
+      searchPlaceholder: 'Filtrer les étiquettes...',
+      created: 'Étiquette créée.',
+      updated: 'Étiquette renommée.',
+      deleted: 'Étiquette supprimée.',
+      saveFailed: 'Impossible d\'enregistrer l\'étiquette.',
+      deleteFailed: 'Impossible de supprimer l\'étiquette.',
+      applyFailed: 'Impossible d\'appliquer les étiquettes.',
+      applyAdd: 'Ajouter des étiquettes',
+      applyRemove: 'Retirer des étiquettes',
+      applyAddSuccess: '{{count}} étiquette(s) ajoutée(s) sur {{files}} fichier(s).',
+      applyRemoveSuccess: '{{count}} étiquette(s) retirée(s) sur {{files}} fichier(s).',
+      actionAdd: 'Ajouter aux fichiers sélectionnés',
+      actionRemove: 'Retirer des fichiers sélectionnés',
+      tagAction: 'Étiqueter',
+      bulkTitle: 'Étiqueter {{count}} fichier(s) sélectionné(s)',
+      bulkTooltip: 'Ajouter ou retirer des étiquettes sur chaque fichier sélectionné.',
+      noPermission: 'Vous n\'avez pas la permission d\'étiqueter les fichiers.',
+      filterLabel: 'Filtré par :',
+      clearAll: 'Tout effacer',
+      confirmDelete: 'Supprimer l\'étiquette « {{name}} » ?',
+      confirmDeleteMessage: 'Cela supprime l\'étiquette du catalogue. Les fichiers conservent leurs autres étiquettes.',
+      confirmDeleteInUseMessage: 'Cette étiquette est sur {{count}} fichier(s). La suppression la retire de tous ; les fichiers eux-mêmes ne sont pas touchés.',
+      editAria: 'Modifier {{name}}',
+      deleteAria: 'Supprimer {{name}}',
+    },
     allTypes: 'Tous types',
     prints: 'Impressions',
     ascending: 'Croissant',

+ 39 - 0
frontend/src/i18n/locales/it.ts

@@ -3332,6 +3332,45 @@ export default {
     readme: {
       truncated: 'Troncato',
     },
+    tags: {
+      title: 'Etichette',
+      subtitle: 'Etichetta i file per filtri trasversali — giocattoli, sicuri per bambini, solo PETG, qualunque cosa serva.',
+      manage: 'Etichette',
+      manageTitle: 'Gestisci il catalogo delle etichette',
+      add: 'Nuova etichetta',
+      edit: 'Rinomina etichetta',
+      name: 'Nome',
+      fileCount: 'File',
+      empty: 'Nessuna etichetta. Creane una per iniziare.',
+      noMatches: 'Nessuna etichetta corrispondente.',
+      createPlaceholder: 'es. giocattoli, sicuro-bimbi, petg',
+      createButton: 'Crea',
+      nameRequired: 'Il nome è obbligatorio.',
+      searchPlaceholder: 'Filtra etichette...',
+      created: 'Etichetta creata.',
+      updated: 'Etichetta rinominata.',
+      deleted: 'Etichetta rimossa.',
+      saveFailed: 'Impossibile salvare l\'etichetta.',
+      deleteFailed: 'Impossibile rimuovere l\'etichetta.',
+      applyFailed: 'Impossibile applicare le etichette.',
+      applyAdd: 'Aggiungi etichette',
+      applyRemove: 'Rimuovi etichette',
+      applyAddSuccess: 'Aggiunte {{count}} etichetta/e su {{files}} file.',
+      applyRemoveSuccess: 'Rimosse {{count}} etichetta/e da {{files}} file.',
+      actionAdd: 'Aggiungi ai file selezionati',
+      actionRemove: 'Rimuovi dai file selezionati',
+      tagAction: 'Etichetta',
+      bulkTitle: 'Etichetta {{count}} file selezionato/i',
+      bulkTooltip: 'Aggiungi o rimuovi etichette su ogni file selezionato.',
+      noPermission: 'Non hai il permesso per etichettare i file.',
+      filterLabel: 'Filtraggio per:',
+      clearAll: 'Pulisci tutto',
+      confirmDelete: 'Eliminare l\'etichetta "{{name}}"?',
+      confirmDeleteMessage: 'Rimuove l\'etichetta dal catalogo. I file mantengono le altre etichette.',
+      confirmDeleteInUseMessage: 'Questa etichetta è su {{count}} file. L\'eliminazione la rimuove da tutti; i file non vengono toccati.',
+      editAria: 'Modifica {{name}}',
+      deleteAria: 'Elimina {{name}}',
+    },
     allTypes: 'Tutti i tipi',
     prints: 'Stampe',
     ascending: 'Crescente',

+ 39 - 0
frontend/src/i18n/locales/ja.ts

@@ -3344,6 +3344,45 @@ export default {
     readme: {
       truncated: '切り詰め',
     },
+    tags: {
+      title: 'タグ',
+      subtitle: 'ファイルに横断的なフィルタ用のラベルを付けます — おもちゃ、子ども向け、PETGのみ、など。',
+      manage: 'タグ',
+      manageTitle: 'タグカタログを管理',
+      add: '新しいタグ',
+      edit: 'タグの名前を変更',
+      name: '名前',
+      fileCount: 'ファイル数',
+      empty: 'タグがまだありません。作成してファイルにラベル付けを始めましょう。',
+      noMatches: '一致するタグがありません。',
+      createPlaceholder: '例:おもちゃ、子ども向け、petg',
+      createButton: '作成',
+      nameRequired: '名前は必須です。',
+      searchPlaceholder: 'タグを絞り込み...',
+      created: 'タグを作成しました。',
+      updated: 'タグの名前を変更しました。',
+      deleted: 'タグを削除しました。',
+      saveFailed: 'タグを保存できませんでした。',
+      deleteFailed: 'タグを削除できませんでした。',
+      applyFailed: 'タグを適用できませんでした。',
+      applyAdd: 'タグを追加',
+      applyRemove: 'タグを削除',
+      applyAddSuccess: '{{files}}個のファイルに{{count}}個のタグを追加しました。',
+      applyRemoveSuccess: '{{files}}個のファイルから{{count}}個のタグを削除しました。',
+      actionAdd: '選択したファイルに追加',
+      actionRemove: '選択したファイルから削除',
+      tagAction: 'タグ付け',
+      bulkTitle: '選択した{{count}}個のファイルにタグ付け',
+      bulkTooltip: '選択したすべてのファイルでタグを追加・削除します。',
+      noPermission: 'ファイルにタグを付ける権限がありません。',
+      filterLabel: '絞り込み:',
+      clearAll: 'すべてクリア',
+      confirmDelete: 'タグ「{{name}}」を削除しますか?',
+      confirmDeleteMessage: 'カタログからタグを削除します。ファイル自体や他のタグはそのまま残ります。',
+      confirmDeleteInUseMessage: 'このタグは{{count}}個のファイルに付いています。削除するとすべてのファイルからチップが消えますが、ファイル自体はそのままです。',
+      editAria: '{{name}}を編集',
+      deleteAria: '{{name}}を削除',
+    },
     allTypes: 'すべての種類',
     prints: '印刷回数',
     ascending: '昇順',

+ 39 - 0
frontend/src/i18n/locales/ko.ts

@@ -3157,6 +3157,45 @@ export default {
     readme: {
       truncated: '잘림',
     },
+    tags: {
+      title: '태그',
+      subtitle: '파일에 가로지르는 필터용 라벨을 붙이세요 — 장난감, 어린이용, PETG 전용 등.',
+      manage: '태그',
+      manageTitle: '태그 카탈로그 관리',
+      add: '새 태그',
+      edit: '태그 이름 변경',
+      name: '이름',
+      fileCount: '파일',
+      empty: '아직 태그가 없습니다. 만들어서 파일에 라벨을 붙이세요.',
+      noMatches: '일치하는 태그가 없습니다.',
+      createPlaceholder: '예: 장난감, 어린이용, petg',
+      createButton: '만들기',
+      nameRequired: '이름은 필수입니다.',
+      searchPlaceholder: '태그 필터...',
+      created: '태그가 만들어졌습니다.',
+      updated: '태그 이름이 변경되었습니다.',
+      deleted: '태그가 제거되었습니다.',
+      saveFailed: '태그를 저장할 수 없습니다.',
+      deleteFailed: '태그를 제거할 수 없습니다.',
+      applyFailed: '태그를 적용할 수 없습니다.',
+      applyAdd: '태그 추가',
+      applyRemove: '태그 제거',
+      applyAddSuccess: '{{files}}개 파일에 태그 {{count}}개를 추가했습니다.',
+      applyRemoveSuccess: '{{files}}개 파일에서 태그 {{count}}개를 제거했습니다.',
+      actionAdd: '선택한 파일에 추가',
+      actionRemove: '선택한 파일에서 제거',
+      tagAction: '태그',
+      bulkTitle: '선택한 {{count}}개 파일에 태그 지정',
+      bulkTooltip: '선택한 모든 파일에 태그를 추가하거나 제거합니다.',
+      noPermission: '파일에 태그를 붙일 권한이 없습니다.',
+      filterLabel: '필터:',
+      clearAll: '모두 지우기',
+      confirmDelete: '태그 "{{name}}"을(를) 삭제하시겠습니까?',
+      confirmDeleteMessage: '카탈로그에서 태그를 제거합니다. 파일과 다른 태그는 그대로 유지됩니다.',
+      confirmDeleteInUseMessage: '이 태그는 {{count}}개 파일에 있습니다. 삭제하면 모든 파일에서 칩이 사라지지만 파일 자체는 변경되지 않습니다.',
+      editAria: '{{name}} 편집',
+      deleteAria: '{{name}} 삭제',
+    },
     allTypes: '모든 유형',
     prints: '인쇄물',
     ascending: '오름차순',

+ 39 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3332,6 +3332,45 @@ export default {
     readme: {
       truncated: 'Truncado',
     },
+    tags: {
+      title: 'Tags',
+      subtitle: 'Rotule arquivos para filtros transversais — brinquedos, seguro para crianças, somente PETG, o que precisar.',
+      manage: 'Tags',
+      manageTitle: 'Gerenciar catálogo de tags',
+      add: 'Nova tag',
+      edit: 'Renomear tag',
+      name: 'Nome',
+      fileCount: 'Arquivos',
+      empty: 'Ainda não há tags. Crie uma para começar a rotular arquivos.',
+      noMatches: 'Nenhuma tag correspondente.',
+      createPlaceholder: 'ex.: brinquedos, infantil, petg',
+      createButton: 'Criar',
+      nameRequired: 'O nome é obrigatório.',
+      searchPlaceholder: 'Filtrar tags...',
+      created: 'Tag criada.',
+      updated: 'Tag renomeada.',
+      deleted: 'Tag removida.',
+      saveFailed: 'Não foi possível salvar a tag.',
+      deleteFailed: 'Não foi possível remover a tag.',
+      applyFailed: 'Não foi possível aplicar as tags.',
+      applyAdd: 'Adicionar tags',
+      applyRemove: 'Remover tags',
+      applyAddSuccess: '{{count}} tag(s) adicionada(s) em {{files}} arquivo(s).',
+      applyRemoveSuccess: '{{count}} tag(s) removida(s) em {{files}} arquivo(s).',
+      actionAdd: 'Adicionar aos arquivos selecionados',
+      actionRemove: 'Remover dos arquivos selecionados',
+      tagAction: 'Marcar',
+      bulkTitle: 'Marcar {{count}} arquivo(s) selecionado(s)',
+      bulkTooltip: 'Adicionar ou remover tags em cada arquivo selecionado.',
+      noPermission: 'Você não tem permissão para marcar arquivos.',
+      filterLabel: 'Filtrando por:',
+      clearAll: 'Limpar tudo',
+      confirmDelete: 'Excluir a tag "{{name}}"?',
+      confirmDeleteMessage: 'Isto remove a tag do catálogo. Os arquivos mantêm as outras tags.',
+      confirmDeleteInUseMessage: 'Esta tag está em {{count}} arquivo(s). Excluí-la remove o chip de todos; os arquivos em si ficam intactos.',
+      editAria: 'Editar {{name}}',
+      deleteAria: 'Excluir {{name}}',
+    },
     allTypes: 'Todos os tipos',
     prints: 'Impressões',
     ascending: 'Crescente',

+ 39 - 0
frontend/src/i18n/locales/tr.ts

@@ -3339,6 +3339,45 @@ export default {
     readme: {
       truncated: 'Kısaltıldı',
     },
+    tags: {
+      title: 'Etiketler',
+      subtitle: 'Çapraz filtreleme için dosyaları etiketleyin — oyuncak, çocuklar için güvenli, sadece PETG, ne isterseniz.',
+      manage: 'Etiketler',
+      manageTitle: 'Etiket kataloğunu yönet',
+      add: 'Yeni etiket',
+      edit: 'Etiketi yeniden adlandır',
+      name: 'Ad',
+      fileCount: 'Dosya',
+      empty: 'Henüz etiket yok. Bir tane oluşturup dosyaları etiketlemeye başlayın.',
+      noMatches: 'Eşleşen etiket yok.',
+      createPlaceholder: 'örn. oyuncak, çocuk-güvenli, petg',
+      createButton: 'Oluştur',
+      nameRequired: 'Ad gerekli.',
+      searchPlaceholder: 'Etiketleri filtrele...',
+      created: 'Etiket oluşturuldu.',
+      updated: 'Etiket yeniden adlandırıldı.',
+      deleted: 'Etiket kaldırıldı.',
+      saveFailed: 'Etiket kaydedilemedi.',
+      deleteFailed: 'Etiket kaldırılamadı.',
+      applyFailed: 'Etiketler uygulanamadı.',
+      applyAdd: 'Etiket ekle',
+      applyRemove: 'Etiket kaldır',
+      applyAddSuccess: '{{files}} dosyaya {{count}} etiket eklendi.',
+      applyRemoveSuccess: '{{files}} dosyadan {{count}} etiket kaldırıldı.',
+      actionAdd: 'Seçili dosyalara ekle',
+      actionRemove: 'Seçili dosyalardan kaldır',
+      tagAction: 'Etiketle',
+      bulkTitle: 'Seçili {{count}} dosyayı etiketle',
+      bulkTooltip: 'Seçili her dosyaya etiket ekleyin veya kaldırın.',
+      noPermission: 'Dosyaları etiketleme izniniz yok.',
+      filterLabel: 'Filtre:',
+      clearAll: 'Tümünü temizle',
+      confirmDelete: '"{{name}}" etiketi silinsin mi?',
+      confirmDeleteMessage: 'Bu, etiketi katalogdan kaldırır. Dosyalar diğer etiketlerini korur.',
+      confirmDeleteInUseMessage: 'Bu etiket {{count}} dosyada bulunuyor. Silmek hepsinden çıkarır; dosyaların kendisine dokunulmaz.',
+      editAria: '{{name}} düzenle',
+      deleteAria: '{{name}} sil',
+    },
     allTypes: 'Tüm türler',
     prints: 'Baskılar',
     ascending: 'Artan',

+ 39 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3332,6 +3332,45 @@ export default {
     readme: {
       truncated: '已截断',
     },
+    tags: {
+      title: '标签',
+      subtitle: '为文件添加跨目录的标签 —— 玩具、儿童安全、仅 PETG 等等。',
+      manage: '标签',
+      manageTitle: '管理标签目录',
+      add: '新建标签',
+      edit: '重命名标签',
+      name: '名称',
+      fileCount: '文件数',
+      empty: '还没有标签。创建一个开始为文件加标签吧。',
+      noMatches: '没有匹配的标签。',
+      createPlaceholder: '例:玩具、儿童安全、petg',
+      createButton: '创建',
+      nameRequired: '名称是必填项。',
+      searchPlaceholder: '过滤标签…',
+      created: '已创建标签。',
+      updated: '已重命名标签。',
+      deleted: '已删除标签。',
+      saveFailed: '无法保存标签。',
+      deleteFailed: '无法删除标签。',
+      applyFailed: '无法应用标签。',
+      applyAdd: '添加标签',
+      applyRemove: '移除标签',
+      applyAddSuccess: '已为 {{files}} 个文件添加 {{count}} 个标签。',
+      applyRemoveSuccess: '已从 {{files}} 个文件移除 {{count}} 个标签。',
+      actionAdd: '添加到所选文件',
+      actionRemove: '从所选文件移除',
+      tagAction: '标签',
+      bulkTitle: '为所选的 {{count}} 个文件加标签',
+      bulkTooltip: '在每个所选文件上添加或移除标签。',
+      noPermission: '您没有为文件加标签的权限。',
+      filterLabel: '筛选条件:',
+      clearAll: '全部清除',
+      confirmDelete: '删除标签 "{{name}}"?',
+      confirmDeleteMessage: '此操作会从目录中删除该标签。文件保留其它标签。',
+      confirmDeleteInUseMessage: '此标签存在于 {{count}} 个文件上。删除后所有文件上的标签都会消失,但文件本身不会动。',
+      editAria: '编辑 {{name}}',
+      deleteAria: '删除 {{name}}',
+    },
     allTypes: '所有类型',
     prints: '打印',
     ascending: '升序',

+ 39 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3332,6 +3332,45 @@ export default {
     readme: {
       truncated: '已截斷',
     },
+    tags: {
+      title: '標籤',
+      subtitle: '為檔案加上跨資料夾的標籤 —— 玩具、兒童安全、僅 PETG 等等。',
+      manage: '標籤',
+      manageTitle: '管理標籤目錄',
+      add: '新增標籤',
+      edit: '重新命名標籤',
+      name: '名稱',
+      fileCount: '檔案數',
+      empty: '還沒有標籤。建立一個開始為檔案加標籤吧。',
+      noMatches: '沒有符合的標籤。',
+      createPlaceholder: '例:玩具、兒童安全、petg',
+      createButton: '建立',
+      nameRequired: '名稱為必填。',
+      searchPlaceholder: '過濾標籤…',
+      created: '已建立標籤。',
+      updated: '已重新命名標籤。',
+      deleted: '已刪除標籤。',
+      saveFailed: '無法儲存標籤。',
+      deleteFailed: '無法刪除標籤。',
+      applyFailed: '無法套用標籤。',
+      applyAdd: '新增標籤',
+      applyRemove: '移除標籤',
+      applyAddSuccess: '已為 {{files}} 個檔案新增 {{count}} 個標籤。',
+      applyRemoveSuccess: '已從 {{files}} 個檔案移除 {{count}} 個標籤。',
+      actionAdd: '加到所選檔案',
+      actionRemove: '從所選檔案移除',
+      tagAction: '標籤',
+      bulkTitle: '為所選的 {{count}} 個檔案加標籤',
+      bulkTooltip: '在每個所選檔案上新增或移除標籤。',
+      noPermission: '您沒有為檔案加標籤的權限。',
+      filterLabel: '篩選條件:',
+      clearAll: '全部清除',
+      confirmDelete: '刪除標籤 "{{name}}"?',
+      confirmDeleteMessage: '此動作會從目錄中刪除該標籤。檔案保留其他標籤。',
+      confirmDeleteInUseMessage: '此標籤存在於 {{count}} 個檔案上。刪除後所有檔案上的標籤都會消失,但檔案本身不會動。',
+      editAria: '編輯 {{name}}',
+      deleteAria: '刪除 {{name}}',
+    },
     allTypes: '所有類型',
     prints: '列印',
     ascending: '升序',

+ 166 - 4
frontend/src/pages/FileManagerPage.tsx

@@ -41,6 +41,7 @@ import {
   RefreshCw,
   Lock,
   FolderSymlink,
+  Tag as TagIcon,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type {
@@ -58,8 +59,10 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { SliceModal } from '../components/SliceModal';
+import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { FolderReadmePanel } from '../components/FolderReadmePanel';
+import { LibraryTagsModal } from '../components/LibraryTagsModal';
 import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
@@ -732,6 +735,7 @@ interface FileCardProps {
   onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
+  onTagClick?: (tagId: number) => void;
   thumbnailVersion?: number;
   hasPermission: (permission: Permission) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
@@ -739,7 +743,7 @@ interface FileCardProps {
   t: TFunction;
 }
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onSlice, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onSlice, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
   return (
@@ -806,6 +810,22 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
             {file.created_by_username}
           </div>
         )}
+        {(file.tags?.length ?? 0) > 0 && (
+          <div className="mt-2 flex flex-wrap gap-1" onClick={(e) => e.stopPropagation()}>
+            {file.tags!.map((tg) => (
+              <button
+                key={tg.id}
+                type="button"
+                onClick={() => onTagClick?.(tg.id)}
+                className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20 transition-colors max-w-full"
+                title={tg.name}
+              >
+                <TagIcon className="w-2.5 h-2.5 flex-shrink-0" />
+                <span className="truncate">{tg.name}</span>
+              </button>
+            ))}
+          </div>
+        )}
       </div>
 
       {/* Actions - always visible on mobile, hover on desktop */}
@@ -963,6 +983,12 @@ export function FileManagerPage() {
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
   const [showPurgeModal, setShowPurgeModal] = useState(false);
+  // Tag UI state (#1268). selectedTagIds is the AND-style filter applied to
+  // the listing; setting it bypasses folder scoping on the server so
+  // "every toy" works regardless of which folder is currently selected.
+  const [showTagsModal, setShowTagsModal] = useState(false);
+  const [showBulkTagsModal, setShowBulkTagsModal] = useState(false);
+  const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
@@ -1132,8 +1158,42 @@ export function FileManagerPage() {
   // is invisible from the parent. Only kicks in for folder-scoped views —
   // root and the internal/external pseudo-nodes already return the union.
   const searchExpandsSubfolders = selectedFolderId !== null && searchQuery.trim().length > 0;
+  // The tag filter overrides folder scoping server-side (#1268 design call),
+  // so the FE query key includes it as a peer of folder/topLevelView. Sorted
+  // so the cache hits regardless of the order tags were toggled.
+  const tagFilterKey = useMemo(() => [...selectedTagIds].sort((a, b) => a - b), [selectedTagIds]);
+  // Tag catalog — needed to resolve names for the active-filter chip bar.
+  // Cheap query, shared with LibraryTagsModal / BulkTagsPickerModal via the
+  // same queryKey so they all invalidate together on tag CRUD.
+  const { data: tagCatalog = [] } = useQuery({
+    queryKey: ['library-tags'],
+    queryFn: api.getLibraryTags,
+  });
+  const tagsById = useMemo(() => {
+    const map = new Map<number, string>();
+    for (const t of tagCatalog) map.set(t.id, t.name);
+    return map;
+  }, [tagCatalog]);
+  // Prune the active filter when a tag is removed from the catalog so the
+  // listing never stalls on a phantom id. Skipped while the catalog query is
+  // still settling (empty array on first paint) — otherwise the user's filter
+  // gets cleared the moment the page mounts.
+  useEffect(() => {
+    if (tagCatalog.length === 0) return;
+    setSelectedTagIds((prev) => {
+      const next = prev.filter((id) => tagsById.has(id));
+      return next.length === prev.length ? prev : next;
+    });
+  }, [tagCatalog.length, tagsById]);
+
+  const toggleTagFilter = useCallback((tagId: number) => {
+    setSelectedTagIds((prev) =>
+      prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId],
+    );
+  }, []);
+
   const { data: files, isLoading: filesLoading } = useQuery({
-    queryKey: ['library-files', selectedFolderId, topLevelView, searchExpandsSubfolders],
+    queryKey: ['library-files', selectedFolderId, topLevelView, searchExpandsSubfolders, tagFilterKey],
     // When a specific folder is selected we list its contents directly; when
     // no folder is selected the topLevelView pseudo-node decides whether the
     // server scopes the result to internal-managed-storage files or to the
@@ -1146,6 +1206,7 @@ export function FileManagerPage() {
         undefined,
         selectedFolderId === null ? topLevelView : undefined,
         searchExpandsSubfolders,
+        tagFilterKey,
       ),
   });
 
@@ -1595,6 +1656,14 @@ export function FileManagerPage() {
             <FolderPlus className="w-4 h-4 mr-2" />
             {t('fileManager.newFolder')}
           </Button>
+          <Button
+            variant="secondary"
+            onClick={() => setShowTagsModal(true)}
+            title={t('fileManager.tags.manageTitle')}
+          >
+            <TagIcon className="w-4 h-4 mr-2" />
+            {t('fileManager.tags.manage')}
+          </Button>
           {hasPermission('library:purge') && (
             <Button
               variant="secondary"
@@ -1870,6 +1939,47 @@ export function FileManagerPage() {
           {/* Markdown description panel (#1268) — auto-hides if the folder
               has no README/description.md so non-users pay no UI cost. */}
           {selectedFolderId !== null && <FolderReadmePanel folderId={selectedFolderId} />}
+          {/* Tag filter rail (#1268). Lists every catalog tag as a togglable
+              chip — active chips are filled green and show an X, inactive
+              chips are outlined and toggle ON when clicked. Clicking an active
+              chip removes it from the filter. Hidden entirely when the
+              catalog is empty so brand-new installs don't see a stray rail. */}
+          {tagCatalog.length > 0 && (
+            <div className="mb-3 flex flex-wrap items-center gap-2 p-2 sm:p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
+              <span className="text-xs text-bambu-gray font-medium shrink-0">
+                {t('fileManager.tags.filterLabel')}
+              </span>
+              {tagCatalog.map((tg) => {
+                const active = selectedTagIds.includes(tg.id);
+                return (
+                  <button
+                    key={tg.id}
+                    type="button"
+                    onClick={() => toggleTagFilter(tg.id)}
+                    className={
+                      active
+                        ? 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-bambu-green/20 text-bambu-green border border-bambu-green/40 hover:bg-bambu-green/30 transition-colors'
+                        : 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-bambu-dark text-bambu-gray border border-bambu-dark-tertiary hover:text-white hover:border-bambu-green/40 transition-colors'
+                    }
+                    title={tg.name}
+                  >
+                    <TagIcon className="w-3 h-3" />
+                    <span>{tg.name}</span>
+                    {active && <X className="w-3 h-3" />}
+                  </button>
+                );
+              })}
+              {selectedTagIds.length > 0 && (
+                <button
+                  type="button"
+                  onClick={() => setSelectedTagIds([])}
+                  className="ml-auto text-xs text-bambu-gray hover:text-white shrink-0"
+                >
+                  {t('fileManager.tags.clearAll')}
+                </button>
+              )}
+            </div>
+          )}
           {/* External folder info bar */}
           {selectedFolder?.is_external && (
             <div className="flex items-center gap-3 mb-4 p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg">
@@ -2083,6 +2193,16 @@ export function FileManagerPage() {
                       <MoveRight className="w-4 h-4 sm:mr-1" />
                       <span className="hidden sm:inline">{t('common.move')}</span>
                     </Button>
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      onClick={() => setShowBulkTagsModal(true)}
+                      disabled={!hasAnyPermission('library:update_own', 'library:update_all')}
+                      title={!hasAnyPermission('library:update_own', 'library:update_all') ? t('fileManager.tags.noPermission') : t('fileManager.tags.bulkTooltip')}
+                    >
+                      <TagIcon className="w-4 h-4 sm:mr-1" />
+                      <span className="hidden sm:inline">{t('fileManager.tags.tagAction')}</span>
+                    </Button>
                     <Button
                       variant="danger"
                       size="sm"
@@ -2195,6 +2315,7 @@ export function FileManagerPage() {
                     }}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
+                    onTagClick={toggleTagFilter}
                     thumbnailVersion={thumbnailVersions[file.id]}
                     hasPermission={hasPermission}
                     canModify={canModify}
@@ -2217,20 +2338,21 @@ export function FileManagerPage() {
                     grids that compute `min-content` independently — the header's empty
                     trailing div resolved to 0px, leaving body columns shifted left of
                     their headers. Fixed width keeps header and body in lockstep. */}
-                <div className={`hidden sm:grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_220px]' : 'grid-cols-[auto_1fr_100px_100px_100px_220px]'} gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium`}>
+                <div className={`hidden sm:grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_minmax(0,200px)_220px]' : 'grid-cols-[auto_1fr_100px_100px_100px_minmax(0,200px)_220px]'} gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium`}>
                   <div className="w-6" />
                   <div>{t('common.name')}</div>
                   {authEnabled && <div>{t('fileManager.uploadedBy', { defaultValue: 'Uploaded By' })}</div>}
                   <div>{t('common.type')}</div>
                   <div>{t('fileManager.size')}</div>
                   <div>{t('fileManager.prints')}</div>
+                  <div>{t('fileManager.tags.title')}</div>
                   <div />
                 </div>
                 {/* List rows */}
                 {filteredAndSortedFiles.map((file) => (
                   <div
                     key={file.id}
-                    className={`grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_220px]' : 'grid-cols-[auto_1fr_100px_100px_100px_220px]'} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
+                    className={`grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_minmax(0,200px)_220px]' : 'grid-cols-[auto_1fr_100px_100px_100px_minmax(0,200px)_220px]'} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                     }`}
                     onClick={() => handleFileSelect(file.id)}
@@ -2304,6 +2426,30 @@ export function FileManagerPage() {
                     <div className="text-sm text-bambu-gray">{formatFileSize(file.file_size)}</div>
                     {/* Prints */}
                     <div className="text-sm text-bambu-gray">{file.print_count > 0 ? `${file.print_count}x` : '-'}</div>
+                    {/* Tags (#1268) — clickable chips push into the active
+                        filter; minmax(0,200px) on the column lets the cell
+                        shrink/wrap on narrow viewports without pushing the
+                        Actions cell off-screen. */}
+                    <div className="min-w-0" onClick={(e) => e.stopPropagation()}>
+                      {!file.tags || file.tags.length === 0 ? (
+                        <span className="text-xs text-bambu-gray/50">-</span>
+                      ) : (
+                        <div className="flex flex-wrap gap-1">
+                          {file.tags.map((tg) => (
+                            <button
+                              key={tg.id}
+                              type="button"
+                              onClick={() => toggleTagFilter(tg.id)}
+                              className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20 transition-colors max-w-full"
+                              title={tg.name}
+                            >
+                              <TagIcon className="w-2.5 h-2.5 flex-shrink-0" />
+                              <span className="truncate">{tg.name}</span>
+                            </button>
+                          ))}
+                        </div>
+                      )}
+                    </div>
                     {/* Actions */}
                     <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
                       {isSlicedFilename(file.filename) && (
@@ -2480,6 +2626,22 @@ export function FileManagerPage() {
         <PurgeOldFilesModal onClose={() => setShowPurgeModal(false)} />
       )}
 
+      <LibraryTagsModal
+        open={showTagsModal}
+        onClose={() => setShowTagsModal(false)}
+        onPickTag={(tagId) => {
+          if (!selectedTagIds.includes(tagId)) {
+            setSelectedTagIds((prev) => [...prev, tagId]);
+          }
+        }}
+      />
+
+      <BulkTagsPickerModal
+        open={showBulkTagsModal}
+        fileIds={selectedFiles}
+        onClose={() => setShowBulkTagsModal(false)}
+      />
+
       {linkFolder && (
         <LinkFolderModal
           folder={linkFolder}

+ 9 - 0
frontend/src/utils/libraryTagsQuery.ts

@@ -0,0 +1,9 @@
+/**
+ * Shared React Query key for the library-tag catalog (#1268).
+ *
+ * Lives in its own module so the consumers — LibraryTagsModal,
+ * BulkTagsPickerModal, FileManagerPage — can invalidate together without
+ * importing component files from each other (which breaks Vite Fast Refresh
+ * when a single file exports both a constant and a component).
+ */
+export const libraryTagsQueryKey = ['library-tags'] as const;

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-CTEw-X7C.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 1
static/assets/index-ChoCDzOQ.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 1 - 0
static/assets/index-DSFMlFH_.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Ba3h_OWp.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-ChoCDzOQ.css">
+    <script type="module" crossorigin src="/assets/index-CTEw-X7C.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DSFMlFH_.css">
   </head>
   <body>
     <div id="root"></div>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor