Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""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))
# 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:
op.drop_column("tag", "position")
30 changes: 28 additions & 2 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -107,6 +108,10 @@ def escape_like(s: str) -> str:

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(
Expand All @@ -115,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)
tag = Tag(name=tc.name, position=next_pos)
next_pos += 1
session.add(tag)
try:
session.flush()
Expand Down Expand Up @@ -572,7 +578,7 @@ def list_tags(session: Session = Depends(get_session)):
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 [
Expand All @@ -594,6 +600,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"}
Expand Down
1 change: 1 addition & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 1 addition & 11 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
56 changes: 56 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading