initial release
This commit is contained in:
parent
e156de22c3
commit
b3ac11a85e
37 changed files with 3649 additions and 669 deletions
|
|
@ -5,6 +5,7 @@ from pathlib import Path
|
||||||
def init_db(db_path: Path) -> None:
|
def init_db(db_path: Path) -> None:
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA busy_timeout=5000")
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
|
@ -14,6 +15,19 @@ def init_db(db_path: Path) -> None:
|
||||||
value TEXT NOT NULL
|
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("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS segments (
|
CREATE TABLE IF NOT EXISTS segments (
|
||||||
id TEXT PRIMARY KEY,
|
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 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()
|
conn.commit()
|
||||||
|
|
||||||
|
# Ensure default pages exist
|
||||||
|
_migrate_default_page(conn)
|
||||||
|
_ensure_team_page(conn)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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:
|
def get_connection(db_path: Path) -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@ from app.config import get_settings
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.routes.assets import router as assets_router
|
from app.routes.assets import router as assets_router
|
||||||
from app.routes.auth import router as auth_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.segments import router as segments_router
|
||||||
from app.routes.site import router as site_router
|
from app.routes.site import router as site_router
|
||||||
|
from app.routes.team import router as team_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
@ -25,9 +27,11 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||||
app = FastAPI(title="Handin Website", lifespan=lifespan)
|
app = FastAPI(title="Handin Website", lifespan=lifespan)
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
app.include_router(pages_router)
|
||||||
app.include_router(segments_router)
|
app.include_router(segments_router)
|
||||||
app.include_router(site_router)
|
app.include_router(site_router)
|
||||||
app.include_router(assets_router)
|
app.include_router(assets_router)
|
||||||
|
app.include_router(team_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ class SegmentType(StrEnum):
|
||||||
AUDIO = "audio"
|
AUDIO = "audio"
|
||||||
IFRAME = "iframe"
|
IFRAME = "iframe"
|
||||||
GALLERY = "gallery"
|
GALLERY = "gallery"
|
||||||
LINK = "link"
|
TEAM = "team"
|
||||||
|
|
||||||
|
|
||||||
class Segment(BaseModel):
|
class Segment(BaseModel):
|
||||||
|
|
@ -22,13 +22,15 @@ class Segment(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
content: str = ""
|
content: str = ""
|
||||||
metadata: dict[str, object] = Field(default_factory=dict)
|
metadata: dict[str, object] = Field(default_factory=dict)
|
||||||
|
page_id: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class SegmentCreateRequest(BaseModel):
|
class SegmentCreateRequest(BaseModel):
|
||||||
type: SegmentType
|
type: SegmentType
|
||||||
title: str
|
title: str = ""
|
||||||
|
page_id: str | None = None
|
||||||
content: str = ""
|
content: str = ""
|
||||||
metadata: dict[str, object] = Field(default_factory=dict)
|
metadata: dict[str, object] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
@ -54,9 +56,54 @@ class SegmentResponse(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
content: str
|
content: str
|
||||||
metadata: dict[str, object]
|
metadata: dict[str, object]
|
||||||
|
page_id: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class SiteResponse(BaseModel):
|
class SiteResponse(BaseModel):
|
||||||
title: str
|
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
104
backend/app/routes/pages.py
Normal 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]
|
||||||
|
|
@ -3,7 +3,7 @@ from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
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.auth import require_admin
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
@ -34,8 +34,11 @@ Admin = Annotated[None, Depends(require_admin)]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
def list_segments(db_path: DbPath) -> list[SegmentResponse]:
|
def list_segments(
|
||||||
segments = segment_service.list_segments(db_path)
|
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]
|
return [SegmentResponse.model_validate(s.model_dump()) for s in segments]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
56
backend/app/routes/team.py
Normal file
56
backend/app/routes/team.py
Normal 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)
|
||||||
149
backend/app/services/page_service.py
Normal file
149
backend/app/services/page_service.py
Normal 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()
|
||||||
|
|
@ -16,6 +16,7 @@ def _row_to_segment(row: sqlite3.Row) -> Segment:
|
||||||
title=row["title"],
|
title=row["title"],
|
||||||
content=row["content"],
|
content=row["content"],
|
||||||
metadata=json.loads(row["metadata"]),
|
metadata=json.loads(row["metadata"]),
|
||||||
|
page_id=dict(row).get("page_id"),
|
||||||
created_at=datetime.fromisoformat(row["created_at"]),
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
updated_at=datetime.fromisoformat(row["updated_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)
|
conn = get_connection(db_path)
|
||||||
try:
|
try:
|
||||||
row = conn.execute(
|
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()
|
).fetchone()
|
||||||
sort_order: int = row["next_order"] if row else 0
|
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()
|
now = datetime.now(UTC).isoformat()
|
||||||
metadata_json = json.dumps(request.metadata)
|
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(
|
conn.execute(
|
||||||
f"INSERT INTO segments ({cols}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
f"INSERT INTO segments ({cols}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
str(segment_id),
|
str(segment_id),
|
||||||
request.type.value,
|
request.type.value,
|
||||||
|
|
@ -43,6 +46,7 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
|
||||||
request.title,
|
request.title,
|
||||||
request.content,
|
request.content,
|
||||||
metadata_json,
|
metadata_json,
|
||||||
|
request.page_id,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
|
|
@ -56,6 +60,7 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
|
||||||
title=request.title,
|
title=request.title,
|
||||||
content=request.content,
|
content=request.content,
|
||||||
metadata=request.metadata,
|
metadata=request.metadata,
|
||||||
|
page_id=request.page_id,
|
||||||
created_at=datetime.fromisoformat(now),
|
created_at=datetime.fromisoformat(now),
|
||||||
updated_at=datetime.fromisoformat(now),
|
updated_at=datetime.fromisoformat(now),
|
||||||
)
|
)
|
||||||
|
|
@ -63,10 +68,16 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
|
||||||
conn.close()
|
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)
|
conn = get_connection(db_path)
|
||||||
try:
|
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]
|
return [_row_to_segment(row) for row in rows]
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -140,6 +151,13 @@ def reorder_segments(db_path: Path, segment_ids: list[UUID]) -> list[Segment]:
|
||||||
)
|
)
|
||||||
conn.commit()
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
|
||||||
91
backend/app/services/team_service.py
Normal file
91
backend/app/services/team_service.py
Normal 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()
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Handin</title>
|
<title>Handin</title>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
1396
frontend/package-lock.json
generated
1396
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -10,13 +10,17 @@
|
||||||
"type-check": "vue-tsc --noEmit"
|
"type-check": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "3.5.13",
|
"@tiptap/starter-kit": "^3.20.0",
|
||||||
|
"@tiptap/vue-3": "^3.20.0",
|
||||||
"marked": "15.0.7",
|
"marked": "15.0.7",
|
||||||
|
"tiptap-markdown": "^0.9.0",
|
||||||
|
"vue": "3.5.13",
|
||||||
|
"vue-router": "^5.0.3",
|
||||||
"vuedraggable": "4.1.0"
|
"vuedraggable": "4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "4.1.4",
|
|
||||||
"@tailwindcss/typography": "0.5.16",
|
"@tailwindcss/typography": "0.5.16",
|
||||||
|
"@tailwindcss/vite": "4.1.4",
|
||||||
"@vitejs/plugin-vue": "5.2.3",
|
"@vitejs/plugin-vue": "5.2.3",
|
||||||
"tailwindcss": "4.1.4",
|
"tailwindcss": "4.1.4",
|
||||||
"typescript": "5.7.3",
|
"typescript": "5.7.3",
|
||||||
|
|
|
||||||
BIN
frontend/public/favicon.png
Normal file
BIN
frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
|
|
@ -1,7 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import HomePage from "@/views/HomePage.vue";
|
import AppShell from "@/components/layout/AppShell.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HomePage />
|
<AppShell>
|
||||||
|
<RouterView />
|
||||||
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
|
|
@ -2,29 +2,27 @@
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import type { SegmentType } from "@/types/segment";
|
import type { SegmentType } from "@/types/segment";
|
||||||
import AssetUploader from "@/components/admin/AssetUploader.vue";
|
import AssetUploader from "@/components/admin/AssetUploader.vue";
|
||||||
|
import RichTextEditor from "@/components/admin/RichTextEditor.vue";
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
create: [data: { type: SegmentType; title: string; content: string; metadata?: Record<string, unknown> }];
|
create: [data: { type: SegmentType; title: string; content: string; metadata?: Record<string, unknown> }];
|
||||||
cancel: [];
|
cancel: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const segmentTypes: { value: SegmentType; label: string }[] = [
|
const segmentTypes: { value: SegmentType; label: string; description: string; icon: string }[] = [
|
||||||
{ value: "markdown", label: "Markdown" },
|
{ 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: "pdf", label: "PDF" },
|
{ 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: "video", label: "Video" },
|
{ 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: "audio", label: "Audio" },
|
{ 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: "iframe", label: "Iframe" },
|
{ 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: "gallery", label: "Gallery" },
|
{ 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>' },
|
||||||
{ value: "link", label: "Link" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const selectedType = ref<SegmentType>("markdown");
|
const selectedType = ref<SegmentType>("markdown");
|
||||||
const title = ref("");
|
const title = ref("");
|
||||||
const content = ref("");
|
const content = ref("");
|
||||||
const galleryImages = ref<string[]>([]);
|
const galleryImages = ref<string[]>([]);
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
if (!title.value.trim()) return false;
|
|
||||||
if (selectedType.value === "gallery") return galleryImages.value.length > 0;
|
if (selectedType.value === "gallery") return galleryImages.value.length > 0;
|
||||||
if (selectedType.value === "markdown") return true;
|
if (selectedType.value === "markdown") return true;
|
||||||
return content.value.trim().length > 0;
|
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"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||||
@click.self="$emit('cancel')"
|
@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">
|
<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-slate-900 dark:text-slate-100">Add New Segment</h2>
|
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">Add a new section</h2>
|
||||||
|
|
||||||
<!-- Type selector: button group -->
|
<!-- Type selector: icon cards -->
|
||||||
<div class="mb-4">
|
<div class="mb-5">
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">Type</label>
|
<div class="grid grid-cols-3 gap-2 sm:grid-cols-6">
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<button
|
<button
|
||||||
v-for="st in segmentTypes"
|
v-for="st in segmentTypes"
|
||||||
:key="st.value"
|
: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="
|
:class="
|
||||||
selectedType === st.value
|
selectedType === st.value
|
||||||
? 'bg-primary-300 text-slate-900 dark:bg-primary-600 dark:text-white'
|
? 'border-primary-300 bg-primary-50 dark:bg-primary-900/20'
|
||||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'
|
: '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 = []"
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<div class="mb-4">
|
<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
|
<input
|
||||||
v-model="title"
|
v-model="title"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Segment title"
|
placeholder="Give this section a 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"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Content: type-dependent -->
|
<!-- Content: type-dependent -->
|
||||||
<div class="mb-4">
|
<div class="mb-5">
|
||||||
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label>
|
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Content</label>
|
||||||
|
|
||||||
<!-- Markdown: textarea -->
|
<!-- Markdown: rich text editor -->
|
||||||
<textarea
|
<RichTextEditor v-if="selectedType === 'markdown'" v-model="content" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Iframe / Link: URL input -->
|
<!-- Iframe: URL input -->
|
||||||
<input
|
<div v-else-if="selectedType === 'iframe'">
|
||||||
v-else-if="selectedType === 'iframe' || selectedType === 'link'"
|
<p class="mb-2 text-xs text-gray-400">Paste the URL of the website or tool you want to embed.</p>
|
||||||
v-model="content"
|
<input
|
||||||
type="url"
|
v-model="content"
|
||||||
:placeholder="selectedType === 'link' ? 'https://external-site.com' : 'https://...'"
|
type="url"
|
||||||
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="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 -->
|
<!-- PDF / Video / Audio -->
|
||||||
<div v-else-if="selectedType === 'pdf' || selectedType === 'video' || selectedType === '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>
|
<span class="truncate">{{ content }}</span>
|
||||||
<button class="shrink-0 text-red-400 hover:text-red-600" @click="content = ''">×</button>
|
<button class="shrink-0 text-red-400 hover:text-red-600" @click="content = ''">×</button>
|
||||||
</div>
|
</div>
|
||||||
<AssetUploader
|
<AssetUploader
|
||||||
:accept="assetAcceptMap[selectedType]"
|
:accept="assetAcceptMap[selectedType]"
|
||||||
:label="`Upload ${selectedType} file`"
|
:label="`Click to upload your ${selectedType} file, or drag and drop`"
|
||||||
@uploaded="handleAssetUploaded"
|
@uploaded="handleAssetUploaded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gallery -->
|
<!-- Gallery -->
|
||||||
<div v-else-if="selectedType === '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-if="galleryImages.length > 0" class="mb-3 grid grid-cols-4 gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="(img, i) in galleryImages"
|
v-for="(img, i) in galleryImages"
|
||||||
:key="i"
|
: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" />
|
<img :src="img" class="h-full w-full object-cover" />
|
||||||
<button
|
<button
|
||||||
|
|
@ -153,24 +152,24 @@ const assetAcceptMap: Record<string, string> = {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button
|
<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')"
|
@click="$emit('cancel')"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="!canSubmit"
|
: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"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
Create
|
Add section
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref, onMounted, watch } from "vue";
|
||||||
import { useAdmin } from "@/composables/useAdmin";
|
import { useAdmin } from "@/composables/useAdmin";
|
||||||
import { useAssetUpload } from "@/composables/useAssetUpload";
|
import { useAssetUpload } from "@/composables/useAssetUpload";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
accept?: string;
|
accept?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
multiple?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -13,28 +14,61 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { authHeaders } = useAdmin();
|
const { authHeaders } = useAdmin();
|
||||||
const { uploading, error, upload } = useAssetUpload();
|
const { upload } = useAssetUpload();
|
||||||
const dragging = ref(false);
|
const dragging = ref(false);
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
// Track batch upload state independently of the per-request composable
|
||||||
const filename = await upload(file, authHeaders.value);
|
const busy = ref(false);
|
||||||
if (filename) {
|
const progressLabel = ref("");
|
||||||
emit("uploaded", filename);
|
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) {
|
function onDrop(e: DragEvent) {
|
||||||
dragging.value = false;
|
dragging.value = false;
|
||||||
const file = e.dataTransfer?.files[0];
|
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||||
if (file) void handleFile(file);
|
void handleFiles(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFileSelect(e: Event) {
|
function onFileSelect(e: Event) {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
const file = target.files?.[0];
|
const files = Array.from(target.files ?? []);
|
||||||
if (file) void handleFile(file);
|
|
||||||
target.value = "";
|
target.value = "";
|
||||||
|
void handleFiles(files);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -47,15 +81,19 @@ function onFileSelect(e: Event) {
|
||||||
@drop.prevent="onDrop"
|
@drop.prevent="onDrop"
|
||||||
@click="fileInput?.click()"
|
@click="fileInput?.click()"
|
||||||
>
|
>
|
||||||
|
<!-- Hidden file input: multiple attribute set imperatively via onMounted/watch -->
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
:accept="props.accept"
|
:accept="props.accept"
|
||||||
|
@click.stop
|
||||||
@change="onFileSelect"
|
@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>
|
<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">
|
<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" />
|
<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>
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ props.label ?? "Drop file here or click to browse" }}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
243
frontend/src/components/admin/RichTextEditor.vue
Normal file
243
frontend/src/components/admin/RichTextEditor.vue
Normal 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()"
|
||||||
|
></></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>
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import type { Segment } from "@/types/segment";
|
import type { Segment } from "@/types/segment";
|
||||||
import AssetUploader from "@/components/admin/AssetUploader.vue";
|
import AssetUploader from "@/components/admin/AssetUploader.vue";
|
||||||
|
import RichTextEditor from "@/components/admin/RichTextEditor.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
segment: Segment;
|
segment: Segment;
|
||||||
|
|
@ -17,11 +18,21 @@ const title = ref(props.segment.title);
|
||||||
const content = ref(props.segment.content);
|
const content = ref(props.segment.content);
|
||||||
const metadata = ref<Record<string, unknown>>({ ...props.segment.metadata });
|
const metadata = ref<Record<string, unknown>>({ ...props.segment.metadata });
|
||||||
const showDeleteConfirm = ref(false);
|
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() {
|
function handleSave() {
|
||||||
const updates: { title?: string; content?: string; metadata?: Record<string, unknown> } = {};
|
const updates: { title?: string; content?: string; metadata?: Record<string, unknown> } = {};
|
||||||
if (title.value !== props.segment.title) updates.title = title.value;
|
if (title.value !== props.segment.title) updates.title = title.value;
|
||||||
if (content.value !== props.segment.content) updates.content = content.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)) {
|
if (JSON.stringify(metadata.value) !== JSON.stringify(props.segment.metadata)) {
|
||||||
updates.metadata = metadata.value;
|
updates.metadata = metadata.value;
|
||||||
}
|
}
|
||||||
|
|
@ -71,20 +82,15 @@ const assetAcceptMap: Record<string, string> = {
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label>
|
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label>
|
||||||
|
|
||||||
<!-- Markdown: textarea -->
|
<!-- Markdown: rich text editor -->
|
||||||
<textarea
|
<RichTextEditor v-if="segment.type === 'markdown'" v-model="content" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Iframe / Link: URL input -->
|
<!-- Iframe: URL input -->
|
||||||
<input
|
<input
|
||||||
v-else-if="segment.type === 'iframe' || segment.type === 'link'"
|
v-else-if="segment.type === 'iframe'"
|
||||||
v-model="content"
|
v-model="content"
|
||||||
type="url"
|
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"
|
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>
|
</div>
|
||||||
<AssetUploader
|
<AssetUploader
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
label="Upload gallery image"
|
:multiple="true"
|
||||||
|
label="Click to upload photos, or drag and drop"
|
||||||
@uploaded="handleGalleryImageUploaded"
|
@uploaded="handleGalleryImageUploaded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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)">×</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>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
|
|
|
||||||
|
|
@ -1,135 +1,175 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from "vue";
|
import { ref, nextTick, onMounted, watch, watchEffect } from "vue";
|
||||||
import type { Segment } from "@/types/segment";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
|
import Sidebar from "@/components/layout/Sidebar.vue";
|
||||||
|
import { usePages } from "@/composables/usePages";
|
||||||
import { useAdmin } from "@/composables/useAdmin";
|
import { useAdmin } from "@/composables/useAdmin";
|
||||||
import { useDarkMode } from "@/composables/useDarkMode";
|
import type { SiteInfo } from "@/types/segment";
|
||||||
import SidebarNav from "@/components/layout/SidebarNav.vue";
|
import logoUrl from "@/assets/logo.png";
|
||||||
import MobileHeader from "@/components/layout/MobileHeader.vue";
|
|
||||||
import TokenPrompt from "@/components/admin/TokenPrompt.vue";
|
|
||||||
import AdminToolbar from "@/components/admin/AdminToolbar.vue";
|
|
||||||
|
|
||||||
defineProps<{
|
const router = useRouter();
|
||||||
segments: Segment[];
|
const route = useRoute();
|
||||||
siteTitle: string;
|
const { pages, fetchPages } = usePages();
|
||||||
activeId: string | null;
|
const { isAdmin, authHeaders } = useAdmin();
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const mobileNavOpen = ref(false);
|
||||||
navigate: [id: string];
|
const siteTitle = ref("Untitled Site");
|
||||||
"add-segment": [];
|
const editingSiteTitle = ref(false);
|
||||||
logout: [];
|
const siteTitleInput = ref("");
|
||||||
}>();
|
|
||||||
|
|
||||||
const { isAdmin } = useAdmin();
|
async function loadSiteTitle() {
|
||||||
const { isDark, toggle: toggleDark } = useDarkMode();
|
try {
|
||||||
|
const res = await fetch("/api/site/");
|
||||||
const showBackToTop = ref(false);
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as SiteInfo;
|
||||||
function handleScroll() {
|
siteTitle.value = data.title;
|
||||||
showBackToTop.value = window.scrollY > 300;
|
}
|
||||||
|
} catch { /* Silently fail */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToTop() {
|
function startEditTitle() {
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
if (!isAdmin.value) return;
|
||||||
|
siteTitleInput.value = siteTitle.value;
|
||||||
|
editingSiteTitle.value = true;
|
||||||
|
nextTick(() => { document.getElementById("header-title-input")?.focus(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNavigate(id: string) {
|
async function saveSiteTitle() {
|
||||||
emit("navigate", id);
|
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(() => {
|
// Dynamic browser tab title
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
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(() => {
|
// Redirect "/" to first page once pages are loaded
|
||||||
window.removeEventListener("scroll", handleScroll);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-slate-50 dark:bg-slate-900">
|
<div class="flex h-screen flex-col overflow-hidden bg-white dark:bg-gray-900">
|
||||||
<!-- Sticky header: title bar + nav -->
|
<!-- Full-width yellow header -->
|
||||||
<div class="sticky top-0 z-20">
|
<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">
|
||||||
<!-- Title bar -->
|
<!-- Hamburger (mobile only) -->
|
||||||
<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">
|
|
||||||
<button
|
<button
|
||||||
v-if="showBackToTop"
|
class="shrink-0 rounded p-1.5 text-gray-700 transition-colors hover:bg-primary-400 md:hidden"
|
||||||
class="fixed right-6 bottom-6 z-30 rounded-full bg-primary-300 p-3 shadow-lg transition-colors hover:bg-primary-400"
|
aria-label="Open navigation"
|
||||||
title="Back to top"
|
@click="mobileNavOpen = true"
|
||||||
@click="scrollToTop"
|
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5 text-slate-900" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="M5 15l7-7 7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fade-up-enter-active,
|
.sidebar-fade-enter-active,
|
||||||
.fade-up-leave-active {
|
.sidebar-fade-leave-active {
|
||||||
transition: opacity 200ms ease, transform 200ms ease;
|
transition: opacity 200ms ease;
|
||||||
}
|
}
|
||||||
.fade-up-enter-from,
|
.sidebar-fade-enter-from,
|
||||||
.fade-up-leave-to {
|
.sidebar-fade-leave-to {
|
||||||
opacity: 0;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
411
frontend/src/components/layout/Sidebar.vue
Normal file
411
frontend/src/components/layout/Sidebar.vue
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import type { Segment } from "@/types/segment";
|
import type { Segment } from "@/types/segment";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
@ -8,21 +8,67 @@ const props = defineProps<{
|
||||||
|
|
||||||
const images = computed(() => {
|
const images = computed(() => {
|
||||||
const meta = props.segment.metadata;
|
const meta = props.segment.metadata;
|
||||||
if (Array.isArray(meta.images)) {
|
if (Array.isArray(meta.images)) return meta.images as string[];
|
||||||
return meta.images as string[];
|
|
||||||
}
|
|
||||||
return [];
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
|
<div v-if="images.length > 0" class="select-none">
|
||||||
<img
|
<!-- Fixed-height image box with arrows overlaid inside it -->
|
||||||
v-for="(src, index) in images"
|
<div class="relative flex h-[70vh] items-center justify-center overflow-hidden rounded bg-white dark:bg-gray-900">
|
||||||
:key="index"
|
<img
|
||||||
:src="src"
|
:src="images[current]"
|
||||||
:alt="`${segment.title} image ${index + 1}`"
|
:alt="`${segment.title} image ${current + 1}`"
|
||||||
class="aspect-square w-full rounded object-cover"
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ defineProps<{
|
||||||
<template>
|
<template>
|
||||||
<iframe
|
<iframe
|
||||||
:src="segment.content"
|
:src="segment.content"
|
||||||
class="h-[600px] w-full rounded border-none"
|
class="h-[85vh] w-full rounded border-none"
|
||||||
:title="segment.title"
|
:title="segment.title"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import type { Segment } from "@/types/segment";
|
import type { Segment } from "@/types/segment";
|
||||||
import { useAdmin } from "@/composables/useAdmin";
|
import { useAdmin } from "@/composables/useAdmin";
|
||||||
import SegmentRenderer from "@/components/segments/SegmentRenderer.vue";
|
import SegmentRenderer from "@/components/segments/SegmentRenderer.vue";
|
||||||
|
|
@ -10,9 +10,6 @@ const props = defineProps<{
|
||||||
segments: Segment[];
|
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<{
|
const emit = defineEmits<{
|
||||||
save: [id: string, updates: { title?: string; content?: string; metadata?: Record<string, unknown> }];
|
save: [id: string, updates: { title?: string; content?: string; metadata?: Record<string, unknown> }];
|
||||||
delete: [id: string];
|
delete: [id: string];
|
||||||
|
|
@ -22,14 +19,11 @@ const emit = defineEmits<{
|
||||||
const { isAdmin } = useAdmin();
|
const { isAdmin } = useAdmin();
|
||||||
const editingId = ref<string | null>(null);
|
const editingId = ref<string | null>(null);
|
||||||
|
|
||||||
// Local copy for draggable
|
|
||||||
const localSegments = ref<Segment[]>([...props.segments]);
|
const localSegments = ref<Segment[]>([...props.segments]);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.segments,
|
() => props.segments,
|
||||||
(val) => {
|
(val) => { localSegments.value = [...val]; },
|
||||||
localSegments.value = [...val];
|
|
||||||
},
|
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -51,89 +45,100 @@ function handleDelete(id: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragEnd() {
|
function onDragEnd() {
|
||||||
const ids = localSegments.value.map((s) => s.id);
|
emit("reorder", localSegments.value.map((s) => s.id));
|
||||||
emit("reorder", ids);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6 p-6">
|
<!-- Admin: draggable flowing sections -->
|
||||||
<!-- Admin mode: draggable with edit controls -->
|
<draggable
|
||||||
<draggable
|
v-if="isAdmin"
|
||||||
v-if="isAdmin"
|
v-model="localSegments"
|
||||||
v-model="localSegments"
|
item-key="id"
|
||||||
item-key="id"
|
handle=".drag-handle"
|
||||||
handle=".drag-handle"
|
:animation="150"
|
||||||
class="space-y-6"
|
:force-fallback="false"
|
||||||
@end="onDragEnd"
|
ghost-class="drag-ghost"
|
||||||
>
|
chosen-class="drag-chosen"
|
||||||
<template #item="{ element: segment }: { element: Segment }">
|
class="segment-list"
|
||||||
<section
|
@end="onDragEnd"
|
||||||
:id="segment.id"
|
>
|
||||||
class="rounded-lg bg-white p-6 shadow-md dark:bg-slate-800 dark:shadow-slate-950/30"
|
<template #item="{ element: segment }: { element: Segment }">
|
||||||
>
|
<section :id="segment.id" class="py-8 first:pt-0">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<!-- Drag handle: grip dots -->
|
<!-- Drag handle -->
|
||||||
<button
|
<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"
|
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"
|
title="Drag to reorder"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<circle cx="9" cy="6" r="1.5" />
|
<circle cx="9" cy="6" r="1.5" /><circle cx="15" 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="12" r="1.5" />
|
<circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
|
||||||
<circle cx="15" cy="12" r="1.5" />
|
</svg>
|
||||||
<circle cx="9" cy="18" r="1.5" />
|
</button>
|
||||||
<circle cx="15" cy="18" r="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between gap-2">
|
||||||
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100">{{ segment.title }}</h2>
|
<h2 v-if="segment.title" class="text-xl font-semibold text-gray-900 dark:text-gray-100">{{ segment.title }}</h2>
|
||||||
<!-- Edit pencil icon -->
|
<span v-else class="flex-1" />
|
||||||
<button
|
<button
|
||||||
class="rounded p-1 text-slate-300 transition-colors hover:text-primary-300 dark:text-slate-600 dark:hover:text-primary-400"
|
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="Edit segment"
|
:title="editingId === segment.id ? 'Close editor' : 'Edit section'"
|
||||||
@click="toggleEdit(segment.id)"
|
@click="toggleEdit(segment.id)"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
||||||
</button>
|
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</div>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
<!-- Link segments show URL instead of content -->
|
</button>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
</draggable>
|
|
||||||
|
|
||||||
<!-- Read-only mode: plain rendering (link segments excluded) -->
|
<!-- Inline editor replaces content -->
|
||||||
<template v-else>
|
<SegmentEditor
|
||||||
<section
|
v-if="editingId === segment.id"
|
||||||
v-for="segment in contentSegments"
|
:segment="segment"
|
||||||
:key="segment.id"
|
@save="(updates) => handleSave(segment.id, updates)"
|
||||||
:id="segment.id"
|
@cancel="editingId = null"
|
||||||
class="rounded-lg bg-white p-6 shadow-md dark:bg-slate-800 dark:shadow-slate-950/30"
|
@delete="handleDelete(segment.id)"
|
||||||
>
|
/>
|
||||||
<h2 class="mb-4 text-xl font-semibold text-slate-900 dark:text-slate-100">{{ segment.title }}</h2>
|
<SegmentRenderer v-else :segment="segment" />
|
||||||
<SegmentRenderer :segment="segment" />
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|
|
||||||
31
frontend/src/components/segments/TeamSegment.vue
Normal file
31
frontend/src/components/segments/TeamSegment.vue
Normal 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>
|
||||||
110
frontend/src/composables/usePages.ts
Normal file
110
frontend/src/composables/usePages.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { ref, onMounted } from "vue";
|
import { ref } from "vue";
|
||||||
import type { Segment, SiteInfo } from "@/types/segment";
|
import type { Segment } from "@/types/segment";
|
||||||
|
|
||||||
export function useSegments() {
|
export function useSegments(pageId: string) {
|
||||||
const segments = ref<Segment[]>([]);
|
const segments = ref<Segment[]>([]);
|
||||||
const siteTitle = ref("Handin");
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -11,18 +10,10 @@ export function useSegments() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
const [siteRes, segmentsRes] = await Promise.all([
|
const res = await fetch(`/api/segments/?page_id=${encodeURIComponent(pageId)}`);
|
||||||
fetch("/api/site/"),
|
if (!res.ok) throw new Error(`Segments fetch failed: ${res.status}`);
|
||||||
fetch("/api/segments/"),
|
const data = (await res.json()) as Segment[];
|
||||||
]);
|
segments.value = data.sort((a, b) => a.sort_order - b.sort_order);
|
||||||
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);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Unknown error";
|
error.value = e instanceof Error ? e.message : "Unknown error";
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -30,9 +21,5 @@ export function useSegments() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
return { segments, loading, error, refresh };
|
||||||
void refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
return { segments, siteTitle, loading, error, refresh };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
frontend/src/composables/useTeamMembers.ts
Normal file
57
frontend/src/composables/useTeamMembers.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
|
import router from "./router";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
createApp(App).mount("#app");
|
createApp(App).use(router).mount("#app");
|
||||||
|
|
|
||||||
22
frontend/src/router/index.ts
Normal file
22
frontend/src/router/index.ts
Normal 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;
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
/* Brand yellow — unchanged */
|
||||||
--color-primary-50: #fffbeb;
|
--color-primary-50: #fffbeb;
|
||||||
--color-primary-100: #fff3c4;
|
--color-primary-100: #fff3c4;
|
||||||
--color-primary-200: #fce588;
|
--color-primary-200: #fce588;
|
||||||
|
|
@ -12,4 +13,17 @@
|
||||||
--color-primary-500: #cc9900;
|
--color-primary-500: #cc9900;
|
||||||
--color-primary-600: #997300;
|
--color-primary-600: #997300;
|
||||||
--color-primary-700: #664d00;
|
--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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
export interface Segment {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -7,6 +7,7 @@ export interface Segment {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
|
page_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
@ -14,3 +15,23 @@ export interface Segment {
|
||||||
export interface SiteInfo {
|
export interface SiteInfo {
|
||||||
title: string;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
182
frontend/src/views/PageView.vue
Normal file
182
frontend/src/views/PageView.vue
Normal 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>
|
||||||
116
frontend/src/views/TeamPage.vue
Normal file
116
frontend/src/views/TeamPage.vue
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue