initial release

This commit is contained in:
Justin Visser 2026-03-03 16:16:36 +01:00
parent e156de22c3
commit b3ac11a85e
37 changed files with 3649 additions and 669 deletions

View file

@ -5,6 +5,7 @@ from pathlib import Path
def init_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
@ -14,6 +15,19 @@ def init_db(db_path: Path) -> None:
value TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS pages (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
sort_order INTEGER NOT NULL,
is_system INTEGER NOT NULL DEFAULT 0,
is_hidden INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_pages_order ON pages(sort_order)")
conn.execute("""
CREATE TABLE IF NOT EXISTS segments (
id TEXT PRIMARY KEY,
@ -27,11 +41,106 @@ def init_db(db_path: Path) -> None:
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_segments_order ON segments(sort_order)")
conn.execute("""
CREATE TABLE IF NOT EXISTS team_members (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
student_number TEXT NOT NULL,
sort_order INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
# Migrate existing table schemas (add columns if missing)
_migrate_columns(conn)
# Remove legacy segment types
conn.execute("DELETE FROM segments WHERE type IN ('link', 'team')")
conn.commit()
# Ensure default pages exist
_migrate_default_page(conn)
_ensure_team_page(conn)
finally:
conn.close()
def _migrate_columns(conn: sqlite3.Connection) -> None:
"""Add any missing columns to existing tables."""
seg_cols = [row["name"] for row in conn.execute("PRAGMA table_info(segments)").fetchall()]
if "page_id" not in seg_cols:
conn.execute("ALTER TABLE segments ADD COLUMN page_id TEXT REFERENCES pages(id)")
page_cols = [row["name"] for row in conn.execute("PRAGMA table_info(pages)").fetchall()]
if "is_system" not in page_cols:
conn.execute("ALTER TABLE pages ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0")
if "is_hidden" not in page_cols:
conn.execute("ALTER TABLE pages ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0")
def _migrate_default_page(conn: sqlite3.Connection) -> None:
"""Ensure a Home page exists and all segments have a page_id."""
from datetime import UTC, datetime
from uuid import uuid4
home_id: str | None = None
home = conn.execute("SELECT id FROM pages WHERE slug = 'home'").fetchone()
if not home:
# Only create Home if no pages exist at all
page_count = conn.execute(
"SELECT COUNT(*) AS cnt FROM pages WHERE is_system = 0"
).fetchone()["cnt"]
if page_count == 0:
now = datetime.now(UTC).isoformat()
default_id = str(uuid4())
conn.execute(
"INSERT INTO pages"
" (id, name, slug, sort_order, is_system, is_hidden, created_at, updated_at)"
" VALUES (?, ?, ?, ?, 1, 0, ?, ?)",
(default_id, "Home", "home", 0, now, now),
)
conn.commit()
home_id = default_id
else:
first = conn.execute(
"SELECT id FROM pages WHERE is_system = 0 ORDER BY sort_order ASC LIMIT 1"
).fetchone()
home_id = first["id"] if first else None
else:
home_id = home["id"]
if home_id:
conn.execute("UPDATE segments SET page_id = ? WHERE page_id IS NULL", (home_id,))
conn.commit()
conn.execute("UPDATE pages SET is_system = 1 WHERE slug = 'home'")
conn.commit()
def _ensure_team_page(conn: sqlite3.Connection) -> None:
"""Ensure the system Team page exists (always pinned last)."""
from datetime import UTC, datetime
from uuid import uuid4
existing = conn.execute("SELECT id FROM pages WHERE slug = 'team'").fetchone()
if existing:
# Make sure it's marked as system
conn.execute("UPDATE pages SET is_system = 1 WHERE slug = 'team'")
conn.commit()
return
now = datetime.now(UTC).isoformat()
conn.execute(
"INSERT INTO pages"
" (id, name, slug, sort_order, is_system, is_hidden, created_at, updated_at)"
" VALUES (?, ?, ?, ?, 1, 0, ?, ?)",
(str(uuid4()), "Team", "team", 9999, now, now),
)
conn.commit()
def get_connection(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row

View file

@ -9,8 +9,10 @@ from app.config import get_settings
from app.database import init_db
from app.routes.assets import router as assets_router
from app.routes.auth import router as auth_router
from app.routes.pages import router as pages_router
from app.routes.segments import router as segments_router
from app.routes.site import router as site_router
from app.routes.team import router as team_router
@asynccontextmanager
@ -25,9 +27,11 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(title="Handin Website", lifespan=lifespan)
app.include_router(auth_router)
app.include_router(pages_router)
app.include_router(segments_router)
app.include_router(site_router)
app.include_router(assets_router)
app.include_router(team_router)
@app.get("/api/health")

View file

@ -12,7 +12,7 @@ class SegmentType(StrEnum):
AUDIO = "audio"
IFRAME = "iframe"
GALLERY = "gallery"
LINK = "link"
TEAM = "team"
class Segment(BaseModel):
@ -22,13 +22,15 @@ class Segment(BaseModel):
title: str
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
page_id: str | None = None
created_at: datetime
updated_at: datetime
class SegmentCreateRequest(BaseModel):
type: SegmentType
title: str
title: str = ""
page_id: str | None = None
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
@ -54,9 +56,54 @@ class SegmentResponse(BaseModel):
title: str
content: str
metadata: dict[str, object]
page_id: str | None = None
created_at: datetime
updated_at: datetime
class SiteResponse(BaseModel):
title: str
class Page(BaseModel):
id: str
name: str
slug: str
sort_order: int
is_system: bool = False
is_hidden: bool = False
created_at: datetime
updated_at: datetime
class PageCreateRequest(BaseModel):
name: str
slug: str
class PageUpdateRequest(BaseModel):
name: str | None = None
slug: str | None = None
is_hidden: bool | None = None
class PageReorderRequest(BaseModel):
page_ids: list[str]
class TeamMember(BaseModel):
id: str
name: str
student_number: str
sort_order: int
created_at: datetime
updated_at: datetime
class TeamMemberCreateRequest(BaseModel):
name: str
student_number: str
class TeamMemberReorderRequest(BaseModel):
member_ids: list[str]

104
backend/app/routes/pages.py Normal file
View file

@ -0,0 +1,104 @@
import threading
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Response
from app.auth import require_admin
from app.config import get_settings
from app.database import init_db
from app.models import (
Page,
PageCreateRequest,
PageReorderRequest,
PageUpdateRequest,
SegmentResponse,
)
from app.services import page_service, segment_service
router = APIRouter(prefix="/api/pages", tags=["pages"])
_write_lock = threading.Lock()
def get_db_path() -> Path:
data_dir = get_settings().data_dir
db_path = Path(data_dir) / "handin.db"
init_db(db_path)
return db_path
DbPath = Annotated[Path, Depends(get_db_path)]
Admin = Annotated[None, Depends(require_admin)]
@router.get("/")
def list_pages(db_path: DbPath) -> list[Page]:
return page_service.list_pages(db_path)
@router.post("/", status_code=201)
def create_page(request: PageCreateRequest, _: Admin, db_path: DbPath) -> Page:
with _write_lock:
# Check slug uniqueness
existing = page_service.get_page_by_slug(db_path, request.slug)
if existing:
raise HTTPException(status_code=409, detail="A page with this slug already exists")
return page_service.create_page(db_path, request)
@router.put("/reorder")
def reorder_pages(request: PageReorderRequest, _: Admin, db_path: DbPath) -> list[Page]:
with _write_lock:
try:
return page_service.reorder_pages(db_path, request.page_ids)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@router.get("/{page_id}")
def get_page(page_id: str, db_path: DbPath) -> Page:
page = page_service.get_page(db_path, page_id)
if page is None:
raise HTTPException(status_code=404, detail="Page not found")
return page
@router.get("/by-slug/{slug}")
def get_page_by_slug(slug: str, db_path: DbPath) -> Page:
page = page_service.get_page_by_slug(db_path, slug)
if page is None:
raise HTTPException(status_code=404, detail="Page not found")
return page
@router.patch("/{page_id}")
def update_page(page_id: str, request: PageUpdateRequest, _: Admin, db_path: DbPath) -> Page:
if request.slug is not None:
existing = page_service.get_page_by_slug(db_path, request.slug)
if existing and existing.id != page_id:
raise HTTPException(status_code=409, detail="A page with this slug already exists")
page = page_service.update_page(db_path, page_id, request)
if page is None:
raise HTTPException(status_code=404, detail="Page not found")
return page
@router.delete("/{page_id}", status_code=204)
def delete_page(page_id: str, _: Admin, db_path: DbPath) -> Response:
deleted, error = page_service.delete_page(db_path, page_id)
if not deleted:
if error == "not_found":
raise HTTPException(status_code=404, detail="Page not found")
if error == "system_page":
raise HTTPException(status_code=403, detail="System pages cannot be deleted")
return Response(status_code=204)
@router.get("/{page_id}/segments")
def list_page_segments(page_id: str, db_path: DbPath) -> list[SegmentResponse]:
page = page_service.get_page(db_path, page_id)
if page is None:
raise HTTPException(status_code=404, detail="Page not found")
segments = segment_service.list_segments(db_path, page_id=page_id)
return [SegmentResponse.model_validate(s.model_dump()) for s in segments]

View file

@ -3,7 +3,7 @@ from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from app.auth import require_admin
from app.config import get_settings
@ -34,8 +34,11 @@ Admin = Annotated[None, Depends(require_admin)]
@router.get("/")
def list_segments(db_path: DbPath) -> list[SegmentResponse]:
segments = segment_service.list_segments(db_path)
def list_segments(
db_path: DbPath,
page_id: Annotated[str | None, Query()] = None,
) -> list[SegmentResponse]:
segments = segment_service.list_segments(db_path, page_id=page_id)
return [SegmentResponse.model_validate(s.model_dump()) for s in segments]

View file

@ -0,0 +1,56 @@
import threading
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Response
from app.auth import require_admin
from app.config import get_settings
from app.database import init_db
from app.models import TeamMember, TeamMemberCreateRequest, TeamMemberReorderRequest
from app.services import team_service
router = APIRouter(prefix="/api/team", tags=["team"])
_write_lock = threading.Lock()
def get_db_path() -> Path:
data_dir = get_settings().data_dir
db_path = Path(data_dir) / "handin.db"
init_db(db_path)
return db_path
DbPath = Annotated[Path, Depends(get_db_path)]
Admin = Annotated[None, Depends(require_admin)]
@router.get("/")
def list_members(db_path: DbPath) -> list[TeamMember]:
return team_service.list_members(db_path)
@router.post("/", status_code=201)
def add_member(request: TeamMemberCreateRequest, _: Admin, db_path: DbPath) -> TeamMember:
with _write_lock:
return team_service.add_member(db_path, request)
@router.put("/reorder")
def reorder_members(
request: TeamMemberReorderRequest, _: Admin, db_path: DbPath
) -> list[TeamMember]:
with _write_lock:
try:
return team_service.reorder_members(db_path, request.member_ids)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@router.delete("/{member_id}", status_code=204)
def delete_member(member_id: str, _: Admin, db_path: DbPath) -> Response:
deleted = team_service.delete_member(db_path, member_id)
if not deleted:
raise HTTPException(status_code=404, detail="Team member not found")
return Response(status_code=204)

View file

@ -0,0 +1,149 @@
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
from app.database import get_connection
from app.models import Page, PageCreateRequest, PageUpdateRequest
def _row_to_page(row: sqlite3.Row) -> Page:
return Page(
id=row["id"],
name=row["name"],
slug=row["slug"],
sort_order=row["sort_order"],
is_system=bool(row["is_system"]),
is_hidden=bool(row["is_hidden"]),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
def list_pages(db_path: Path) -> list[Page]:
conn = get_connection(db_path)
try:
rows = conn.execute(
"SELECT * FROM pages ORDER BY is_system ASC, sort_order ASC"
).fetchall()
return [_row_to_page(row) for row in rows]
finally:
conn.close()
def get_page(db_path: Path, page_id: str) -> Page | None:
conn = get_connection(db_path)
try:
row = conn.execute("SELECT * FROM pages WHERE id = ?", (page_id,)).fetchone()
return _row_to_page(row) if row else None
finally:
conn.close()
def get_page_by_slug(db_path: Path, slug: str) -> Page | None:
conn = get_connection(db_path)
try:
row = conn.execute("SELECT * FROM pages WHERE slug = ?", (slug,)).fetchone()
return _row_to_page(row) if row else None
finally:
conn.close()
def create_page(db_path: Path, request: PageCreateRequest) -> Page:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order FROM pages WHERE is_system = 0"
).fetchone()
sort_order: int = row["next_order"] if row else 0
page_id = str(uuid4())
now = datetime.now(UTC).isoformat()
conn.execute(
"INSERT INTO pages"
" (id, name, slug, sort_order, is_system, is_hidden, created_at, updated_at)"
" VALUES (?, ?, ?, ?, 0, 0, ?, ?)",
(page_id, request.name, request.slug, sort_order, now, now),
)
conn.commit()
return Page(
id=page_id,
name=request.name,
slug=request.slug,
sort_order=sort_order,
is_system=False,
is_hidden=False,
created_at=datetime.fromisoformat(now),
updated_at=datetime.fromisoformat(now),
)
finally:
conn.close()
def update_page(db_path: Path, page_id: str, request: PageUpdateRequest) -> Page | None:
conn = get_connection(db_path)
try:
existing = conn.execute("SELECT * FROM pages WHERE id = ?", (page_id,)).fetchone()
if existing is None:
return None
now = datetime.now(UTC).isoformat()
name = request.name if request.name is not None else existing["name"]
slug = request.slug if request.slug is not None else existing["slug"]
is_hidden = (
int(request.is_hidden) if request.is_hidden is not None else existing["is_hidden"]
)
conn.execute(
"UPDATE pages SET name = ?, slug = ?, is_hidden = ?, updated_at = ? WHERE id = ?",
(name, slug, is_hidden, now, page_id),
)
conn.commit()
return get_page(db_path, page_id)
finally:
conn.close()
def delete_page(db_path: Path, page_id: str) -> tuple[bool, str | None]:
"""Cascade-deletes the page and all its segments."""
conn = get_connection(db_path)
try:
existing = conn.execute("SELECT * FROM pages WHERE id = ?", (page_id,)).fetchone()
if existing is None:
return False, "not_found"
if existing["is_system"]:
return False, "system_page"
conn.execute("DELETE FROM segments WHERE page_id = ?", (page_id,))
conn.execute("DELETE FROM pages WHERE id = ?", (page_id,))
conn.commit()
return True, None
finally:
conn.close()
def reorder_pages(db_path: Path, page_ids: list[str]) -> list[Page]:
"""Reorders only non-system pages."""
conn = get_connection(db_path)
try:
existing_ids = {
row["id"]
for row in conn.execute("SELECT id FROM pages WHERE is_system = 0").fetchall()
}
requested_ids = set(page_ids)
missing = requested_ids - existing_ids
if missing:
raise ValueError(f"Page IDs not found: {missing}")
now = datetime.now(UTC).isoformat()
for index, pid in enumerate(page_ids):
conn.execute(
"UPDATE pages SET sort_order = ?, updated_at = ? WHERE id = ?",
(index, now, pid),
)
conn.commit()
return list_pages(db_path)
finally:
conn.close()

View file

@ -16,6 +16,7 @@ def _row_to_segment(row: sqlite3.Row) -> Segment:
title=row["title"],
content=row["content"],
metadata=json.loads(row["metadata"]),
page_id=dict(row).get("page_id"),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
@ -25,7 +26,9 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order FROM segments"
"SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order"
" FROM segments WHERE page_id = ?",
(request.page_id,),
).fetchone()
sort_order: int = row["next_order"] if row else 0
@ -33,9 +36,9 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
now = datetime.now(UTC).isoformat()
metadata_json = json.dumps(request.metadata)
cols = "id, type, sort_order, title, content, metadata, created_at, updated_at"
cols = "id, type, sort_order, title, content, metadata, page_id, created_at, updated_at"
conn.execute(
f"INSERT INTO segments ({cols}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
f"INSERT INTO segments ({cols}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
str(segment_id),
request.type.value,
@ -43,6 +46,7 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
request.title,
request.content,
metadata_json,
request.page_id,
now,
now,
),
@ -56,6 +60,7 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
title=request.title,
content=request.content,
metadata=request.metadata,
page_id=request.page_id,
created_at=datetime.fromisoformat(now),
updated_at=datetime.fromisoformat(now),
)
@ -63,10 +68,16 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
conn.close()
def list_segments(db_path: Path) -> list[Segment]:
def list_segments(db_path: Path, page_id: str | None = None) -> list[Segment]:
conn = get_connection(db_path)
try:
rows = conn.execute("SELECT * FROM segments ORDER BY sort_order ASC").fetchall()
if page_id is not None:
rows = conn.execute(
"SELECT * FROM segments WHERE page_id = ? ORDER BY sort_order ASC",
(page_id,),
).fetchall()
else:
rows = conn.execute("SELECT * FROM segments ORDER BY sort_order ASC").fetchall()
return [_row_to_segment(row) for row in rows]
finally:
conn.close()
@ -140,6 +151,13 @@ def reorder_segments(db_path: Path, segment_ids: list[UUID]) -> list[Segment]:
)
conn.commit()
return list_segments(db_path)
# Return segments scoped to the same page as the first segment in the list
if segment_ids:
row = conn.execute(
"SELECT page_id FROM segments WHERE id = ?", (str(segment_ids[0]),)
).fetchone()
page_id = row["page_id"] if row else None
return list_segments(db_path, page_id=page_id)
return []
finally:
conn.close()

View file

@ -0,0 +1,91 @@
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
from app.database import get_connection
from app.models import TeamMember, TeamMemberCreateRequest
def _row_to_member(row: sqlite3.Row) -> TeamMember:
return TeamMember(
id=row["id"],
name=row["name"],
student_number=row["student_number"],
sort_order=row["sort_order"],
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
def list_members(db_path: Path) -> list[TeamMember]:
conn = get_connection(db_path)
try:
rows = conn.execute("SELECT * FROM team_members ORDER BY sort_order ASC").fetchall()
return [_row_to_member(row) for row in rows]
finally:
conn.close()
def add_member(db_path: Path, request: TeamMemberCreateRequest) -> TeamMember:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order FROM team_members"
).fetchone()
sort_order: int = row["next_order"] if row else 0
member_id = str(uuid4())
now = datetime.now(UTC).isoformat()
conn.execute(
"INSERT INTO team_members"
" (id, name, student_number, sort_order, created_at, updated_at)"
" VALUES (?, ?, ?, ?, ?, ?)",
(member_id, request.name, request.student_number, sort_order, now, now),
)
conn.commit()
return TeamMember(
id=member_id,
name=request.name,
student_number=request.student_number,
sort_order=sort_order,
created_at=datetime.fromisoformat(now),
updated_at=datetime.fromisoformat(now),
)
finally:
conn.close()
def delete_member(db_path: Path, member_id: str) -> bool:
conn = get_connection(db_path)
try:
existing = conn.execute(
"SELECT id FROM team_members WHERE id = ?", (member_id,)
).fetchone()
if existing is None:
return False
conn.execute("DELETE FROM team_members WHERE id = ?", (member_id,))
conn.commit()
return True
finally:
conn.close()
def reorder_members(db_path: Path, member_ids: list[str]) -> list[TeamMember]:
conn = get_connection(db_path)
try:
existing_ids = {
row["id"] for row in conn.execute("SELECT id FROM team_members").fetchall()
}
missing = set(member_ids) - existing_ids
if missing:
raise ValueError(f"Member IDs not found: {missing}")
now = datetime.now(UTC).isoformat()
for index, mid in enumerate(member_ids):
conn.execute(
"UPDATE team_members SET sort_order = ?, updated_at = ? WHERE id = ?",
(index, now, mid),
)
conn.commit()
return list_members(db_path)
finally:
conn.close()

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Handin</title>
<link rel="icon" type="image/png" href="/favicon.png" />
</head>
<body>
<div id="app"></div>

File diff suppressed because it is too large Load diff

View file

@ -10,13 +10,17 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "3.5.13",
"@tiptap/starter-kit": "^3.20.0",
"@tiptap/vue-3": "^3.20.0",
"marked": "15.0.7",
"tiptap-markdown": "^0.9.0",
"vue": "3.5.13",
"vue-router": "^5.0.3",
"vuedraggable": "4.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.1.4",
"@tailwindcss/typography": "0.5.16",
"@tailwindcss/vite": "4.1.4",
"@vitejs/plugin-vue": "5.2.3",
"tailwindcss": "4.1.4",
"typescript": "5.7.3",

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View file

@ -1,7 +1,9 @@
<script setup lang="ts">
import HomePage from "@/views/HomePage.vue";
import AppShell from "@/components/layout/AppShell.vue";
</script>
<template>
<HomePage />
<AppShell>
<RouterView />
</AppShell>
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View file

@ -2,29 +2,27 @@
import { ref, computed } from "vue";
import type { SegmentType } from "@/types/segment";
import AssetUploader from "@/components/admin/AssetUploader.vue";
import RichTextEditor from "@/components/admin/RichTextEditor.vue";
const emit = defineEmits<{
create: [data: { type: SegmentType; title: string; content: string; metadata?: Record<string, unknown> }];
cancel: [];
}>();
const segmentTypes: { value: SegmentType; label: string }[] = [
{ value: "markdown", label: "Markdown" },
{ value: "pdf", label: "PDF" },
{ value: "video", label: "Video" },
{ value: "audio", label: "Audio" },
{ value: "iframe", label: "Iframe" },
{ value: "gallery", label: "Gallery" },
{ value: "link", label: "Link" },
const segmentTypes: { value: SegmentType; label: string; description: string; icon: string }[] = [
{ value: "markdown", label: "Text", description: "Write formatted text, headings, and lists", icon: '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h10M4 18h6" /></svg>' },
{ value: "gallery", label: "Images", description: "Display a photo gallery", icon: '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" ry="2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="8.5" cy="8.5" r="1.5" stroke-width="2" /><polyline points="21 15 16 10 5 21" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>' },
{ value: "pdf", label: "PDF", description: "Show a PDF document", icon: '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>' },
{ value: "video", label: "Video", description: "Embed a video file", icon: '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>' },
{ value: "audio", label: "Audio", description: "Embed an audio file", icon: '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12m0 0a3 3 0 003-3V9a3 3 0 00-3 3m0 0a3 3 0 01-3 3V9a3 3 0 013-3" /></svg>' },
{ value: "iframe", label: "Embed", description: "Embed an external website or tool", icon: '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>' },
];
const selectedType = ref<SegmentType>("markdown");
const title = ref("");
const content = ref("");
const galleryImages = ref<string[]>([]);
const canSubmit = computed(() => {
if (!title.value.trim()) return false;
if (selectedType.value === "gallery") return galleryImages.value.length > 0;
if (selectedType.value === "markdown") return true;
return content.value.trim().length > 0;
@ -67,82 +65,83 @@ const assetAcceptMap: Record<string, string> = {
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
@click.self="$emit('cancel')"
>
<div class="mx-4 w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
<h2 class="mb-4 text-lg font-semibold text-slate-900 dark:text-slate-100">Add New Segment</h2>
<div class="mx-4 w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">Add a new section</h2>
<!-- Type selector: button group -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">Type</label>
<div class="flex flex-wrap gap-2">
<!-- Type selector: icon cards -->
<div class="mb-5">
<div class="grid grid-cols-3 gap-2 sm:grid-cols-6">
<button
v-for="st in segmentTypes"
:key="st.value"
class="rounded-full px-3 py-1 text-sm font-medium transition-colors"
type="button"
class="flex flex-col items-center gap-1.5 rounded-lg border-2 py-3 px-2 transition-colors"
:class="
selectedType === st.value
? 'bg-primary-300 text-slate-900 dark:bg-primary-600 dark:text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'
? 'border-primary-300 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
"
:title="st.description"
@click="selectedType = st.value; content = ''; galleryImages = []"
>
{{ st.label }}
<span class="text-gray-500 dark:text-gray-400" v-html="st.icon" />
<span class="text-xs font-medium text-gray-900 dark:text-gray-100">{{ st.label }}</span>
</button>
</div>
</div>
<!-- Title -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Title</label>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Section title <span class="font-normal text-gray-400">(optional)</span>
</label>
<input
v-model="title"
type="text"
placeholder="Segment title"
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
placeholder="Give this section a title"
class="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<!-- Content: type-dependent -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label>
<div class="mb-5">
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Content</label>
<!-- Markdown: textarea -->
<textarea
v-if="selectedType === 'markdown'"
v-model="content"
rows="6"
placeholder="Markdown content..."
class="w-full rounded border border-slate-300 px-3 py-2 font-mono text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
/>
<!-- Markdown: rich text editor -->
<RichTextEditor v-if="selectedType === 'markdown'" v-model="content" />
<!-- Iframe / Link: URL input -->
<input
v-else-if="selectedType === 'iframe' || selectedType === 'link'"
v-model="content"
type="url"
:placeholder="selectedType === 'link' ? 'https://external-site.com' : 'https://...'"
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
/>
<!-- Iframe: URL input -->
<div v-else-if="selectedType === 'iframe'">
<p class="mb-2 text-xs text-gray-400">Paste the URL of the website or tool you want to embed.</p>
<input
v-model="content"
type="url"
placeholder="https://..."
class="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<!-- PDF / Video / Audio -->
<div v-else-if="selectedType === 'pdf' || selectedType === 'video' || selectedType === 'audio'">
<div v-if="content" class="mb-2 flex items-center gap-2 rounded bg-slate-50 px-3 py-2 text-sm text-slate-600">
<div v-if="content" class="mb-2 flex items-center gap-2 rounded bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:bg-gray-700 dark:text-gray-300">
<span class="truncate">{{ content }}</span>
<button class="shrink-0 text-red-400 hover:text-red-600" @click="content = ''">&times;</button>
</div>
<AssetUploader
:accept="assetAcceptMap[selectedType]"
:label="`Upload ${selectedType} file`"
:label="`Click to upload your ${selectedType} file, or drag and drop`"
@uploaded="handleAssetUploaded"
/>
</div>
<!-- Gallery -->
<div v-else-if="selectedType === 'gallery'">
<p class="mb-2 text-xs text-gray-400">Upload one or more photos. You can add more later.</p>
<div v-if="galleryImages.length > 0" class="mb-3 grid grid-cols-4 gap-2">
<div
v-for="(img, i) in galleryImages"
:key="i"
class="group relative aspect-square overflow-hidden rounded bg-slate-100"
class="group relative aspect-square overflow-hidden rounded bg-gray-100 dark:bg-gray-700"
>
<img :src="img" class="h-full w-full object-cover" />
<button
@ -153,24 +152,24 @@ const assetAcceptMap: Record<string, string> = {
</button>
</div>
</div>
<AssetUploader accept="image/*" label="Upload gallery image" @uploaded="handleGalleryImageUploaded" />
<AssetUploader accept="image/*" :multiple="true" label="Click to upload photos, or drag and drop" @uploaded="handleGalleryImageUploaded" />
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2">
<button
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
class="rounded px-3 py-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
@click="$emit('cancel')"
>
Cancel
</button>
<button
:disabled="!canSubmit"
class="rounded bg-primary-300 px-4 py-1.5 text-sm font-medium text-slate-900 transition-colors hover:bg-primary-400 disabled:opacity-50 dark:bg-primary-600 dark:text-white dark:hover:bg-primary-500"
class="rounded bg-primary-300 px-4 py-1.5 text-sm font-medium text-gray-900 transition-colors hover:bg-primary-400 disabled:opacity-50"
@click="handleSubmit"
>
Create
Add section
</button>
</div>
</div>

View file

@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref } from "vue";
import { ref, onMounted, watch } from "vue";
import { useAdmin } from "@/composables/useAdmin";
import { useAssetUpload } from "@/composables/useAssetUpload";
const props = defineProps<{
accept?: string;
label?: string;
multiple?: boolean;
}>();
const emit = defineEmits<{
@ -13,28 +14,61 @@ const emit = defineEmits<{
}>();
const { authHeaders } = useAdmin();
const { uploading, error, upload } = useAssetUpload();
const { upload } = useAssetUpload();
const dragging = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
async function handleFile(file: File) {
const filename = await upload(file, authHeaders.value);
if (filename) {
emit("uploaded", filename);
// Track batch upload state independently of the per-request composable
const busy = ref(false);
const progressLabel = ref("");
const uploadError = ref<string | null>(null);
// Ensure the `multiple` HTML attribute is set directly on the DOM node,
// since some GTK/GNOME file pickers only respect the attribute, not the
// DOM property that Vue's :multiple binding sets.
function applyMultiple() {
if (!fileInput.value) return;
if (props.multiple) {
fileInput.value.setAttribute("multiple", "");
} else {
fileInput.value.removeAttribute("multiple");
}
}
onMounted(applyMultiple);
watch(() => props.multiple, applyMultiple);
async function handleFiles(files: File[]) {
if (files.length === 0) return;
busy.value = true;
uploadError.value = null;
try {
for (let i = 0; i < files.length; i++) {
progressLabel.value = files.length > 1
? `Uploading ${i + 1} of ${files.length}`
: "Uploading…";
const filename = await upload(files[i], authHeaders.value);
if (filename) {
emit("uploaded", filename);
}
}
} finally {
busy.value = false;
progressLabel.value = "";
}
}
function onDrop(e: DragEvent) {
dragging.value = false;
const file = e.dataTransfer?.files[0];
if (file) void handleFile(file);
const files = Array.from(e.dataTransfer?.files ?? []);
void handleFiles(files);
}
function onFileSelect(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) void handleFile(file);
const files = Array.from(target.files ?? []);
target.value = "";
void handleFiles(files);
}
</script>
@ -47,15 +81,19 @@ function onFileSelect(e: Event) {
@drop.prevent="onDrop"
@click="fileInput?.click()"
>
<!-- Hidden file input: multiple attribute set imperatively via onMounted/watch -->
<input
ref="fileInput"
type="file"
class="hidden"
:accept="props.accept"
@click.stop
@change="onFileSelect"
/>
<div v-if="uploading" class="text-sm text-slate-500 dark:text-slate-400">Uploading...</div>
<div v-if="busy" class="text-sm text-slate-500 dark:text-slate-400">
{{ progressLabel }}
</div>
<div v-else>
<svg class="mx-auto mb-2 h-8 w-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
@ -63,6 +101,6 @@ function onFileSelect(e: Event) {
<p class="text-sm text-slate-500 dark:text-slate-400">{{ props.label ?? "Drop file here or click to browse" }}</p>
</div>
<p v-if="error" class="mt-2 text-sm text-red-500">{{ error }}</p>
<p v-if="uploadError" class="mt-2 text-sm text-red-500">{{ uploadError }}</p>
</div>
</template>

View file

@ -0,0 +1,243 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from "vue";
import { useEditor, EditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
const props = defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
const headingDropdownOpen = ref(false);
const headingLevels = [
{ level: 0, label: "Paragraph" },
{ level: 1, label: "Heading 1" },
{ level: 2, label: "Heading 2" },
{ level: 3, label: "Heading 3" },
] as const;
const editor = useEditor({
extensions: [
StarterKit,
Markdown.configure({ html: false, transformPastedText: true }),
],
content: props.modelValue,
onUpdate() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emit("update:modelValue", (editor.value?.storage as any).markdown?.getMarkdown() ?? "");
},
});
function getActiveHeadingLabel() {
if (!editor.value) return "Paragraph";
for (const h of headingLevels) {
if (h.level > 0 && editor.value.isActive("heading", { level: h.level })) return h.label;
}
return "Paragraph";
}
function applyHeading(level: 0 | 1 | 2 | 3) {
headingDropdownOpen.value = false;
if (!editor.value) return;
if (level === 0) {
editor.value.chain().focus().setParagraph().run();
} else {
editor.value.chain().focus().toggleHeading({ level }).run();
}
}
watch(
() => props.modelValue,
(val) => {
if (!editor.value) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const current = (editor.value.storage as any).markdown?.getMarkdown() ?? "";
if (val !== current) {
editor.value.commands.setContent(val);
}
},
);
onBeforeUnmount(() => {
editor.value?.destroy();
});
</script>
<template>
<div class="rounded border border-gray-300 dark:border-gray-600">
<!-- Toolbar -->
<div class="flex flex-wrap items-center gap-0.5 border-b border-gray-300 bg-gray-50 p-1.5 dark:border-gray-600 dark:bg-gray-700/50">
<!-- Heading dropdown -->
<div class="relative">
<button
type="button"
class="flex items-center gap-1 rounded px-2 py-1.5 text-xs text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('heading') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Heading level"
@click="headingDropdownOpen = !headingDropdownOpen"
>
<span class="font-medium">{{ getActiveHeadingLabel() }}</span>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div
v-if="headingDropdownOpen"
class="absolute left-0 top-full z-10 mt-0.5 min-w-[120px] rounded border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-600 dark:bg-gray-800"
>
<button
v-for="h in headingLevels"
:key="h.level"
type="button"
class="block w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
:class="[
h.level === 0 ? 'text-gray-700 dark:text-gray-300' : '',
h.level === 1 ? 'text-base font-bold text-gray-900 dark:text-gray-100' : '',
h.level === 2 ? 'font-semibold text-gray-900 dark:text-gray-100' : '',
h.level === 3 ? 'font-medium text-gray-800 dark:text-gray-200' : '',
]"
@click="applyHeading(h.level as 0 | 1 | 2 | 3)"
>{{ h.label }}</button>
</div>
</div>
<div class="mx-1 h-5 w-px bg-gray-300 dark:bg-gray-600" />
<!-- Bold -->
<button
type="button"
class="rounded p-1.5 text-sm font-bold text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('bold') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Bold (Ctrl+B)"
@click="editor?.chain().focus().toggleBold().run()"
>B</button>
<!-- Italic -->
<button
type="button"
class="rounded p-1.5 text-sm italic text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('italic') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Italic (Ctrl+I)"
@click="editor?.chain().focus().toggleItalic().run()"
>I</button>
<!-- Strikethrough -->
<button
type="button"
class="rounded p-1.5 text-sm text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('strike') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Strikethrough"
@click="editor?.chain().focus().toggleStrike().run()"
><span class="line-through">S</span></button>
<!-- Inline code -->
<button
type="button"
class="rounded p-1.5 text-xs font-mono text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('code') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Inline code"
@click="editor?.chain().focus().toggleCode().run()"
>&lt;/&gt;</button>
<div class="mx-1 h-5 w-px bg-gray-300 dark:bg-gray-600" />
<!-- Bullet list -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('bulletList') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Bullet list"
@click="editor?.chain().focus().toggleBulletList().run()"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
</svg>
</button>
<!-- Ordered list -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('orderedList') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Ordered list"
@click="editor?.chain().focus().toggleOrderedList().run()"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 6h13M7 12h13M7 18h13M3 6h.01M3 12h.01M3 18h.01" />
</svg>
</button>
<!-- Blockquote -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('blockquote') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Blockquote"
@click="editor?.chain().focus().toggleBlockquote().run()"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.804.167 3.226 1.648 3.226 3.489a3.5 3.5 0 01-3.5 3.5c-1.073 0-2.099-.49-2.748-1.179zm10 0C13.553 16.227 13 15 13 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.804.167 3.226 1.648 3.226 3.489a3.5 3.5 0 01-3.5 3.5c-1.073 0-2.099-.49-2.748-1.179z" />
</svg>
</button>
<!-- Code block -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('codeBlock') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Code block"
@click="editor?.chain().focus().toggleCodeBlock().run()"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</button>
<!-- Horizontal rule -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
title="Horizontal rule"
@click="editor?.chain().focus().setHorizontalRule().run()"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14" />
</svg>
</button>
<div class="mx-1 h-5 w-px bg-gray-300 dark:bg-gray-600" />
<!-- Undo -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 disabled:opacity-30 dark:text-gray-300 dark:hover:bg-gray-600"
title="Undo (Ctrl+Z)"
:disabled="!editor?.can().undo()"
@click="editor?.chain().focus().undo().run()"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6M3 10l6-6" />
</svg>
</button>
<!-- Redo -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 disabled:opacity-30 dark:text-gray-300 dark:hover:bg-gray-600"
title="Redo (Ctrl+Y)"
:disabled="!editor?.can().redo()"
@click="editor?.chain().focus().redo().run()"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 10H11a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6" />
</svg>
</button>
</div>
<!-- Close heading dropdown on outside click -->
<div v-if="headingDropdownOpen" class="fixed inset-0 z-0" @click="headingDropdownOpen = false" />
<!-- Editor content -->
<EditorContent
:editor="editor"
class="min-h-[200px] px-3 py-2 text-sm text-gray-900 dark:text-gray-100 [&_.ProseMirror]:min-h-[180px] [&_.ProseMirror]:outline-none [&_.ProseMirror]:prose [&_.ProseMirror]:dark:prose-invert [&_.ProseMirror]:prose-sm [&_.ProseMirror]:max-w-none"
/>
</div>
</template>

View file

@ -2,6 +2,7 @@
import { ref } from "vue";
import type { Segment } from "@/types/segment";
import AssetUploader from "@/components/admin/AssetUploader.vue";
import RichTextEditor from "@/components/admin/RichTextEditor.vue";
const props = defineProps<{
segment: Segment;
@ -17,11 +18,21 @@ const title = ref(props.segment.title);
const content = ref(props.segment.content);
const metadata = ref<Record<string, unknown>>({ ...props.segment.metadata });
const showDeleteConfirm = ref(false);
const teamMembers = ref<{ name: string; student_number: string }[]>(
Array.isArray(props.segment.metadata.members)
? [...(props.segment.metadata.members as { name: string; student_number: string }[])]
: [],
);
const newMemberName = ref("");
const newMemberNumber = ref("");
function handleSave() {
const updates: { title?: string; content?: string; metadata?: Record<string, unknown> } = {};
if (title.value !== props.segment.title) updates.title = title.value;
if (content.value !== props.segment.content) updates.content = content.value;
if (props.segment.type === "team") {
metadata.value = { ...metadata.value, members: teamMembers.value };
}
if (JSON.stringify(metadata.value) !== JSON.stringify(props.segment.metadata)) {
updates.metadata = metadata.value;
}
@ -71,20 +82,15 @@ const assetAcceptMap: Record<string, string> = {
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label>
<!-- Markdown: textarea -->
<textarea
v-if="segment.type === 'markdown'"
v-model="content"
rows="8"
class="w-full rounded border border-slate-300 px-3 py-2 font-mono text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
/>
<!-- Markdown: rich text editor -->
<RichTextEditor v-if="segment.type === 'markdown'" v-model="content" />
<!-- Iframe / Link: URL input -->
<!-- Iframe: URL input -->
<input
v-else-if="segment.type === 'iframe' || segment.type === 'link'"
v-else-if="segment.type === 'iframe'"
v-model="content"
type="url"
:placeholder="segment.type === 'link' ? 'https://external-site.com' : 'https://...'"
placeholder="https://..."
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
/>
@ -125,10 +131,47 @@ const assetAcceptMap: Record<string, string> = {
</div>
<AssetUploader
accept="image/*"
label="Upload gallery image"
:multiple="true"
label="Click to upload photos, or drag and drop"
@uploaded="handleGalleryImageUploaded"
/>
</div>
<!-- Team: member list -->
<div v-else-if="segment.type === 'team'">
<div v-if="teamMembers.length > 0" class="mb-3 space-y-1">
<div
v-for="(member, i) in teamMembers"
:key="i"
class="flex items-center justify-between rounded bg-white px-3 py-1.5 text-sm dark:bg-slate-700"
>
<span class="text-slate-900 dark:text-slate-100">{{ member.name }}</span>
<span class="font-mono text-slate-500 dark:text-slate-400">{{ member.student_number }}</span>
<button class="ml-2 shrink-0 text-red-400 hover:text-red-600" @click="teamMembers = teamMembers.filter((_, j) => j !== i)">&times;</button>
</div>
</div>
<div class="flex gap-2">
<input
v-model="newMemberName"
type="text"
placeholder="Name"
class="min-w-0 flex-1 rounded border border-slate-300 px-3 py-1.5 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
/>
<input
v-model="newMemberNumber"
type="text"
placeholder="Student number"
class="w-36 rounded border border-slate-300 px-3 py-1.5 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
/>
<button
:disabled="!newMemberName.trim() || !newMemberNumber.trim()"
class="rounded bg-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-300 disabled:opacity-50 dark:bg-slate-600 dark:text-slate-200 dark:hover:bg-slate-500"
@click="teamMembers = [...teamMembers, { name: newMemberName.trim(), student_number: newMemberNumber.trim() }]; newMemberName = ''; newMemberNumber = ''"
>
Add
</button>
</div>
</div>
</div>
<!-- Actions -->

View file

@ -1,135 +1,175 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import type { Segment } from "@/types/segment";
import { ref, nextTick, onMounted, watch, watchEffect } from "vue";
import { useRouter, useRoute } from "vue-router";
import Sidebar from "@/components/layout/Sidebar.vue";
import { usePages } from "@/composables/usePages";
import { useAdmin } from "@/composables/useAdmin";
import { useDarkMode } from "@/composables/useDarkMode";
import SidebarNav from "@/components/layout/SidebarNav.vue";
import MobileHeader from "@/components/layout/MobileHeader.vue";
import TokenPrompt from "@/components/admin/TokenPrompt.vue";
import AdminToolbar from "@/components/admin/AdminToolbar.vue";
import type { SiteInfo } from "@/types/segment";
import logoUrl from "@/assets/logo.png";
defineProps<{
segments: Segment[];
siteTitle: string;
activeId: string | null;
}>();
const router = useRouter();
const route = useRoute();
const { pages, fetchPages } = usePages();
const { isAdmin, authHeaders } = useAdmin();
const emit = defineEmits<{
navigate: [id: string];
"add-segment": [];
logout: [];
}>();
const mobileNavOpen = ref(false);
const siteTitle = ref("Untitled Site");
const editingSiteTitle = ref(false);
const siteTitleInput = ref("");
const { isAdmin } = useAdmin();
const { isDark, toggle: toggleDark } = useDarkMode();
const showBackToTop = ref(false);
function handleScroll() {
showBackToTop.value = window.scrollY > 300;
async function loadSiteTitle() {
try {
const res = await fetch("/api/site/");
if (res.ok) {
const data = (await res.json()) as SiteInfo;
siteTitle.value = data.title;
}
} catch { /* Silently fail */ }
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
function startEditTitle() {
if (!isAdmin.value) return;
siteTitleInput.value = siteTitle.value;
editingSiteTitle.value = true;
nextTick(() => { document.getElementById("header-title-input")?.focus(); });
}
function handleNavigate(id: string) {
emit("navigate", id);
async function saveSiteTitle() {
const trimmed = siteTitleInput.value.trim();
if (trimmed && trimmed !== siteTitle.value) {
try {
const res = await fetch("/api/site/", {
method: "PUT",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ title: trimmed }),
});
if (res.ok) siteTitle.value = trimmed;
} catch { /* Silently fail */ }
}
editingSiteTitle.value = false;
}
onMounted(() => {
window.addEventListener("scroll", handleScroll, { passive: true });
// Dynamic browser tab title
watchEffect(() => {
const slug = route.params.slug as string | undefined;
const page = slug ? pages.value.find((p) => p.slug === slug) : undefined;
document.title = page ? `${page.name} | ${siteTitle.value}` : siteTitle.value;
});
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
// Redirect "/" to first page once pages are loaded
watch(pages, (loaded) => {
if (loaded.length > 0 && (route.path === "/" || route.path === "")) {
void router.replace({ name: "page", params: { slug: loaded[0].slug } });
}
});
onMounted(async () => {
await Promise.all([loadSiteTitle(), fetchPages()]);
if (pages.value.length > 0 && (route.path === "/" || route.path === "")) {
void router.replace({ name: "page", params: { slug: pages.value[0].slug } });
}
});
</script>
<template>
<div class="min-h-screen bg-slate-50 dark:bg-slate-900">
<!-- Sticky header: title bar + nav -->
<div class="sticky top-0 z-20">
<!-- Title bar -->
<header class="flex items-center justify-between bg-primary-300 px-6 py-4">
<h1 class="text-xl font-bold text-slate-900">{{ siteTitle }}</h1>
<div class="flex items-center gap-2">
<!-- Dark mode toggle -->
<button
class="rounded p-1.5 text-slate-700 transition-colors hover:bg-primary-400/30"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleDark"
>
<!-- Sun icon (shown in dark mode) -->
<svg v-if="isDark" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<!-- Moon icon (shown in light mode) -->
<svg v-else class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
<TokenPrompt />
</div>
</header>
<!-- Desktop tab nav -->
<div class="hidden md:block">
<SidebarNav
:segments="segments"
:site-title="siteTitle"
:active-id="activeId"
@navigate="handleNavigate"
/>
</div>
<!-- Mobile header with hamburger -->
<div class="md:hidden">
<MobileHeader
:site-title="siteTitle"
:segments="segments"
:active-id="activeId"
@navigate="handleNavigate"
/>
</div>
</div>
<!-- Admin toolbar -->
<AdminToolbar
v-if="isAdmin"
@add-segment="$emit('add-segment')"
@logout="$emit('logout')"
/>
<!-- Main content -->
<main class="mx-auto max-w-6xl px-6 py-8">
<slot />
</main>
<!-- Back to top button -->
<Transition name="fade-up">
<div class="flex h-screen flex-col overflow-hidden bg-white dark:bg-gray-900">
<!-- Full-width yellow header -->
<header class="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-3 border-b border-primary-400 bg-primary-300 px-4">
<!-- Hamburger (mobile only) -->
<button
v-if="showBackToTop"
class="fixed right-6 bottom-6 z-30 rounded-full bg-primary-300 p-3 shadow-lg transition-colors hover:bg-primary-400"
title="Back to top"
@click="scrollToTop"
class="shrink-0 rounded p-1.5 text-gray-700 transition-colors hover:bg-primary-400 md:hidden"
aria-label="Open navigation"
@click="mobileNavOpen = true"
>
<svg class="h-5 w-5 text-slate-900" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</Transition>
<!-- Logo -->
<img :src="logoUrl" alt="Logo" class="h-8 w-auto shrink-0" />
<!-- Site title -->
<form v-if="editingSiteTitle" @submit.prevent="saveSiteTitle">
<input
id="header-title-input"
v-model="siteTitleInput"
type="text"
class="rounded border border-primary-500 bg-primary-200/60 px-2 py-0.5 text-base font-bold text-gray-900 focus:outline-none"
@blur="saveSiteTitle"
@keydown.escape="editingSiteTitle = false"
/>
</form>
<div
v-else
class="group flex min-w-0 items-center gap-1"
:class="isAdmin ? 'cursor-pointer' : ''"
:title="isAdmin ? 'Click to rename' : undefined"
@click="startEditTitle"
>
<span class="truncate text-base font-bold text-gray-900">{{ siteTitle }}</span>
<svg
v-if="isAdmin"
class="h-3 w-3 shrink-0 text-gray-600 opacity-0 transition-opacity group-hover:opacity-100"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
</header>
<!-- Body: sidebar + scrollable content -->
<div class="flex min-h-0 flex-1">
<!-- Mobile sidebar overlay -->
<Transition name="sidebar-fade">
<div
v-if="mobileNavOpen"
class="fixed inset-0 z-40 bg-black/40 md:hidden"
@click="mobileNavOpen = false"
/>
</Transition>
<!-- Mobile sidebar drawer -->
<Transition name="sidebar-slide">
<div
v-show="mobileNavOpen"
class="fixed inset-y-0 left-0 z-50 md:hidden"
>
<Sidebar @close="mobileNavOpen = false" />
</div>
</Transition>
<!-- Desktop sidebar (always visible) -->
<div class="hidden md:flex md:shrink-0">
<Sidebar @close="() => {}" />
</div>
<!-- Scrollable content -->
<main class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-4xl px-6 py-10">
<slot />
</div>
</main>
</div>
</div>
</template>
<style scoped>
.fade-up-enter-active,
.fade-up-leave-active {
transition: opacity 200ms ease, transform 200ms ease;
.sidebar-fade-enter-active,
.sidebar-fade-leave-active {
transition: opacity 200ms ease;
}
.fade-up-enter-from,
.fade-up-leave-to {
.sidebar-fade-enter-from,
.sidebar-fade-leave-to {
opacity: 0;
transform: translateY(8px);
}
.sidebar-slide-enter-active,
.sidebar-slide-leave-active {
transition: transform 250ms ease;
}
.sidebar-slide-enter-from,
.sidebar-slide-leave-to {
transform: translateX(-100%);
}
</style>

View file

@ -1,129 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import type { Segment } from "@/types/segment";
defineProps<{
siteTitle: string;
segments: Segment[];
activeId: string | null;
}>();
const emit = defineEmits<{
navigate: [id: string];
}>();
const open = ref(false);
function toggle() {
open.value = !open.value;
}
function handleNav(id: string) {
open.value = false;
emit("navigate", id);
}
function handleBackdropClick() {
open.value = false;
}
</script>
<template>
<!-- Mobile nav bar -->
<div class="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
<span class="text-sm font-medium text-slate-500 dark:text-slate-400">Sections</span>
<button
class="text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
aria-label="Open navigation"
@click="toggle"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
<!-- Backdrop -->
<Transition name="fade">
<div
v-if="open"
class="fixed inset-0 z-40 bg-black/40"
@click="handleBackdropClick"
/>
</Transition>
<!-- Dropdown panel -->
<Transition name="dropdown">
<div
v-if="open"
class="fixed top-0 right-0 left-0 z-50"
>
<div class="bg-white shadow-lg dark:bg-slate-800">
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-700">
<span class="text-sm font-semibold text-slate-900 dark:text-slate-100">{{ siteTitle }}</span>
<button
class="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
aria-label="Close navigation"
@click="toggle"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<ul class="max-h-80 overflow-y-auto py-1">
<li v-for="segment in segments" :key="segment.id">
<!-- External link segments -->
<a
v-if="segment.type === 'link'"
:href="segment.content"
target="_blank"
rel="noopener noreferrer"
class="flex w-full items-center gap-1 px-4 py-2.5 text-left text-sm text-slate-600 transition-colors duration-150 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100"
@click="open = false"
>
{{ segment.title }}
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<!-- Regular segment nav buttons -->
<button
v-else
class="w-full px-4 py-2.5 text-left text-sm transition-colors duration-150"
:class="
activeId === segment.id
? 'bg-primary-50 font-medium text-slate-900 dark:bg-primary-700/20 dark:text-slate-100'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100'
"
@click="handleNav(segment.id)"
>
{{ segment.title }}
</button>
</li>
</ul>
</div>
</div>
</Transition>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 200ms ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: transform 200ms ease, opacity 200ms ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
transform: translateY(-100%);
opacity: 0;
}
</style>

View file

@ -0,0 +1,411 @@
<script setup lang="ts">
import { ref, watch, nextTick } from "vue";
import { useRouter, useRoute } from "vue-router";
import draggable from "vuedraggable";
import type { Page } from "@/types/segment";
import { useAdmin } from "@/composables/useAdmin";
import { usePages } from "@/composables/usePages";
import { useDarkMode } from "@/composables/useDarkMode";
const emit = defineEmits<{
close: [];
}>();
const router = useRouter();
const route = useRoute();
const { isAdmin, verifying, login, logout } = useAdmin();
const { pages, createPage, updatePage, deletePage, reorderPages, toggleHidden } = usePages();
const { isDark, toggle: toggleDark } = useDarkMode();
// --- Page list drag ---
const localPages = ref<Page[]>(pages.value.filter((p) => !p.is_system));
watch(pages, (val) => { localPages.value = val.filter((p) => !p.is_system); });
function onPageDragEnd() {
void reorderPages(localPages.value.map((p) => p.id));
}
// --- Page rename ---
const renamingPageId = ref<string | null>(null);
const renameInput = ref("");
function startRename(page: Page) {
renamingPageId.value = page.id;
renameInput.value = page.name;
nextTick(() => { document.getElementById(`rename-input-${page.id}`)?.focus(); });
}
async function saveRename(page: Page) {
const trimmed = renameInput.value.trim();
if (trimmed && trimmed !== page.name) {
const newSlug = trimmed.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
await updatePage(page.id, { name: trimmed, slug: newSlug });
if (route.params.slug === page.slug) {
await router.replace({ name: "page", params: { slug: newSlug } });
}
}
renamingPageId.value = null;
}
// --- Delete page ---
async function handleDelete(page: Page) {
const result = await deletePage(page.id);
if (result.ok && route.params.slug === page.slug) {
const remaining = pages.value.filter((p) => p.id !== page.id);
if (remaining.length > 0) {
await router.push({ name: "page", params: { slug: remaining[0].slug } });
} else {
await router.push("/");
}
}
}
// --- Add page ---
const showAddPage = ref(false);
const newPageName = ref("");
const addPageError = ref<string | null>(null);
const addPageLoading = ref(false);
function openAddPage() {
showAddPage.value = true;
newPageName.value = "";
addPageError.value = null;
nextTick(() => { document.getElementById("new-page-name")?.focus(); });
}
async function submitAddPage() {
const name = newPageName.value.trim();
if (!name) return;
addPageLoading.value = true;
addPageError.value = null;
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const page = await createPage(name, slug);
addPageLoading.value = false;
if (page) {
showAddPage.value = false;
await router.push({ name: "page", params: { slug: page.slug } });
} else {
addPageError.value = "Could not create page. The name might already be taken.";
}
}
// --- Admin login (inline) ---
const showLoginInput = ref(false);
const loginTokenInput = ref("");
const loginError = ref(false);
async function handleLogin() {
loginError.value = false;
const success = await login(loginTokenInput.value);
if (success) {
showLoginInput.value = false;
loginTokenInput.value = "";
} else {
loginError.value = true;
}
}
</script>
<template>
<nav class="flex h-full w-60 flex-col border-r border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-700">
<!-- Pages list (scrollable) -->
<div class="flex-1 overflow-y-auto py-2">
<!-- Pinned Home page (always at top, no actions) -->
<template v-if="pages.find(p => p.slug === 'home')">
<div class="px-2 py-0.5">
<router-link
:to="{ name: 'page', params: { slug: 'home' } }"
class="flex items-center rounded-md px-2 py-2 text-sm transition-colors"
:class="
$route.params.slug === 'home'
? 'border-l-2 border-primary-400 bg-primary-50 font-medium text-gray-900 dark:border-primary-400 dark:bg-gray-600 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white'
"
@click="$emit('close')"
>
<span v-if="isAdmin" class="w-3.5 shrink-0" />
<span class="flex-1 truncate">{{ pages.find(p => p.slug === 'home')?.name }}</span>
</router-link>
</div>
</template>
<!-- Admin draggable user pages (non-system) -->
<draggable
v-if="isAdmin"
v-model="localPages"
item-key="id"
handle=".page-drag-handle"
:animation="150"
@end="onPageDragEnd"
>
<template #item="{ element: page }: { element: Page }">
<div class="group relative px-2 py-0.5">
<!-- Rename input -->
<form v-if="renamingPageId === page.id" @submit.prevent="saveRename(page)">
<input
:id="`rename-input-${page.id}`"
v-model="renameInput"
type="text"
class="w-full rounded border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
@blur="saveRename(page)"
@keydown.escape="renamingPageId = null"
/>
</form>
<!-- Normal page row -->
<router-link
v-else
:to="{ name: 'page', params: { slug: page.slug } }"
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm transition-colors"
:class="[
$route.params.slug === page.slug
? 'border-l-2 border-primary-400 bg-primary-50 font-medium text-gray-900 dark:border-primary-400 dark:bg-gray-600 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white',
page.is_hidden ? 'opacity-50' : '',
]"
@click="$emit('close')"
>
<!-- Drag handle -->
<span class="page-drag-handle shrink-0 cursor-grab text-gray-400 active:cursor-grabbing dark:text-gray-600">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
<circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
</svg>
</span>
<span class="flex-1 truncate">{{ page.name }}</span>
<span class="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100">
<!-- Hide/show toggle -->
<button
class="rounded p-0.5 transition-colors"
:class="page.is_hidden ? 'text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' : 'text-gray-400 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300'"
:title="page.is_hidden ? 'Show page' : 'Hide page'"
@click.prevent="toggleHidden(page.id, !page.is_hidden)"
>
<svg v-if="page.is_hidden" class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
<svg v-else class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
<!-- Rename -->
<button
class="rounded p-0.5 text-gray-400 hover:text-gray-700 dark:text-gray-600 dark:hover:text-primary-400"
title="Rename page"
@click.prevent="startRename(page)"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<!-- Delete -->
<button
class="rounded p-0.5 text-gray-400 hover:text-red-500 dark:text-gray-600 dark:hover:text-red-400"
title="Delete page"
@click.prevent="handleDelete(page)"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</span>
</router-link>
</div>
</template>
</draggable>
<!-- System pages shown after draggable list (admin), excluding home -->
<div v-if="isAdmin" v-for="page in pages.filter(p => p.is_system && p.slug !== 'home')" :key="page.id" class="group px-2 py-0.5">
<router-link
:to="{ name: 'page', params: { slug: page.slug } }"
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm transition-colors"
:class="[
$route.params.slug === page.slug
? 'border-l-2 border-primary-400 bg-primary-50 font-medium text-gray-900 dark:border-primary-400 dark:bg-gray-600 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white',
page.is_hidden ? 'opacity-50' : '',
]"
@click="$emit('close')"
>
<span class="w-3.5 shrink-0" />
<span class="flex-1 truncate">{{ page.name }}</span>
<span class="flex shrink-0 items-center opacity-0 group-hover:opacity-100">
<button
class="rounded p-0.5 transition-colors"
:class="page.is_hidden ? 'text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' : 'text-gray-400 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300'"
:title="page.is_hidden ? 'Show page' : 'Hide page'"
@click.prevent="toggleHidden(page.id, !page.is_hidden)"
>
<svg v-if="page.is_hidden" class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
<svg v-else class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</span>
</router-link>
</div>
<!-- Read-only pages list (non-admin) -->
<template v-else-if="!isAdmin">
<!-- Home always shown first -->
<template v-if="!pages.find(p => p.slug === 'home')">
<!-- home already rendered above via pinned template -->
</template>
<!-- Other non-system, non-hidden pages -->
<div v-for="page in pages.filter(p => !p.is_hidden && !p.is_system)" :key="page.id" class="px-2 py-0.5">
<router-link
:to="{ name: 'page', params: { slug: page.slug } }"
class="flex items-center rounded-md px-3 py-2 text-sm transition-colors"
:class="
$route.params.slug === page.slug
? 'border-l-2 border-primary-400 bg-primary-50 font-medium text-gray-900 dark:border-primary-400 dark:bg-gray-600 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white'
"
@click="$emit('close')"
>
{{ page.name }}
</router-link>
</div>
<!-- System pages (e.g. team) that are not hidden, excluding home -->
<div v-for="page in pages.filter(p => !p.is_hidden && p.is_system && p.slug !== 'home')" :key="`sys-${page.id}`" class="px-2 py-0.5">
<router-link
:to="{ name: 'page', params: { slug: page.slug } }"
class="flex items-center rounded-md px-3 py-2 text-sm transition-colors"
:class="
$route.params.slug === page.slug
? 'border-l-2 border-primary-400 bg-primary-50 font-medium text-gray-900 dark:border-primary-400 dark:bg-gray-600 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white'
"
@click="$emit('close')"
>
{{ page.name }}
</router-link>
</div>
</template>
</div>
<!-- Add page (admin only) -->
<div v-if="isAdmin" class="border-t border-gray-300 p-3 dark:border-gray-600">
<div v-if="showAddPage">
<form @submit.prevent="submitAddPage">
<input
id="new-page-name"
v-model="newPageName"
type="text"
placeholder="Page name"
class="mb-1.5 w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
@keydown.escape="showAddPage = false"
/>
<p v-if="addPageError" class="mb-1.5 text-xs text-red-700 dark:text-red-400">{{ addPageError }}</p>
<div class="flex gap-2">
<button
type="submit"
:disabled="!newPageName.trim() || addPageLoading"
class="flex-1 rounded bg-primary-200 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-primary-300 disabled:opacity-50"
>
{{ addPageLoading ? "Creating..." : "Create page" }}
</button>
<button
type="button"
class="rounded px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
@click="showAddPage = false"
>
Cancel
</button>
</div>
</form>
</div>
<button
v-else
class="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-gray-300 px-3 py-2 text-sm text-gray-500 transition-colors hover:border-primary-400 hover:bg-primary-50 hover:text-primary-700 dark:border-gray-600 dark:text-gray-400 dark:hover:border-primary-400 dark:hover:text-primary-400"
@click="openAddPage"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add new page
</button>
</div>
<!-- Footer: dark mode + auth -->
<div class="border-t border-gray-300 px-3 py-2 dark:border-gray-600">
<div class="flex items-center justify-between">
<!-- Dark mode toggle (icon only) -->
<button
class="rounded-md p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-gray-200"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleDark"
>
<svg v-if="isDark" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
<!-- Logged-in admin: badge + logout -->
<div v-if="isAdmin" class="flex items-center gap-1.5">
<span class="rounded bg-gray-900 px-1.5 py-0.5 text-xs font-semibold text-primary-300 dark:bg-primary-400 dark:text-gray-900">Admin</span>
<button
class="rounded-md p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-red-500 dark:text-gray-500 dark:hover:bg-gray-600 dark:hover:text-red-400"
title="Log out"
@click="logout()"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
<!-- Not logged in: lock icon or inline login form -->
<div v-else>
<button
v-if="!showLoginInput"
class="rounded-md p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
title="Admin login"
@click="showLoginInput = true"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</button>
</div>
</div>
<!-- Inline login form (expands below the icon row) -->
<form v-if="!isAdmin && showLoginInput" class="mt-2 space-y-1.5" @submit.prevent="handleLogin">
<input
v-model="loginTokenInput"
type="password"
placeholder="Admin token"
autofocus
class="w-full rounded border px-2 py-1.5 text-sm text-gray-900 placeholder-gray-400 focus:outline-none dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
:class="loginError ? 'border-red-400 bg-red-50' : 'border-gray-300 dark:border-gray-600'"
/>
<p v-if="loginError" class="text-xs text-red-700 dark:text-red-400">Incorrect token.</p>
<div class="flex gap-2">
<button
type="submit"
:disabled="verifying || !loginTokenInput"
class="flex-1 rounded bg-primary-200 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-primary-300 disabled:opacity-50 dark:bg-primary-200 dark:text-gray-800 dark:hover:bg-primary-300"
>
{{ verifying ? "Checking..." : "Log in" }}
</button>
<button
type="button"
class="rounded px-2 py-1.5 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
@click="showLoginInput = false; loginTokenInput = ''; loginError = false"
>
&times;
</button>
</div>
</form>
</div>
</nav>
</template>

View file

@ -1,52 +0,0 @@
<script setup lang="ts">
import type { Segment } from "@/types/segment";
defineProps<{
segments: Segment[];
siteTitle: string;
activeId: string | null;
}>();
const emit = defineEmits<{
navigate: [id: string];
}>();
function handleClick(id: string) {
emit("navigate", id);
}
</script>
<template>
<nav class="border-b border-slate-200 bg-white px-6 dark:border-slate-700 dark:bg-slate-800">
<ul class="flex gap-1 overflow-x-auto">
<li v-for="segment in segments" :key="segment.id">
<!-- External link segments -->
<a
v-if="segment.type === 'link'"
:href="segment.content"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 whitespace-nowrap border-b-2 border-transparent px-3 py-2.5 text-sm font-medium text-slate-500 transition-colors duration-150 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
>
{{ segment.title }}
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<!-- Regular segment nav buttons -->
<button
v-else
class="whitespace-nowrap border-b-2 px-3 py-2.5 text-sm font-medium transition-colors duration-150"
:class="
activeId === segment.id
? 'border-primary-400 text-slate-900 dark:text-slate-100'
: 'border-transparent text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100'
"
@click="handleClick(segment.id)"
>
{{ segment.title }}
</button>
</li>
</ul>
</nav>
</template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from "vue";
import { ref, computed } from "vue";
import type { Segment } from "@/types/segment";
const props = defineProps<{
@ -8,21 +8,67 @@ const props = defineProps<{
const images = computed(() => {
const meta = props.segment.metadata;
if (Array.isArray(meta.images)) {
return meta.images as string[];
}
if (Array.isArray(meta.images)) return meta.images as string[];
return [];
});
const current = ref(0);
function prev() {
current.value = (current.value - 1 + images.value.length) % images.value.length;
}
function next() {
current.value = (current.value + 1) % images.value.length;
}
</script>
<template>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
<img
v-for="(src, index) in images"
:key="index"
:src="src"
:alt="`${segment.title} image ${index + 1}`"
class="aspect-square w-full rounded object-cover"
/>
<div v-if="images.length > 0" class="select-none">
<!-- Fixed-height image box with arrows overlaid inside it -->
<div class="relative flex h-[70vh] items-center justify-center overflow-hidden rounded bg-white dark:bg-gray-900">
<img
:src="images[current]"
:alt="`${segment.title} image ${current + 1}`"
class="h-full w-full object-contain"
/>
<!-- Arrows (only when multiple images) -->
<template v-if="images.length > 1">
<button
type="button"
class="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/40 p-1.5 text-white transition-colors hover:bg-black/65"
aria-label="Previous image"
@click="prev"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/40 p-1.5 text-white transition-colors hover:bg-black/65"
aria-label="Next image"
@click="next"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</template>
</div>
<!-- Dot indicators below (only when multiple images) -->
<div v-if="images.length > 1" class="mt-3 flex justify-center gap-1.5">
<button
v-for="(_, i) in images"
:key="i"
type="button"
class="h-2 rounded-full transition-all"
:class="i === current ? 'w-4 bg-gray-600 dark:bg-gray-300' : 'w-2 bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500'"
:aria-label="`Go to image ${i + 1}`"
@click="current = i"
/>
</div>
</div>
</template>

View file

@ -9,7 +9,7 @@ defineProps<{
<template>
<iframe
:src="segment.content"
class="h-[600px] w-full rounded border-none"
class="h-[85vh] w-full rounded border-none"
:title="segment.title"
/>
</template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { ref, watch } from "vue";
import type { Segment } from "@/types/segment";
import { useAdmin } from "@/composables/useAdmin";
import SegmentRenderer from "@/components/segments/SegmentRenderer.vue";
@ -10,9 +10,6 @@ const props = defineProps<{
segments: Segment[];
}>();
// Filter out link segments from content rendering (they only appear in nav)
const contentSegments = computed(() => props.segments.filter((s) => s.type !== "link"));
const emit = defineEmits<{
save: [id: string, updates: { title?: string; content?: string; metadata?: Record<string, unknown> }];
delete: [id: string];
@ -22,14 +19,11 @@ const emit = defineEmits<{
const { isAdmin } = useAdmin();
const editingId = ref<string | null>(null);
// Local copy for draggable
const localSegments = ref<Segment[]>([...props.segments]);
watch(
() => props.segments,
(val) => {
localSegments.value = [...val];
},
(val) => { localSegments.value = [...val]; },
{ deep: true }
);
@ -51,89 +45,100 @@ function handleDelete(id: string) {
}
function onDragEnd() {
const ids = localSegments.value.map((s) => s.id);
emit("reorder", ids);
emit("reorder", localSegments.value.map((s) => s.id));
}
</script>
<template>
<div class="space-y-6 p-6">
<!-- Admin mode: draggable with edit controls -->
<draggable
v-if="isAdmin"
v-model="localSegments"
item-key="id"
handle=".drag-handle"
class="space-y-6"
@end="onDragEnd"
>
<template #item="{ element: segment }: { element: Segment }">
<section
:id="segment.id"
class="rounded-lg bg-white p-6 shadow-md dark:bg-slate-800 dark:shadow-slate-950/30"
>
<div class="flex items-start gap-3">
<!-- Drag handle: grip dots -->
<button
class="drag-handle mt-1 cursor-grab text-slate-300 transition-colors hover:text-primary-300 active:cursor-grabbing dark:text-slate-600 dark:hover:text-primary-400"
title="Drag to reorder"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="6" r="1.5" />
<circle cx="15" cy="6" r="1.5" />
<circle cx="9" cy="12" r="1.5" />
<circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="18" r="1.5" />
<circle cx="15" cy="18" r="1.5" />
</svg>
</button>
<!-- Admin: draggable flowing sections -->
<draggable
v-if="isAdmin"
v-model="localSegments"
item-key="id"
handle=".drag-handle"
:animation="150"
:force-fallback="false"
ghost-class="drag-ghost"
chosen-class="drag-chosen"
class="segment-list"
@end="onDragEnd"
>
<template #item="{ element: segment }: { element: Segment }">
<section :id="segment.id" class="py-8 first:pt-0">
<div class="flex items-start gap-3">
<!-- Drag handle -->
<button
class="drag-handle mt-1 shrink-0 cursor-grab text-gray-300 transition-colors hover:text-primary-300 active:cursor-grabbing dark:text-gray-700 dark:hover:text-primary-400"
title="Drag to reorder"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
<circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
</svg>
</button>
<div class="min-w-0 flex-1">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100">{{ segment.title }}</h2>
<!-- Edit pencil icon -->
<button
class="rounded p-1 text-slate-300 transition-colors hover:text-primary-300 dark:text-slate-600 dark:hover:text-primary-400"
title="Edit segment"
@click="toggleEdit(segment.id)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
</div>
<!-- Link segments show URL instead of content -->
<p v-if="segment.type === 'link'" class="text-sm text-slate-400">
External link: <a :href="segment.content" target="_blank" rel="noopener noreferrer" class="text-primary-500 underline">{{ segment.content }}</a>
</p>
<SegmentRenderer v-else :segment="segment" />
<!-- Inline editor -->
<SegmentEditor
v-if="editingId === segment.id"
:segment="segment"
@save="(updates) => handleSave(segment.id, updates)"
@cancel="editingId = null"
@delete="handleDelete(segment.id)"
/>
<div class="min-w-0 flex-1">
<div class="mb-3 flex items-center justify-between gap-2">
<h2 v-if="segment.title" class="text-xl font-semibold text-gray-900 dark:text-gray-100">{{ segment.title }}</h2>
<span v-else class="flex-1" />
<button
class="shrink-0 rounded p-1.5 text-gray-300 transition-colors hover:bg-gray-100 hover:text-primary-400 dark:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-primary-400"
:title="editingId === segment.id ? 'Close editor' : 'Edit section'"
@click="toggleEdit(segment.id)"
>
<svg v-if="editingId !== segment.id" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</section>
</template>
</draggable>
<!-- Read-only mode: plain rendering (link segments excluded) -->
<template v-else>
<section
v-for="segment in contentSegments"
:key="segment.id"
:id="segment.id"
class="rounded-lg bg-white p-6 shadow-md dark:bg-slate-800 dark:shadow-slate-950/30"
>
<h2 class="mb-4 text-xl font-semibold text-slate-900 dark:text-slate-100">{{ segment.title }}</h2>
<SegmentRenderer :segment="segment" />
<!-- Inline editor replaces content -->
<SegmentEditor
v-if="editingId === segment.id"
:segment="segment"
@save="(updates) => handleSave(segment.id, updates)"
@cancel="editingId = null"
@delete="handleDelete(segment.id)"
/>
<SegmentRenderer v-else :segment="segment" />
</div>
</div>
</section>
</template>
</draggable>
<!-- Read-only: flowing sections -->
<div v-else class="segment-list">
<section
v-for="segment in segments"
:key="segment.id"
:id="segment.id"
class="py-8 first:pt-0"
>
<h2 v-if="segment.title" class="mb-3 text-xl font-semibold text-gray-900 dark:text-gray-100">{{ segment.title }}</h2>
<SegmentRenderer :segment="segment" />
</section>
</div>
</template>
<style scoped>
.segment-list > * + * {
border-top: 2px solid transparent;
border-image: linear-gradient(to right, transparent, #d1d5db 20%, #d1d5db 80%, transparent) 1;
}
:global(.dark) .segment-list > * + * {
border-image: linear-gradient(to right, transparent, #374151 20%, #374151 80%, transparent) 1;
}
.drag-ghost {
opacity: 0.4;
}
.drag-chosen {
box-shadow: 0 4px 16px rgb(0 0 0 / 0.1);
}
</style>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Segment } from "@/types/segment";
const props = defineProps<{
segment: Segment;
}>();
interface TeamMember {
name: string;
student_number: string;
}
const members = computed<TeamMember[]>(() => {
const raw = props.segment.metadata.members;
return Array.isArray(raw) ? (raw as TeamMember[]) : [];
});
</script>
<template>
<ul class="divide-y divide-slate-200 dark:divide-slate-700">
<li
v-for="(member, i) in members"
:key="i"
class="flex items-center justify-between py-2 text-sm"
>
<span class="font-medium text-slate-900 dark:text-slate-100">{{ member.name }}</span>
<span class="font-mono text-slate-500 dark:text-slate-400">{{ member.student_number }}</span>
</li>
</ul>
</template>

View file

@ -0,0 +1,110 @@
import { ref } from "vue";
import type { Page } from "@/types/segment";
import { useAdmin } from "@/composables/useAdmin";
const pages = ref<Page[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
export function usePages() {
const { authHeaders } = useAdmin();
async function fetchPages(): Promise<void> {
loading.value = true;
error.value = null;
try {
const res = await fetch("/api/pages/");
if (!res.ok) throw new Error(`Failed to fetch pages: ${res.status}`);
pages.value = (await res.json()) as Page[];
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
} finally {
loading.value = false;
}
}
async function createPage(name: string, slug: string): Promise<Page | null> {
try {
const res = await fetch("/api/pages/", {
method: "POST",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ name, slug }),
});
if (!res.ok) {
const data = (await res.json()) as { detail?: string };
throw new Error(data.detail ?? `Create failed: ${res.status}`);
}
const page = (await res.json()) as Page;
await fetchPages();
return page;
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
return null;
}
}
async function updatePage(id: string, data: { name?: string; slug?: string }): Promise<Page | null> {
try {
const res = await fetch(`/api/pages/${id}`, {
method: "PATCH",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const body = (await res.json()) as { detail?: string };
throw new Error(body.detail ?? `Update failed: ${res.status}`);
}
const page = (await res.json()) as Page;
await fetchPages();
return page;
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
return null;
}
}
async function deletePage(id: string): Promise<{ ok: boolean; error?: string }> {
try {
const res = await fetch(`/api/pages/${id}`, {
method: "DELETE",
headers: authHeaders.value,
});
if (!res.ok) {
const body = (await res.json()) as { detail?: string };
return { ok: false, error: body.detail ?? `Delete failed: ${res.status}` };
}
await fetchPages();
return { ok: true };
} catch (e) {
const msg = e instanceof Error ? e.message : "Unknown error";
return { ok: false, error: msg };
}
}
async function toggleHidden(id: string, hidden: boolean): Promise<void> {
try {
await fetch(`/api/pages/${id}`, {
method: "PATCH",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ is_hidden: hidden }),
});
await fetchPages();
} catch { /* Silently fail */ }
}
async function reorderPages(ids: string[]): Promise<void> {
try {
const res = await fetch("/api/pages/reorder", {
method: "PUT",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ page_ids: ids }),
});
if (!res.ok) throw new Error(`Reorder failed: ${res.status}`);
pages.value = (await res.json()) as Page[];
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
}
}
return { pages, loading, error, fetchPages, createPage, updatePage, deletePage, reorderPages, toggleHidden };
}

View file

@ -1,9 +1,8 @@
import { ref, onMounted } from "vue";
import type { Segment, SiteInfo } from "@/types/segment";
import { ref } from "vue";
import type { Segment } from "@/types/segment";
export function useSegments() {
export function useSegments(pageId: string) {
const segments = ref<Segment[]>([]);
const siteTitle = ref("Handin");
const loading = ref(true);
const error = ref<string | null>(null);
@ -11,18 +10,10 @@ export function useSegments() {
loading.value = true;
error.value = null;
try {
const [siteRes, segmentsRes] = await Promise.all([
fetch("/api/site/"),
fetch("/api/segments/"),
]);
if (!siteRes.ok) throw new Error(`Site fetch failed: ${siteRes.status}`);
if (!segmentsRes.ok) throw new Error(`Segments fetch failed: ${segmentsRes.status}`);
const siteData = (await siteRes.json()) as SiteInfo;
const segmentsData = (await segmentsRes.json()) as Segment[];
siteTitle.value = siteData.title;
segments.value = segmentsData.sort((a, b) => a.sort_order - b.sort_order);
const res = await fetch(`/api/segments/?page_id=${encodeURIComponent(pageId)}`);
if (!res.ok) throw new Error(`Segments fetch failed: ${res.status}`);
const data = (await res.json()) as Segment[];
segments.value = data.sort((a, b) => a.sort_order - b.sort_order);
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
} finally {
@ -30,9 +21,5 @@ export function useSegments() {
}
}
onMounted(() => {
void refresh();
});
return { segments, siteTitle, loading, error, refresh };
return { segments, loading, error, refresh };
}

View file

@ -0,0 +1,57 @@
import { ref } from "vue";
import type { TeamMember } from "@/types/segment";
import { useAdmin } from "@/composables/useAdmin";
export function useTeamMembers() {
const { authHeaders } = useAdmin();
const members = ref<TeamMember[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchMembers(): Promise<void> {
loading.value = true;
error.value = null;
try {
const res = await fetch("/api/team/");
if (!res.ok) throw new Error(`Failed to fetch team: ${res.status}`);
members.value = (await res.json()) as TeamMember[];
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
} finally {
loading.value = false;
}
}
async function addMember(name: string, student_number: string): Promise<TeamMember | null> {
try {
const res = await fetch("/api/team/", {
method: "POST",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ name, student_number }),
});
if (!res.ok) throw new Error(`Add failed: ${res.status}`);
const member = (await res.json()) as TeamMember;
await fetchMembers();
return member;
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
return null;
}
}
async function deleteMember(id: string): Promise<boolean> {
try {
const res = await fetch(`/api/team/${id}`, {
method: "DELETE",
headers: authHeaders.value,
});
if (!res.ok) return false;
await fetchMembers();
return true;
} catch {
return false;
}
}
return { members, loading, error, fetchMembers, addMember, deleteMember };
}

View file

@ -1,5 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import "./style.css";
createApp(App).mount("#app");
createApp(App).use(router).mount("#app");

View file

@ -0,0 +1,22 @@
import { createRouter, createWebHistory } from "vue-router";
import PageView from "@/views/PageView.vue";
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
redirect: () => {
// Will be redirected to first page after pages load
return "/";
},
},
{
path: "/:slug",
name: "page",
component: PageView,
},
],
});
export default router;

View file

@ -4,6 +4,7 @@
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* Brand yellow — unchanged */
--color-primary-50: #fffbeb;
--color-primary-100: #fff3c4;
--color-primary-200: #fce588;
@ -12,4 +13,17 @@
--color-primary-500: #cc9900;
--color-primary-600: #997300;
--color-primary-700: #664d00;
/* Soft dark gray palette — used throughout dark mode */
--color-gray-950: #0d0d0d;
--color-gray-900: #171717;
--color-gray-800: #1e1e1e;
--color-gray-700: #2a2a2a;
--color-gray-600: #3d3d3d;
--color-gray-500: #6b6b6b;
--color-gray-400: #9e9e9e;
--color-gray-300: #c2c2c2;
--color-gray-200: #e0e0e0;
--color-gray-100: #f0f0f0;
--color-gray-50: #f8f8f8;
}

View file

@ -1,4 +1,4 @@
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery" | "link";
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery" | "team";
export interface Segment {
id: string;
@ -7,6 +7,7 @@ export interface Segment {
title: string;
content: string;
metadata: Record<string, unknown>;
page_id: string | null;
created_at: string;
updated_at: string;
}
@ -14,3 +15,23 @@ export interface Segment {
export interface SiteInfo {
title: string;
}
export interface Page {
id: string;
name: string;
slug: string;
sort_order: number;
is_system: boolean;
is_hidden: boolean;
created_at: string;
updated_at: string;
}
export interface TeamMember {
id: string;
name: string;
student_number: string;
sort_order: number;
created_at: string;
updated_at: string;
}

View file

@ -1,165 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { useSegments } from "@/composables/useSegments";
import { useAdmin } from "@/composables/useAdmin";
import type { SegmentType } from "@/types/segment";
import AppShell from "@/components/layout/AppShell.vue";
import SegmentList from "@/components/segments/SegmentList.vue";
import AddSegmentModal from "@/components/admin/AddSegmentModal.vue";
const { segments, siteTitle, loading, error, refresh } = useSegments();
const { authHeaders, logout } = useAdmin();
const activeId = ref<string | null>(null);
const showAddModal = ref(false);
let observer: IntersectionObserver | null = null;
function handleNavigate(id: string) {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth" });
activeId.value = id;
}
}
async function handleCreateSegment(data: {
type: SegmentType;
title: string;
content: string;
metadata?: Record<string, unknown>;
}) {
const res = await fetch("/api/segments/", {
method: "POST",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
showAddModal.value = false;
await refresh();
}
}
async function handleSaveSegment(
id: string,
updates: { title?: string; content?: string; metadata?: Record<string, unknown> }
) {
if (Object.keys(updates).length === 0) return;
const res = await fetch(`/api/segments/${id}`, {
method: "PATCH",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (res.ok) {
await refresh();
}
}
async function handleDeleteSegment(id: string) {
const res = await fetch(`/api/segments/${id}`, {
method: "DELETE",
headers: authHeaders.value,
});
if (res.ok) {
await refresh();
}
}
async function handleReorder(ids: string[]) {
const res = await fetch("/api/segments/reorder", {
method: "PUT",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ segment_ids: ids }),
});
if (res.ok) {
await refresh();
}
}
function handleLogout() {
logout();
}
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
activeId.value = entry.target.id;
}
}
},
{ rootMargin: "-20% 0px -60% 0px" }
);
const mutObs = new MutationObserver(() => {
for (const seg of segments.value) {
const el = document.getElementById(seg.id);
if (el) observer?.observe(el);
}
});
mutObs.observe(document.body, { childList: true, subtree: true });
onUnmounted(() => {
observer?.disconnect();
mutObs.disconnect();
});
});
</script>
<template>
<AppShell
:segments="segments"
:site-title="siteTitle"
:active-id="activeId"
@navigate="handleNavigate"
@add-segment="showAddModal = true"
@logout="handleLogout"
>
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-32">
<svg
class="h-10 w-10 animate-spin text-primary-300"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
<!-- Error state -->
<div v-else-if="error" class="flex items-center justify-center py-32">
<p class="text-lg text-red-500">{{ error }}</p>
</div>
<!-- Empty state -->
<div v-else-if="segments.length === 0" class="flex flex-col items-center justify-center py-32 text-slate-400 dark:text-slate-600">
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p class="text-lg">No content yet</p>
</div>
<!-- Segment list -->
<SegmentList
v-else
:segments="segments"
@save="handleSaveSegment"
@delete="handleDeleteSegment"
@reorder="handleReorder"
/>
</AppShell>
<!-- Add segment modal -->
<AddSegmentModal
v-if="showAddModal"
@create="handleCreateSegment"
@cancel="showAddModal = false"
/>
</template>

View file

@ -0,0 +1,182 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { useRoute } from "vue-router";
import { usePages } from "@/composables/usePages";
import { useAdmin } from "@/composables/useAdmin";
import type { Page, Segment, SegmentType } from "@/types/segment";
import SegmentList from "@/components/segments/SegmentList.vue";
import AddSegmentModal from "@/components/admin/AddSegmentModal.vue";
import TeamPage from "@/views/TeamPage.vue";
const route = useRoute();
const { pages } = usePages();
const { authHeaders, isAdmin } = useAdmin();
const currentPage = ref<Page | null>(null);
const segments = ref<Segment[]>([]);
const loading = ref(false);
const segmentError = ref<string | null>(null);
const showAddModal = ref(false);
const pageNotFound = ref(false);
async function fetchSegments(pageId: string) {
loading.value = true;
segmentError.value = null;
try {
const res = await fetch(`/api/segments/?page_id=${encodeURIComponent(pageId)}`);
if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
const data = (await res.json()) as Segment[];
segments.value = data.sort((a, b) => a.sort_order - b.sort_order);
} catch (e) {
segmentError.value = e instanceof Error ? e.message : "Unknown error";
} finally {
loading.value = false;
}
}
function resolvePage(slug: string) {
if (!pages.value.length) return;
pageNotFound.value = false;
const found = pages.value.find((p) => p.slug === slug);
if (found) {
currentPage.value = found;
void fetchSegments(found.id);
} else {
currentPage.value = null;
pageNotFound.value = true;
}
}
watch(() => route.params.slug as string, (slug) => { resolvePage(slug); }, { immediate: true });
watch(pages, () => { resolvePage(route.params.slug as string); });
async function handleCreateSegment(data: {
type: SegmentType;
title: string;
content: string;
metadata?: Record<string, unknown>;
}) {
if (!currentPage.value) return;
const res = await fetch("/api/segments/", {
method: "POST",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ ...data, page_id: currentPage.value.id }),
});
if (res.ok) {
showAddModal.value = false;
await fetchSegments(currentPage.value.id);
}
}
async function handleSaveSegment(
id: string,
updates: { title?: string; content?: string; metadata?: Record<string, unknown> }
) {
if (!currentPage.value || Object.keys(updates).length === 0) return;
const res = await fetch(`/api/segments/${id}`, {
method: "PATCH",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (res.ok) await fetchSegments(currentPage.value.id);
}
async function handleDeleteSegment(id: string) {
if (!currentPage.value) return;
const res = await fetch(`/api/segments/${id}`, {
method: "DELETE",
headers: authHeaders.value,
});
if (res.ok) await fetchSegments(currentPage.value.id);
}
async function handleReorder(ids: string[]) {
if (!currentPage.value) return;
const res = await fetch("/api/segments/reorder", {
method: "PUT",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ segment_ids: ids }),
});
if (res.ok) await fetchSegments(currentPage.value.id);
}
</script>
<template>
<!-- Not found -->
<div v-if="pageNotFound" class="flex flex-col items-center justify-center py-32 text-gray-400">
<p class="text-lg">Page not found.</p>
</div>
<!-- Waiting for pages to load -->
<div v-else-if="!currentPage && !pageNotFound" class="flex items-center justify-center py-32">
<svg class="h-8 w-8 animate-spin text-primary-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
<!-- Page content -->
<template v-else-if="currentPage">
<!-- Team system page -->
<TeamPage v-if="currentPage.slug === 'team'" />
<!-- Regular page -->
<template v-else>
<!-- Loading segments -->
<div v-if="loading" class="flex items-center justify-center py-32">
<svg class="h-8 w-8 animate-spin text-primary-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
<!-- Error -->
<div v-else-if="segmentError" class="flex items-center justify-center py-32">
<p class="text-lg text-red-500">{{ segmentError }}</p>
</div>
<!-- Empty page -->
<div
v-else-if="!segments.length"
class="flex flex-col items-center justify-center py-32 text-gray-400 dark:text-gray-600"
>
<svg class="mb-4 h-14 w-14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="mb-1 text-base font-medium">This page is empty</p>
<p v-if="isAdmin" class="text-sm">Use the <strong>+ Add section</strong> button to add your first section.</p>
</div>
<!-- Segment list -->
<SegmentList
v-else
:segments="segments"
@save="handleSaveSegment"
@delete="handleDeleteSegment"
@reorder="handleReorder"
/>
</template>
</template>
<!-- Floating "Add section" FAB (admin only) -->
<Teleport to="body">
<button
v-if="isAdmin && currentPage && !currentPage.is_system"
class="fixed right-6 bottom-6 z-30 flex items-center gap-2 rounded-full bg-primary-300 px-5 py-3 font-medium text-gray-900 shadow-lg transition-all hover:bg-primary-400 hover:shadow-xl active:scale-95"
title="Add a new section to this page"
@click="showAddModal = true"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add section
</button>
</Teleport>
<!-- Add segment modal -->
<AddSegmentModal
v-if="showAddModal"
@create="handleCreateSegment"
@cancel="showAddModal = false"
/>
</template>

View file

@ -0,0 +1,116 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useTeamMembers } from "@/composables/useTeamMembers";
import { useAdmin } from "@/composables/useAdmin";
const { members, loading, fetchMembers, addMember, deleteMember } = useTeamMembers();
const { isAdmin } = useAdmin();
const newName = ref("");
const newNumber = ref("");
const adding = ref(false);
const addError = ref<string | null>(null);
onMounted(() => { void fetchMembers(); });
async function handleAdd() {
const name = newName.value.trim();
const number = newNumber.value.trim();
if (!name || !number) return;
adding.value = true;
addError.value = null;
const result = await addMember(name, number);
adding.value = false;
if (result) {
newName.value = "";
newNumber.value = "";
} else {
addError.value = "Could not add member.";
}
}
</script>
<template>
<div>
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-100">Team Members</h1>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-16">
<svg class="h-8 w-8 animate-spin text-primary-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
<template v-else>
<!-- Empty state (non-admin) -->
<div
v-if="!members.length && !isAdmin"
class="flex flex-col items-center justify-center py-24 text-gray-400 dark:text-gray-600"
>
<svg class="mb-4 h-12 w-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p class="text-base font-medium">No team members yet.</p>
</div>
<!-- Member table -->
<div v-else-if="members.length" class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800">
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">Name</th>
<th class="px-4 py-3 text-left font-mono font-medium text-gray-600 dark:text-gray-300">Student number</th>
<th v-if="isAdmin" class="w-12 px-4 py-3" />
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
<tr
v-for="member in members"
:key="member.id"
class="bg-white transition-colors hover:bg-gray-50 dark:bg-gray-900 dark:hover:bg-gray-800/60"
>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">{{ member.name }}</td>
<td class="px-4 py-3 font-mono text-gray-500 dark:text-gray-400">{{ member.student_number }}</td>
<td v-if="isAdmin" class="px-4 py-3 text-right">
<button
class="rounded p-1 text-gray-300 transition-colors hover:bg-red-50 hover:text-red-500 dark:text-gray-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Remove member"
@click="deleteMember(member.id)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Add member form (admin) -->
<form v-if="isAdmin" class="mt-4 flex gap-2" @submit.prevent="handleAdd">
<input
v-model="newName"
type="text"
placeholder="Full name"
class="min-w-0 flex-1 rounded border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
/>
<input
v-model="newNumber"
type="text"
placeholder="Student number"
class="w-40 rounded border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
/>
<button
type="submit"
:disabled="!newName.trim() || !newNumber.trim() || adding"
class="rounded bg-primary-200 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-primary-300 disabled:opacity-50"
>
{{ adding ? "Adding…" : "Add" }}
</button>
</form>
<p v-if="addError" class="mt-1.5 text-xs text-red-600 dark:text-red-400">{{ addError }}</p>
</template>
</div>
</template>