From 4bf2c377154e3bb5b79e1b3b701cda8190031903 Mon Sep 17 00:00:00 2001 From: Brandon Brown Date: Mon, 27 Apr 2026 18:29:48 -0700 Subject: [PATCH 1/4] feat: add tag position column and reorder endpoint (#41) - Add nullable `position` int column to Tag model - New tags auto-assigned next position on creation - `GET /api/tags` now orders by position (nulls last), then name - `PATCH /api/tags/reorder` (auth-required) accepts ordered tag_ids - Alembic migration 598878310987 Co-Authored-By: Claude Sonnet 4.6 --- ...7_1829-598878310987_add_position_to_tag.py | 25 +++++++ app/api.py | 32 ++++++++- app/models.py | 1 + tests/conftest.py | 13 ++++ tests/test_api_tags.py | 65 ++++++++++++++++++- 5 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/2026_04_27_1829-598878310987_add_position_to_tag.py diff --git a/alembic/versions/2026_04_27_1829-598878310987_add_position_to_tag.py b/alembic/versions/2026_04_27_1829-598878310987_add_position_to_tag.py new file mode 100644 index 0000000..297f3a2 --- /dev/null +++ b/alembic/versions/2026_04_27_1829-598878310987_add_position_to_tag.py @@ -0,0 +1,25 @@ +"""add position to tag + +Revision ID: 598878310987 +Revises: 6998ce81619a +Create Date: 2026-04-27 18:29:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "598878310987" +down_revision: Union[str, None] = "6998ce81619a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("tag", sa.Column("position", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("tag", "position") diff --git a/app/api.py b/app/api.py index 501b908..fcfba93 100644 --- a/app/api.py +++ b/app/api.py @@ -105,6 +105,12 @@ def escape_like(s: str) -> str: return re.sub(r"([%_\\])", r"\\\1", s) +def _next_tag_position(session: Session) -> int: + """Return the next position value for a new tag (max + 1, or 0 if none).""" + result = session.exec(select(func.max(Tag.position))).first() + return (result or 0) + 1 + + def get_or_create_tags(session: Session, tag_creates: list[TagCreate]) -> list[Tag]: """Find existing tags by normalized name, or create new ones.""" tags = [] @@ -115,7 +121,7 @@ def get_or_create_tags(session: Session, tag_creates: list[TagCreate]) -> list[T if existing: tags.append(existing) else: - tag = Tag(name=tc.name) + tag = Tag(name=tc.name, position=_next_tag_position(session)) session.add(tag) try: session.flush() @@ -568,11 +574,13 @@ def delete_breadcrumb( @router.get("/tags", response_model=list[TagWithCount]) def list_tags(session: Session = Depends(get_session)): + from sqlalchemy import nulls_last + statement = ( select(Tag, func.count(ThemeTag.theme_id).label("theme_count")) .outerjoin(ThemeTag, Tag.id == ThemeTag.tag_id) .group_by(Tag.id) - .order_by(Tag.name) + .order_by(nulls_last(Tag.position), Tag.name) ) results = session.exec(statement).all() return [ @@ -594,6 +602,26 @@ def get_themes_by_tag( return tag.themes +class TagReorderRequest(SQLModel, table=False): + tag_ids: List[int] + + +@router.patch("/tags/reorder", status_code=200) +def reorder_tags( + body: TagReorderRequest, + session: Session = Depends(get_session), + _: None = Depends(require_admin), +): + """Assign server-side positions to tags based on the supplied ordered list.""" + for position, tag_id in enumerate(body.tag_ids): + tag = session.get(Tag, tag_id) + if tag is not None: + tag.position = position + session.add(tag) + session.commit() + return {"ok": True} + + # ---------- image uploads ---------- ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"} diff --git a/app/models.py b/app/models.py index 8d41519..746d1c4 100644 --- a/app/models.py +++ b/app/models.py @@ -177,6 +177,7 @@ class Tag(TagBase, table=True): __tablename__ = "tag" __table_args__ = (Index("uq_tag_name_lower_idx", text("lower(name)"), unique=True),) id: Optional[int] = Field(default=None, primary_key=True) + position: Optional[int] = Field(default=None, nullable=True) themes: List["Theme"] = Relationship( # type: ignore back_populates="tags", link_model=ThemeTag, diff --git a/tests/conftest.py b/tests/conftest.py index aee4c49..405c341 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,3 +42,16 @@ def get_session_override(): client = TestClient(app) yield client app.dependency_overrides.clear() + + +@pytest.fixture(name="client_no_auth") +def client_no_auth_fixture(session: Session): + """TestClient without auth override — tests real auth enforcement.""" + + def get_session_override(): + return session + + app.dependency_overrides[get_session] = get_session_override + client = TestClient(app, raise_server_exceptions=False) + yield client + app.dependency_overrides.clear() diff --git a/tests/test_api_tags.py b/tests/test_api_tags.py index d8debe6..78d5337 100644 --- a/tests/test_api_tags.py +++ b/tests/test_api_tags.py @@ -35,7 +35,8 @@ def test_list_tags_with_counts(client): assert data[1]["theme_count"] == 1 -def test_list_tags_alphabetical_order(client): +def test_list_tags_creation_order(client): + """Tags with no custom position are returned in creation order (position ascending).""" client.post( "/api/themes", json={"body_md": "T", "tags": [{"name": "zebra"}, {"name": "alpha"}]}, @@ -43,8 +44,9 @@ def test_list_tags_alphabetical_order(client): response = client.get("/api/tags") data = response.json() - assert data[0]["name"] == "alpha" - assert data[1]["name"] == "zebra" + # zebra was created first (position 1), alpha second (position 2) + assert data[0]["name"] == "zebra" + assert data[1]["name"] == "alpha" # ---------- GET /tags/{name}/themes ---------- @@ -70,3 +72,60 @@ def test_get_themes_by_tag(client): def test_get_themes_by_tag_not_found(client): response = client.get("/api/tags/nonexistent/themes") assert response.status_code == 404 + + +# ---------- PATCH /tags/reorder ---------- + + +def test_reorder_tags_updates_positions(client): + """Tags are returned in server-side position order after reorder.""" + client.post("/api/themes", json={"body_md": "T1", "tags": [{"name": "alpha"}]}) + client.post("/api/themes", json={"body_md": "T2", "tags": [{"name": "beta"}]}) + client.post("/api/themes", json={"body_md": "T3", "tags": [{"name": "gamma"}]}) + + tags_before = client.get("/api/tags").json() + ids_by_name = {t["name"]: t["id"] for t in tags_before} + + # Reorder: gamma, alpha, beta + new_order = [ids_by_name["gamma"], ids_by_name["alpha"], ids_by_name["beta"]] + response = client.patch("/api/tags/reorder", json={"tag_ids": new_order}) + assert response.status_code == 200 + + tags_after = client.get("/api/tags").json() + assert [t["name"] for t in tags_after] == ["gamma", "alpha", "beta"] + + +def test_reorder_tags_requires_auth(client_no_auth): + """Reorder endpoint rejects unauthenticated requests.""" + response = client_no_auth.patch("/api/tags/reorder", json={"tag_ids": [1, 2]}) + assert response.status_code == 401 + + +def test_reorder_tags_ignores_unknown_ids(client): + """Reorder with unknown IDs doesn't crash — unknown IDs are silently skipped.""" + client.post("/api/themes", json={"body_md": "T1", "tags": [{"name": "alpha"}]}) + tags = client.get("/api/tags").json() + real_id = tags[0]["id"] + + response = client.patch("/api/tags/reorder", json={"tag_ids": [real_id, 99999]}) + assert response.status_code == 200 + + +def test_new_tags_get_highest_position(client): + """Newly created tags are appended after existing positioned tags.""" + client.post("/api/themes", json={"body_md": "T1", "tags": [{"name": "first"}]}) + client.post("/api/themes", json={"body_md": "T2", "tags": [{"name": "second"}]}) + + tags_before = client.get("/api/tags").json() + ids = [t["id"] for t in tags_before] + + # Fix order: first, second + client.patch("/api/tags/reorder", json={"tag_ids": ids}) + + # Add a new tag + client.post("/api/themes", json={"body_md": "T3", "tags": [{"name": "newcomer"}]}) + + tags_after = client.get("/api/tags").json() + names = [t["name"] for t in tags_after] + # newcomer should appear last + assert names[-1] == "newcomer" From 6e2f262d595f0b22423845caf820d9be69ced0d1 Mon Sep 17 00:00:00 2001 From: Brandon Brown Date: Mon, 27 Apr 2026 18:32:29 -0700 Subject: [PATCH 2/4] feat: drag-to-reorder tags in sidebar and mobile pill strip (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities - Sidebar (vertical) and mobile pill strip (horizontal) both support drag reorder via dnd-kit SortableContext - Drag handles show on hover in sidebar; visible in pill strip - Drag handles only appear when authenticated (reorder is auth-required) - On drop: optimistic local reorder + async PATCH /api/tags/reorder - New "custom" sort mode (server position order); cycles usage → alpha → custom - Non-authenticated readers see custom order without drag handles Co-Authored-By: Claude Sonnet 4.6 --- frontend/package-lock.json | 56 ++++ frontend/package.json | 3 + frontend/src/components/tag-bar.tsx | 394 +++++++++++++++++++--------- frontend/src/lib/api.ts | 8 + 4 files changed, 344 insertions(+), 117 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d0d9caa..23ba8e3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-query": "^5.90.20", "@tanstack/react-router": "^1.159.5", "class-variance-authority": "^0.7.1", @@ -529,6 +532,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2303eda..f3bb730 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-query": "^5.90.20", "@tanstack/react-router": "^1.159.5", "class-variance-authority": "^0.7.1", diff --git a/frontend/src/components/tag-bar.tsx b/frontend/src/components/tag-bar.tsx index fb38f48..69704d4 100644 --- a/frontend/src/components/tag-bar.tsx +++ b/frontend/src/components/tag-bar.tsx @@ -1,7 +1,27 @@ import { useMemo, useState } from "react" -import { useQuery } from "@tanstack/react-query" +import { useQuery, useQueryClient } from "@tanstack/react-query" import { Link } from "@tanstack/react-router" -import { fetchTags } from "@/lib/api" +import { + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core" +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, + horizontalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { GripVertical } from "lucide-react" +import { fetchTags, reorderTags } from "@/lib/api" +import { isAuthenticated } from "@/lib/auth" import type { StreamSearch, TagWithCount } from "@/lib/types" import { cn } from "@/lib/utils" import { Input } from "@/components/ui/input" @@ -11,14 +31,15 @@ const VISIBLE_COUNT = 15 const MOBILE_VISIBLE_COUNT = 12 const SORT_STORAGE_KEY = "breadcrumbs-tag-sort" -type TagSortOrder = "usage" | "alpha" +type TagSortOrder = "usage" | "alpha" | "custom" function getStoredSort(): TagSortOrder { try { const v = localStorage.getItem(SORT_STORAGE_KEY) - return v === "alpha" ? "alpha" : "usage" + if (v === "alpha" || v === "usage" || v === "custom") return v + return "custom" } catch { - return "usage" + return "custom" } } @@ -61,31 +82,78 @@ function getUsageTier(count: number, maxCount: number): number { export function TagBar({ activeTag, horizontal = false }: TagBarProps) { const [sortOrder, setSortOrder] = useState(getStoredSort) + const queryClient = useQueryClient() const { data: tags, error } = useQuery({ queryKey: ["tags"], queryFn: fetchTags, }) - const sortedTags = useMemo( - () => - tags?.slice().sort( - sortOrder === "usage" - ? (a, b) => b.theme_count - a.theme_count || a.name.localeCompare(b.name) - : (a, b) => a.name.localeCompare(b.name), - ), - [tags, sortOrder], - ) + // Local ordered state for optimistic drag reorder + const [localOrder, setLocalOrder] = useState(null) + + const sortedTags = useMemo(() => { + if (!tags) return undefined + const base = tags.slice() + + if (sortOrder === "custom") { + if (localOrder) { + const idToTag = new Map(tags.map((t) => [t.id, t])) + const ordered = localOrder.flatMap((id) => { + const t = idToTag.get(id) + return t ? [t] : [] + }) + // append any tags not in localOrder (newly created) + const inOrder = new Set(localOrder) + const rest = base.filter((t) => !inOrder.has(t.id)) + return [...ordered, ...rest] + } + return base // server already returned in position order + } + + return base.sort( + sortOrder === "usage" + ? (a, b) => b.theme_count - a.theme_count || a.name.localeCompare(b.name) + : (a, b) => a.name.localeCompare(b.name), + ) + }, [tags, sortOrder, localOrder]) const maxCount = useMemo( () => sortedTags?.reduce((max, t) => Math.max(max, t.theme_count), 0) ?? 0, [sortedTags], ) - function toggleSort() { - const next = sortOrder === "usage" ? "alpha" : "usage" + const canDrag = isAuthenticated() && sortOrder === "custom" + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ) + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + if (!over || active.id === over.id || !sortedTags) return + + const oldIndex = sortedTags.findIndex((t) => t.id === active.id) + const newIndex = sortedTags.findIndex((t) => t.id === over.id) + const reordered = arrayMove(sortedTags, oldIndex, newIndex) + const newIds = reordered.map((t) => t.id) + + setLocalOrder(newIds) + reorderTags(newIds).then(() => { + queryClient.invalidateQueries({ queryKey: ["tags"] }) + }) + } + + function cycleSortOrder() { + const next: TagSortOrder = + sortOrder === "custom" ? "usage" : sortOrder === "usage" ? "alpha" : "custom" setSortOrder(next) storeSort(next) + // clear local order when leaving custom mode + if (next !== "custom") setLocalOrder(null) } if (error) { @@ -105,56 +173,96 @@ export function TagBar({ activeTag, horizontal = false }: TagBarProps) { if (horizontal) { return ( - + + + ) } return ( - + + + ) } function TagPill({ tag, isActive, + canDrag, }: { tag: TagWithCount isActive: boolean + canDrag: boolean }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: tag.id, disabled: !canDrag }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : undefined, + } + return ( - ({ q: prev.q, tag: tag.name })} - aria-label={`${tag.name}, ${tag.theme_count} ${tag.theme_count === 1 ? "theme" : "themes"}`} - aria-current={isActive ? "page" : undefined} - className={cn( - "shrink-0 rounded-full px-3 py-1.5 no-underline transition-colors whitespace-nowrap", - isActive - ? "bg-foreground text-background font-medium" - : "bg-secondary text-muted-foreground hover:text-foreground", +
+ {canDrag && ( + )} - > - {tag.name} - {tag.theme_count} - + ({ q: prev.q, tag: tag.name })} + aria-label={`${tag.name}, ${tag.theme_count} ${tag.theme_count === 1 ? "theme" : "themes"}`} + aria-current={isActive ? "page" : undefined} + className={cn( + "shrink-0 rounded-full px-3 py-1.5 no-underline transition-colors whitespace-nowrap", + isActive + ? "bg-foreground text-background font-medium" + : "bg-secondary text-muted-foreground hover:text-foreground", + )} + > + {tag.name} + {tag.theme_count} + +
) } function HorizontalTagBar({ sortedTags, activeTag, + canDrag, }: { sortedTags: TagWithCount[] activeTag?: string + canDrag: boolean }) { const [sheetOpen, setSheetOpen] = useState(false) @@ -178,33 +286,43 @@ function HorizontalTagBar({ return ( <> - + All + + {mobileTags.map((tag) => ( + + ))} + {hiddenCount > 0 && ( + + )} + + {needsTruncation && ( ({ q: prev.q, tag: tag.name })} - aria-label={`${tag.name}, ${tag.theme_count} ${tag.theme_count === 1 ? "theme" : "themes"}`} - aria-current={isActive ? "page" : undefined} - className={cn( - "flex items-center justify-between py-0.5 no-underline hover:text-foreground transition-colors", - isActive ? "text-foreground font-medium" : USAGE_TIER_CLASSES[tier], - )} +
- {tag.name} - - {tag.theme_count} - - + {canDrag && ( + + )} + ({ q: prev.q, tag: tag.name })} + aria-label={`${tag.name}, ${tag.theme_count} ${tag.theme_count === 1 ? "theme" : "themes"}`} + aria-current={isActive ? "page" : undefined} + className={cn( + "flex items-center justify-between py-0.5 no-underline hover:text-foreground transition-colors flex-1 min-w-0", + isActive ? "text-foreground font-medium" : USAGE_TIER_CLASSES[tier], + )} + > + {tag.name} + + {tag.theme_count} + + +
) } @@ -265,19 +411,26 @@ function ToggleButton({ function SortToggle({ sortOrder, - onToggle, + onCycle, }: { sortOrder: TagSortOrder - onToggle: () => void + onCycle: () => void }) { + const label = sortOrder === "custom" ? "A→Z" : sortOrder === "usage" ? "A→Z" : "# usage" + const title = + sortOrder === "custom" + ? "Switch to alphabetical" + : sortOrder === "usage" + ? "Switch to alphabetical" + : "Switch to custom order" return ( ) } @@ -287,13 +440,15 @@ function VerticalTagList({ maxCount, activeTag, sortOrder, - onToggleSort, + onCycleSort, + canDrag, }: { sortedTags: TagWithCount[] maxCount: number activeTag?: string sortOrder: TagSortOrder - onToggleSort: () => void + onCycleSort: () => void + canDrag: boolean }) { const [expanded, setExpanded] = useState(false) const [filter, setFilter] = useState("") @@ -305,7 +460,6 @@ function VerticalTagList({ sortedTags.findIndex((t) => t.name === activeTag) >= VISIBLE_COUNT const showAll = expanded || activeInOverflow - // When filter is active, show all matching tags flat (no overflow split) const filteredTags = filter ? sortedTags.filter((t) => t.name.startsWith(filter.toLowerCase())) : null @@ -354,41 +508,47 @@ function VerticalTagList({ > All - + {expanded && hasOverflow && !filteredTags && ( setExpanded(false)}> − fewer tags )} -
- {visibleTags.map((tag) => ( - - ))} - - {hiddenCount > 0 && ( - setExpanded(true)}> - + {hiddenCount} more tags - - )} + t.id)} + strategy={verticalListSortingStrategy} + > +
+ {visibleTags.map((tag) => ( + + ))} + + {hiddenCount > 0 && ( + setExpanded(true)}> + + {hiddenCount} more tags + + )} - {expanded && hasOverflow && !filteredTags && ( - setExpanded(false)}> - − fewer tags - - )} + {expanded && hasOverflow && !filteredTags && ( + setExpanded(false)}> + − fewer tags + + )} - {filteredTags && filteredTags.length === 0 && ( -

- No matching tags. -

- )} -
+ {filteredTags && filteredTags.length === 0 && ( +

+ No matching tags. +

+ )} +
+ ) } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4e661e2..8527fa4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -328,6 +328,14 @@ export function suggestTags(themeId: number): Promise<{ tags: string[] }> { }) } +export function reorderTags(tagIds: number[]): Promise<{ ok: boolean }> { + return apiMutate("/api/tags/reorder", { + method: "PATCH", + body: { tag_ids: tagIds }, + label: "reorder tags", + }) +} + export function subscribe(email: string): Promise<{ message: string }> { return apiMutate("/api/subscribers/subscribe", { method: "POST", From 44ef1826456005fb77feaa77006ee885ce1921ff Mon Sep 17 00:00:00 2001 From: Brandon Brown Date: Mon, 27 Apr 2026 18:36:01 -0700 Subject: [PATCH 3/4] fix: address review findings (#41) - Fix N+1: compute next tag position once before loop, not per tag - Migration: backfill positions for existing tags (alphabetical order) so no tag starts as NULL after deploy - DnD: clear localOrder after successful server sync; rollback on error - SortToggle: fix label/title mapping for all three sort modes - Move nulls_last import to module level Co-Authored-By: Claude Sonnet 4.6 --- ...7_1829-598878310987_add_position_to_tag.py | 9 +++++ app/api.py | 16 ++++----- frontend/src/components/tag-bar.tsx | 35 ++++++++++++------- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/alembic/versions/2026_04_27_1829-598878310987_add_position_to_tag.py b/alembic/versions/2026_04_27_1829-598878310987_add_position_to_tag.py index 297f3a2..0ccfc95 100644 --- a/alembic/versions/2026_04_27_1829-598878310987_add_position_to_tag.py +++ b/alembic/versions/2026_04_27_1829-598878310987_add_position_to_tag.py @@ -19,6 +19,15 @@ def upgrade() -> None: op.add_column("tag", sa.Column("position", sa.Integer(), nullable=True)) + # Backfill existing tags in alphabetical order so no tag starts as NULL. + # Uses a subquery-based row_number pattern compatible with both SQLite and PostgreSQL. + conn = op.get_bind() + tags = conn.execute(sa.text("SELECT id FROM tag ORDER BY name")).fetchall() + for i, row in enumerate(tags): + conn.execute( + sa.text("UPDATE tag SET position = :pos WHERE id = :id"), + {"pos": i, "id": row[0]}, + ) def downgrade() -> None: diff --git a/app/api.py b/app/api.py index fcfba93..090c373 100644 --- a/app/api.py +++ b/app/api.py @@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles +from sqlalchemy import nulls_last from sqlalchemy.exc import DataError, IntegrityError, OperationalError from starlette.requests import Request from sqlmodel import Session, SQLModel, col, func, or_, select @@ -105,14 +106,12 @@ def escape_like(s: str) -> str: return re.sub(r"([%_\\])", r"\\\1", s) -def _next_tag_position(session: Session) -> int: - """Return the next position value for a new tag (max + 1, or 0 if none).""" - result = session.exec(select(func.max(Tag.position))).first() - return (result or 0) + 1 - - def get_or_create_tags(session: Session, tag_creates: list[TagCreate]) -> list[Tag]: """Find existing tags by normalized name, or create new ones.""" + # Compute next position once to avoid N+1 and concurrent duplicate positions. + max_pos = session.exec(select(func.max(Tag.position))).first() or 0 + next_pos = max_pos + 1 + tags = [] for tc in tag_creates: existing = session.exec( @@ -121,7 +120,8 @@ def get_or_create_tags(session: Session, tag_creates: list[TagCreate]) -> list[T if existing: tags.append(existing) else: - tag = Tag(name=tc.name, position=_next_tag_position(session)) + tag = Tag(name=tc.name, position=next_pos) + next_pos += 1 session.add(tag) try: session.flush() @@ -574,8 +574,6 @@ def delete_breadcrumb( @router.get("/tags", response_model=list[TagWithCount]) def list_tags(session: Session = Depends(get_session)): - from sqlalchemy import nulls_last - statement = ( select(Tag, func.count(ThemeTag.theme_id).label("theme_count")) .outerjoin(ThemeTag, Tag.id == ThemeTag.tag_id) diff --git a/frontend/src/components/tag-bar.tsx b/frontend/src/components/tag-bar.tsx index 69704d4..2612eae 100644 --- a/frontend/src/components/tag-bar.tsx +++ b/frontend/src/components/tag-bar.tsx @@ -140,11 +140,17 @@ export function TagBar({ activeTag, horizontal = false }: TagBarProps) { const newIndex = sortedTags.findIndex((t) => t.id === over.id) const reordered = arrayMove(sortedTags, oldIndex, newIndex) const newIds = reordered.map((t) => t.id) + const prevOrder = localOrder setLocalOrder(newIds) - reorderTags(newIds).then(() => { - queryClient.invalidateQueries({ queryKey: ["tags"] }) - }) + reorderTags(newIds) + .then(() => { + setLocalOrder(null) // let server order take over after sync + queryClient.invalidateQueries({ queryKey: ["tags"] }) + }) + .catch(() => { + setLocalOrder(prevOrder) // roll back optimistic update on failure + }) } function cycleSortOrder() { @@ -409,6 +415,18 @@ function ToggleButton({ ) } +const NEXT_SORT_LABEL: Record = { + custom: "# usage", // custom → usage + usage: "A→Z", // usage → alpha + alpha: "↕ custom", // alpha → custom +} + +const NEXT_SORT_TITLE: Record = { + custom: "Switch to by usage", + usage: "Switch to alphabetical", + alpha: "Switch to custom order", +} + function SortToggle({ sortOrder, onCycle, @@ -416,21 +434,14 @@ function SortToggle({ sortOrder: TagSortOrder onCycle: () => void }) { - const label = sortOrder === "custom" ? "A→Z" : sortOrder === "usage" ? "A→Z" : "# usage" - const title = - sortOrder === "custom" - ? "Switch to alphabetical" - : sortOrder === "usage" - ? "Switch to alphabetical" - : "Switch to custom order" return ( ) } From df16b7d7a4011469da197d7e8e4098be7f1ea0d3 Mon Sep 17 00:00:00 2001 From: Brandon Brown Date: Mon, 27 Apr 2026 18:39:02 -0700 Subject: [PATCH 4/4] docs: update roadmap, CLAUDE.md, and llms.txt for tag reordering (#41) Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 3 ++- docs/roadmap.md | 12 +----------- llms.txt | 5 +++-- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 76b488e..86a1d73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,10 +104,11 @@ See `docs/log/README.md` for format and dimensions. - **Tag-based organization:** Tags applied at theme level for filtering and discovery - **Tag usage gradient:** Tags sorted by usage with 5-tier opacity gradient showing relative popularity - **Tag chip input:** Typeahead suggestions, comma/Enter to commit, visual chips distinguishing existing vs new tags +- **Tag drag-to-reorder:** Authenticated writers drag tags in sidebar (desktop) or mobile pill strip to set custom server-side order; `position` int column on Tag; `PATCH /api/tags/reorder` (auth-required); sort cycles custom → usage → alpha; order persists across devices - **Draft/publish workflow:** Writers can draft themes before publishing to readers - **Authenticated editing:** Writers login to see unpublished themes and edit existing ones - **Easy to read:** Continuous stream presentation with clear theme boundaries (not traditional blog articles) -- **Tag browsing:** Readers browse tags sorted by usage and filter themes by tag +- **Tag browsing:** Readers browse tags in custom (server-side) order with usage/alpha toggle; filter themes by tag - **Search:** Full-text search across theme bodies, breadcrumb content, and tags - **Timestamps:** Every breadcrumb has a timestamp - **Markdown rendering:** Full markdown support for formatting diff --git a/docs/roadmap.md b/docs/roadmap.md index f442f63..cd8b897 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,18 +16,10 @@ - **Navigation & Permalinks** — Sidebar digest nav (monthly digest links with smooth-scroll), theme permalink pages (`/themes/$themeId`), hover-visible permalink icons on themes, DOM anchor IDs on all feed items - **Image Uploads** — Upload images/GIFs to Cloudflare R2 via writer dashboard, markdown image syntax inserted into breadcrumbs - **AI Tag Suggestions** — After theme creation, Claude Haiku suggests 3–5 reuse-aware tags; two-phase create dialog pre-fills the chip input with sparkle indicators; writer can edit before saving +- **Tag Reordering** — Drag-to-reorder tags in sidebar (desktop) and mobile pill strip via dnd-kit; `position` column on Tag model; `PATCH /api/tags/reorder` (auth-required); order persists server-side across devices; sort cycles custom → usage → alpha ## Up Next -### Phase 5: Tag Reordering - -Drag-to-reorder tags in both the sidebar and mobile pill strip. Order stored server-side so readers see the same ordering across all devices. - -- New `position` column on the Tag model with migration -- `PATCH /api/tags/reorder` endpoint (auth required) -- dnd-kit sortable containers in sidebar and tag bar -- Feed and tag list respect server-side sort order - ### Future - **Digest regeneration** — Allow re-generating a draft digest to capture themes added after initial generation @@ -40,5 +32,3 @@ Drag-to-reorder tags in both the sidebar and mobile pill strip. Order stored ser #### Always-On Blog Authoring Agent A self-contained Telegram bot (not OpenClaw) with persistent memory via SQLite + vector embeddings. Responds 24/7 from a VPS or Mac Mini. Tools: create theme, add breadcrumb, list recent themes, semantic search over content. Hosting TBD. -#### Tag Reordering -Drag-to-reorder tags in the sidebar and mobile pill strip. The horizontal pill layout is already compatible with dnd-kit sortable containers. diff --git a/llms.txt b/llms.txt index c402b8e..c5bb07e 100644 --- a/llms.txt +++ b/llms.txt @@ -48,7 +48,8 @@ Breadcrumbs is a web application for capturing and organizing stream-of-consciou - `/api/themes/{id}/breadcrumbs` - CRUD operations for breadcrumbs within a theme - `/api/themes/{id}/suggest-tags` - AI-powered tag suggestion (Claude Haiku, auth required) - `/api/themes/{id}/generate-image` / `/api/themes/{id}/image` - AI cover image generation (Flux Schnell via Replicate, stored in R2) -- `/api/tags` - Tag management and browsing +- `/api/tags` - Tag listing (ordered by server-side `position`, then name) and browsing +- `/api/tags/reorder` - PATCH endpoint (auth-required) to set custom tag display order - `/api/months` - Calendar months for rolling-window feed navigation - `/api/digests` - Weekly/monthly AI-generated summary digests - `/api/uploads` - Image/GIF upload to Cloudflare R2 @@ -67,7 +68,7 @@ Breadcrumbs is a web application for capturing and organizing stream-of-consciou **Data Models:** - **Theme:** Topical container with title, optional description, visibility (draft/published), and tags - **Breadcrumb:** Small thought atom (markdown content) that belongs to exactly one theme. Breadcrumbs can nest via optional `parent_id` (self-referential FK). API returns flat list with `parent_id`; clients build tree. -- **Tag:** Categorization labels with many-to-many relationship to themes +- **Tag:** Categorization labels with many-to-many relationship to themes. Has an integer `position` field for custom server-side display order; writers drag-reorder via `PATCH /api/tags/reorder` (auth-required). - **ThemeTag:** Join table for many-to-many relationship between themes and tags ## Getting Started