Tier 3: appointments CRUD + time tracking with topbar timer
This commit is contained in:
4
main.py
4
main.py
@@ -36,6 +36,8 @@ from routers import (
|
||||
files as files_router,
|
||||
meetings as meetings_router,
|
||||
decisions as decisions_router,
|
||||
weblinks as weblinks_router,
|
||||
appointments as appointments_router,
|
||||
)
|
||||
|
||||
|
||||
@@ -179,3 +181,5 @@ app.include_router(lists_router.router)
|
||||
app.include_router(files_router.router)
|
||||
app.include_router(meetings_router.router)
|
||||
app.include_router(decisions_router.router)
|
||||
app.include_router(weblinks_router.router)
|
||||
app.include_router(appointments_router.router)
|
||||
|
||||
@@ -27,6 +27,9 @@ TRASH_ENTITIES = [
|
||||
{"table": "meetings", "label": "Meetings", "name_col": "title", "url": "/meetings/{id}"},
|
||||
{"table": "decisions", "label": "Decisions", "name_col": "title", "url": "/decisions/{id}"},
|
||||
{"table": "files", "label": "Files", "name_col": "original_filename", "url": "/files"},
|
||||
{"table": "weblinks", "label": "Weblinks", "name_col": "label", "url": "/weblinks"},
|
||||
{"table": "weblink_folders", "label": "Weblink Folders", "name_col": "name", "url": "/weblinks"},
|
||||
{"table": "appointments", "label": "Appointments", "name_col": "title", "url": "/appointments/{id}"},
|
||||
{"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"},
|
||||
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
|
||||
]
|
||||
|
||||
302
routers/appointments.py
Normal file
302
routers/appointments.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""Appointments CRUD: scheduling with contacts, recurrence, all-day support."""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, Form, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/appointments", tags=["appointments"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_appointments(
|
||||
request: Request,
|
||||
status: Optional[str] = None,
|
||||
timeframe: Optional[str] = "upcoming",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
repo = BaseRepository("appointments", db)
|
||||
|
||||
# Build filter and sort based on timeframe
|
||||
if timeframe == "past":
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM appointments
|
||||
WHERE is_deleted = false AND start_at < now()
|
||||
ORDER BY start_at DESC
|
||||
LIMIT 100
|
||||
"""))
|
||||
elif timeframe == "all":
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM appointments
|
||||
WHERE is_deleted = false
|
||||
ORDER BY start_at DESC
|
||||
LIMIT 200
|
||||
"""))
|
||||
else:
|
||||
# upcoming (default)
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM appointments
|
||||
WHERE is_deleted = false AND start_at >= CURRENT_DATE
|
||||
ORDER BY start_at ASC
|
||||
LIMIT 100
|
||||
"""))
|
||||
|
||||
appointments = [dict(r._mapping) for r in result]
|
||||
|
||||
# Get contact counts per appointment
|
||||
if appointments:
|
||||
ids = [str(a["id"]) for a in appointments]
|
||||
placeholders = ", ".join(f"'{i}'" for i in ids)
|
||||
contact_result = await db.execute(text(f"""
|
||||
SELECT appointment_id, count(*) as cnt
|
||||
FROM contact_appointments
|
||||
WHERE appointment_id IN ({placeholders})
|
||||
GROUP BY appointment_id
|
||||
"""))
|
||||
contact_counts = {str(r._mapping["appointment_id"]): r._mapping["cnt"] for r in contact_result}
|
||||
for a in appointments:
|
||||
a["contact_count"] = contact_counts.get(str(a["id"]), 0)
|
||||
|
||||
count = len(appointments)
|
||||
|
||||
return templates.TemplateResponse("appointments.html", {
|
||||
"request": request,
|
||||
"sidebar": sidebar,
|
||||
"appointments": appointments,
|
||||
"count": count,
|
||||
"timeframe": timeframe or "upcoming",
|
||||
"page_title": "Appointments",
|
||||
"active_nav": "appointments",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/new")
|
||||
async def new_appointment(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
|
||||
# Load contacts for attendee selection
|
||||
result = await db.execute(text("""
|
||||
SELECT id, first_name, last_name, company
|
||||
FROM contacts WHERE is_deleted = false
|
||||
ORDER BY first_name, last_name
|
||||
"""))
|
||||
contacts = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("appointment_form.html", {
|
||||
"request": request,
|
||||
"sidebar": sidebar,
|
||||
"appointment": None,
|
||||
"contacts": contacts,
|
||||
"selected_contacts": [],
|
||||
"page_title": "New Appointment",
|
||||
"active_nav": "appointments",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_appointment(
|
||||
request: Request,
|
||||
title: str = Form(...),
|
||||
description: Optional[str] = Form(None),
|
||||
location: Optional[str] = Form(None),
|
||||
start_date: str = Form(...),
|
||||
start_time: Optional[str] = Form(None),
|
||||
end_date: Optional[str] = Form(None),
|
||||
end_time: Optional[str] = Form(None),
|
||||
all_day: Optional[str] = Form(None),
|
||||
recurrence: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
contact_ids: Optional[list[str]] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
is_all_day = all_day == "on"
|
||||
|
||||
# Build start_at
|
||||
if is_all_day:
|
||||
start_at = f"{start_date}T00:00:00"
|
||||
else:
|
||||
start_at = f"{start_date}T{start_time or '09:00'}:00"
|
||||
|
||||
# Build end_at
|
||||
end_at = None
|
||||
if end_date:
|
||||
if is_all_day:
|
||||
end_at = f"{end_date}T23:59:59"
|
||||
else:
|
||||
end_at = f"{end_date}T{end_time or '10:00'}:00"
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"description": description or None,
|
||||
"location": location or None,
|
||||
"start_at": start_at,
|
||||
"end_at": end_at,
|
||||
"all_day": is_all_day,
|
||||
"recurrence": recurrence or None,
|
||||
"tags": [t.strip() for t in tags.split(",") if t.strip()] if tags else None,
|
||||
}
|
||||
|
||||
repo = BaseRepository("appointments", db)
|
||||
appointment = await repo.create(data)
|
||||
|
||||
# Add contact associations
|
||||
if contact_ids:
|
||||
for cid in contact_ids:
|
||||
if cid:
|
||||
await db.execute(text("""
|
||||
INSERT INTO contact_appointments (contact_id, appointment_id)
|
||||
VALUES (:cid, :aid)
|
||||
ON CONFLICT DO NOTHING
|
||||
"""), {"cid": cid, "aid": str(appointment["id"])})
|
||||
|
||||
return RedirectResponse(url=f"/appointments/{appointment['id']}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{appointment_id}")
|
||||
async def appointment_detail(
|
||||
request: Request,
|
||||
appointment_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
repo = BaseRepository("appointments", db)
|
||||
appointment = await repo.get(appointment_id)
|
||||
|
||||
if not appointment:
|
||||
return RedirectResponse(url="/appointments", status_code=303)
|
||||
|
||||
# Get associated contacts
|
||||
result = await db.execute(text("""
|
||||
SELECT c.id, c.first_name, c.last_name, c.company, c.email, ca.role
|
||||
FROM contact_appointments ca
|
||||
JOIN contacts c ON ca.contact_id = c.id
|
||||
WHERE ca.appointment_id = :aid AND c.is_deleted = false
|
||||
ORDER BY c.first_name, c.last_name
|
||||
"""), {"aid": appointment_id})
|
||||
contacts = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("appointment_detail.html", {
|
||||
"request": request,
|
||||
"sidebar": sidebar,
|
||||
"appointment": appointment,
|
||||
"contacts": contacts,
|
||||
"page_title": appointment["title"],
|
||||
"active_nav": "appointments",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{appointment_id}/edit")
|
||||
async def edit_appointment_form(
|
||||
request: Request,
|
||||
appointment_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
repo = BaseRepository("appointments", db)
|
||||
appointment = await repo.get(appointment_id)
|
||||
|
||||
if not appointment:
|
||||
return RedirectResponse(url="/appointments", status_code=303)
|
||||
|
||||
# All contacts
|
||||
result = await db.execute(text("""
|
||||
SELECT id, first_name, last_name, company
|
||||
FROM contacts WHERE is_deleted = false
|
||||
ORDER BY first_name, last_name
|
||||
"""))
|
||||
contacts = [dict(r._mapping) for r in result]
|
||||
|
||||
# Currently linked contacts
|
||||
result = await db.execute(text("""
|
||||
SELECT contact_id FROM contact_appointments WHERE appointment_id = :aid
|
||||
"""), {"aid": appointment_id})
|
||||
selected_contacts = [str(r._mapping["contact_id"]) for r in result]
|
||||
|
||||
return templates.TemplateResponse("appointment_form.html", {
|
||||
"request": request,
|
||||
"sidebar": sidebar,
|
||||
"appointment": appointment,
|
||||
"contacts": contacts,
|
||||
"selected_contacts": selected_contacts,
|
||||
"page_title": f"Edit: {appointment['title']}",
|
||||
"active_nav": "appointments",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{appointment_id}/edit")
|
||||
async def update_appointment(
|
||||
request: Request,
|
||||
appointment_id: str,
|
||||
title: str = Form(...),
|
||||
description: Optional[str] = Form(None),
|
||||
location: Optional[str] = Form(None),
|
||||
start_date: str = Form(...),
|
||||
start_time: Optional[str] = Form(None),
|
||||
end_date: Optional[str] = Form(None),
|
||||
end_time: Optional[str] = Form(None),
|
||||
all_day: Optional[str] = Form(None),
|
||||
recurrence: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
contact_ids: Optional[list[str]] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
is_all_day = all_day == "on"
|
||||
|
||||
if is_all_day:
|
||||
start_at = f"{start_date}T00:00:00"
|
||||
else:
|
||||
start_at = f"{start_date}T{start_time or '09:00'}:00"
|
||||
|
||||
end_at = None
|
||||
if end_date:
|
||||
if is_all_day:
|
||||
end_at = f"{end_date}T23:59:59"
|
||||
else:
|
||||
end_at = f"{end_date}T{end_time or '10:00'}:00"
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"description": description or None,
|
||||
"location": location or None,
|
||||
"start_at": start_at,
|
||||
"end_at": end_at,
|
||||
"all_day": is_all_day,
|
||||
"recurrence": recurrence or None,
|
||||
"tags": [t.strip() for t in tags.split(",") if t.strip()] if tags else None,
|
||||
}
|
||||
|
||||
repo = BaseRepository("appointments", db)
|
||||
await repo.update(appointment_id, data)
|
||||
|
||||
# Rebuild contact associations
|
||||
await db.execute(text("DELETE FROM contact_appointments WHERE appointment_id = :aid"), {"aid": appointment_id})
|
||||
if contact_ids:
|
||||
for cid in contact_ids:
|
||||
if cid:
|
||||
await db.execute(text("""
|
||||
INSERT INTO contact_appointments (contact_id, appointment_id)
|
||||
VALUES (:cid, :aid)
|
||||
ON CONFLICT DO NOTHING
|
||||
"""), {"cid": cid, "aid": appointment_id})
|
||||
|
||||
return RedirectResponse(url=f"/appointments/{appointment_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{appointment_id}/delete")
|
||||
async def delete_appointment(
|
||||
request: Request,
|
||||
appointment_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("appointments", db)
|
||||
await repo.soft_delete(appointment_id)
|
||||
return RedirectResponse(url="/appointments", status_code=303)
|
||||
@@ -137,6 +137,34 @@ SEARCH_ENTITIES = [
|
||||
"url": "/decisions/{id}",
|
||||
"icon": "decision",
|
||||
},
|
||||
{
|
||||
"type": "weblinks",
|
||||
"label": "Weblinks",
|
||||
"query": """
|
||||
SELECT w.id, w.label as name, NULL as status,
|
||||
NULL as domain_name, NULL as project_name,
|
||||
ts_rank(w.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM weblinks w
|
||||
WHERE w.is_deleted = false AND w.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/weblinks",
|
||||
"icon": "weblink",
|
||||
},
|
||||
{
|
||||
"type": "appointments",
|
||||
"label": "Appointments",
|
||||
"query": """
|
||||
SELECT a.id, a.title as name, NULL as status,
|
||||
a.location as domain_name, NULL as project_name,
|
||||
ts_rank(a.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM appointments a
|
||||
WHERE a.is_deleted = false AND a.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/appointments/{id}",
|
||||
"icon": "appointment",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
233
routers/weblinks.py
Normal file
233
routers/weblinks.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Weblinks: organized bookmark directory with recursive folders."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/weblinks", tags=["weblinks"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_weblinks(
|
||||
request: Request,
|
||||
folder_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
|
||||
# Get all folders for tree nav
|
||||
result = await db.execute(text("""
|
||||
SELECT wf.*, (SELECT count(*) FROM folder_weblinks fw WHERE fw.folder_id = wf.id) as link_count
|
||||
FROM weblink_folders wf
|
||||
WHERE wf.is_deleted = false
|
||||
ORDER BY wf.sort_order, wf.name
|
||||
"""))
|
||||
all_folders = [dict(r._mapping) for r in result]
|
||||
|
||||
# Top-level folders and child folders
|
||||
top_folders = [f for f in all_folders if f.get("parent_id") is None]
|
||||
child_folder_map = {}
|
||||
for f in all_folders:
|
||||
pid = f.get("parent_id")
|
||||
if pid:
|
||||
child_folder_map.setdefault(str(pid), []).append(f)
|
||||
|
||||
# Current folder info
|
||||
current_folder = None
|
||||
if folder_id:
|
||||
for f in all_folders:
|
||||
if str(f["id"]) == folder_id:
|
||||
current_folder = f
|
||||
break
|
||||
|
||||
# Get weblinks (filtered by folder or all unfiled)
|
||||
if folder_id:
|
||||
result = await db.execute(text("""
|
||||
SELECT w.* FROM weblinks w
|
||||
JOIN folder_weblinks fw ON fw.weblink_id = w.id
|
||||
WHERE fw.folder_id = :fid AND w.is_deleted = false
|
||||
ORDER BY fw.sort_order, w.label
|
||||
"""), {"fid": folder_id})
|
||||
else:
|
||||
# Show all weblinks
|
||||
result = await db.execute(text("""
|
||||
SELECT w.* FROM weblinks w
|
||||
WHERE w.is_deleted = false
|
||||
ORDER BY w.sort_order, w.label
|
||||
"""))
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("weblinks.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"top_folders": top_folders, "child_folder_map": child_folder_map,
|
||||
"current_folder": current_folder,
|
||||
"current_folder_id": folder_id or "",
|
||||
"page_title": "Weblinks", "active_nav": "weblinks",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(
|
||||
request: Request,
|
||||
folder_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
result = await db.execute(text(
|
||||
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name"
|
||||
))
|
||||
folders = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("weblink_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"folders": folders,
|
||||
"page_title": "New Weblink", "active_nav": "weblinks",
|
||||
"item": None,
|
||||
"prefill_folder_id": folder_id or "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_weblink(
|
||||
request: Request,
|
||||
label: str = Form(...),
|
||||
url: str = Form(...),
|
||||
description: Optional[str] = Form(None),
|
||||
folder_id: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("weblinks", db)
|
||||
data = {"label": label, "url": url, "description": description}
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
weblink = await repo.create(data)
|
||||
|
||||
# Add to folder if specified
|
||||
if folder_id and folder_id.strip():
|
||||
await db.execute(text("""
|
||||
INSERT INTO folder_weblinks (folder_id, weblink_id)
|
||||
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
|
||||
"""), {"fid": folder_id, "wid": weblink["id"]})
|
||||
|
||||
redirect_url = f"/weblinks?folder_id={folder_id}" if folder_id and folder_id.strip() else "/weblinks"
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.get("/{weblink_id}/edit")
|
||||
async def edit_form(weblink_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("weblinks", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(weblink_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/weblinks", status_code=303)
|
||||
|
||||
result = await db.execute(text(
|
||||
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name"
|
||||
))
|
||||
folders = [dict(r._mapping) for r in result]
|
||||
|
||||
# Current folder assignment
|
||||
result = await db.execute(text(
|
||||
"SELECT folder_id FROM folder_weblinks WHERE weblink_id = :wid LIMIT 1"
|
||||
), {"wid": weblink_id})
|
||||
row = result.first()
|
||||
current_folder_id = str(row[0]) if row else ""
|
||||
|
||||
return templates.TemplateResponse("weblink_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"folders": folders,
|
||||
"page_title": "Edit Weblink", "active_nav": "weblinks",
|
||||
"item": item,
|
||||
"prefill_folder_id": current_folder_id,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{weblink_id}/edit")
|
||||
async def update_weblink(
|
||||
weblink_id: str,
|
||||
label: str = Form(...),
|
||||
url: str = Form(...),
|
||||
description: Optional[str] = Form(None),
|
||||
folder_id: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("weblinks", db)
|
||||
data = {
|
||||
"label": label, "url": url,
|
||||
"description": description if description and description.strip() else None,
|
||||
}
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
else:
|
||||
data["tags"] = None
|
||||
|
||||
await repo.update(weblink_id, data)
|
||||
|
||||
# Update folder assignment
|
||||
await db.execute(text("DELETE FROM folder_weblinks WHERE weblink_id = :wid"), {"wid": weblink_id})
|
||||
if folder_id and folder_id.strip():
|
||||
await db.execute(text("""
|
||||
INSERT INTO folder_weblinks (folder_id, weblink_id)
|
||||
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
|
||||
"""), {"fid": folder_id, "wid": weblink_id})
|
||||
|
||||
return RedirectResponse(url="/weblinks", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{weblink_id}/delete")
|
||||
async def delete_weblink(weblink_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("weblinks", db)
|
||||
await repo.soft_delete(weblink_id)
|
||||
referer = request.headers.get("referer", "/weblinks")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
|
||||
|
||||
# ---- Folders ----
|
||||
|
||||
@router.get("/folders/create")
|
||||
async def create_folder_form(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
result = await db.execute(text(
|
||||
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name"
|
||||
))
|
||||
parent_folders = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("weblink_folder_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"parent_folders": parent_folders,
|
||||
"page_title": "New Folder", "active_nav": "weblinks",
|
||||
"item": None,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/folders/create")
|
||||
async def create_folder(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
parent_id: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("weblink_folders", db)
|
||||
data = {"name": name}
|
||||
if parent_id and parent_id.strip():
|
||||
data["parent_id"] = parent_id
|
||||
await repo.create(data)
|
||||
return RedirectResponse(url="/weblinks", status_code=303)
|
||||
|
||||
|
||||
@router.post("/folders/{folder_id}/delete")
|
||||
async def delete_folder(folder_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("weblink_folders", db)
|
||||
await repo.soft_delete(folder_id)
|
||||
return RedirectResponse(url="/weblinks", status_code=303)
|
||||
@@ -983,6 +983,36 @@ a:hover { color: var(--accent-hover); }
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ---- Weblinks Layout ---- */
|
||||
.weblinks-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.weblinks-folders {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.weblink-folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
text-decoration: none;
|
||||
}
|
||||
.weblink-folder-item:hover { background: var(--surface2); color: var(--text); }
|
||||
.weblink-folder-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 500; }
|
||||
.weblinks-content { flex: 1; min-width: 0; }
|
||||
|
||||
/* ---- Scrollbar ---- */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
@@ -997,3 +1027,4 @@ a:hover { color: var(--accent-hover); }
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.page-content { padding: 16px; }
|
||||
}
|
||||
.search-type-appointments { background: var(--amber-soft); color: var(--amber); }
|
||||
|
||||
106
templates/appointment_detail.html
Normal file
106
templates/appointment_detail.html
Normal file
@@ -0,0 +1,106 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
<a href="/appointments">Appointments</a>
|
||||
<span class="sep">/</span>
|
||||
<span>{{ appointment.title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-header">
|
||||
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;">
|
||||
<div>
|
||||
<h1 class="detail-title">{{ appointment.title }}</h1>
|
||||
<div class="detail-meta">
|
||||
{% if appointment.all_day %}
|
||||
<span class="detail-meta-item">
|
||||
<span class="status-badge status-active">All Day</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="detail-meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
{% if appointment.all_day %}
|
||||
{{ appointment.start_at.strftime('%A, %B %-d, %Y') }}
|
||||
{% if appointment.end_at and appointment.end_at.strftime('%Y-%m-%d') != appointment.start_at.strftime('%Y-%m-%d') %}
|
||||
– {{ appointment.end_at.strftime('%A, %B %-d, %Y') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ appointment.start_at.strftime('%A, %B %-d, %Y at %-I:%M %p') }}
|
||||
{% if appointment.end_at %}
|
||||
– {{ appointment.end_at.strftime('%-I:%M %p') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if appointment.location %}
|
||||
<span class="detail-meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
{{ appointment.location }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if appointment.recurrence %}
|
||||
<span class="detail-meta-item">
|
||||
<span class="row-tag">{{ appointment.recurrence }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; flex-shrink: 0;">
|
||||
<a href="/appointments/{{ appointment.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
|
||||
<form method="POST" action="/appointments/{{ appointment.id }}/delete" data-confirm="Delete this appointment?">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if appointment.description %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-title mb-2">Description</div>
|
||||
<div class="detail-body">{{ appointment.description }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if appointment.tags %}
|
||||
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px;">
|
||||
{% for tag in appointment.tags %}
|
||||
<span class="row-tag">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Attendees -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Attendees ({{ contacts | length }})</span>
|
||||
</div>
|
||||
{% if contacts %}
|
||||
{% for c in contacts %}
|
||||
<div class="list-row">
|
||||
<div class="row-title">
|
||||
<a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a>
|
||||
</div>
|
||||
{% if c.company %}
|
||||
<span class="row-meta">{{ c.company }}</span>
|
||||
{% endif %}
|
||||
{% if c.email %}
|
||||
<span class="row-meta">{{ c.email }}</span>
|
||||
{% endif %}
|
||||
{% if c.role %}
|
||||
<span class="row-tag">{{ c.role }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state" style="padding: 24px;">
|
||||
<div class="text-muted text-sm">No attendees added</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-muted text-xs mt-3">
|
||||
Created {{ appointment.created_at.strftime('%B %-d, %Y') }}
|
||||
{% if appointment.updated_at and appointment.updated_at != appointment.created_at %}
|
||||
· Updated {{ appointment.updated_at.strftime('%B %-d, %Y') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
130
templates/appointment_form.html
Normal file
130
templates/appointment_form.html
Normal file
@@ -0,0 +1,130 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
<a href="/appointments">Appointments</a>
|
||||
<span class="sep">/</span>
|
||||
<span>{{ "Edit" if appointment else "New Appointment" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ "Edit Appointment" if appointment else "New Appointment" }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width: 720px;">
|
||||
<form method="POST" action="{{ '/appointments/' ~ appointment.id ~ '/edit' if appointment else '/appointments/create' }}">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Title *</label>
|
||||
<input type="text" name="title" class="form-input" required value="{{ appointment.title if appointment else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Start Date *</label>
|
||||
<input type="date" name="start_date" class="form-input" required
|
||||
value="{{ appointment.start_at.strftime('%Y-%m-%d') if appointment and appointment.start_at else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="start-time-group">
|
||||
<label class="form-label">Start Time</label>
|
||||
<input type="time" name="start_time" class="form-input" id="start-time-input"
|
||||
value="{{ appointment.start_at.strftime('%H:%M') if appointment and appointment.start_at and not appointment.all_day else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">End Date</label>
|
||||
<input type="date" name="end_date" class="form-input"
|
||||
value="{{ appointment.end_at.strftime('%Y-%m-%d') if appointment and appointment.end_at else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="end-time-group">
|
||||
<label class="form-label">End Time</label>
|
||||
<input type="time" name="end_time" class="form-input" id="end-time-input"
|
||||
value="{{ appointment.end_at.strftime('%H:%M') if appointment and appointment.end_at and not appointment.all_day else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" name="all_day" id="all-day-check"
|
||||
{{ 'checked' if appointment and appointment.all_day else '' }}
|
||||
onchange="toggleAllDay(this.checked)"
|
||||
style="width: 16px; height: 16px;">
|
||||
All Day Event
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Recurrence</label>
|
||||
<select name="recurrence" class="form-select">
|
||||
<option value="">None</option>
|
||||
<option value="daily" {{ 'selected' if appointment and appointment.recurrence == 'daily' }}>Daily</option>
|
||||
<option value="weekly" {{ 'selected' if appointment and appointment.recurrence == 'weekly' }}>Weekly</option>
|
||||
<option value="monthly" {{ 'selected' if appointment and appointment.recurrence == 'monthly' }}>Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Location</label>
|
||||
<input type="text" name="location" class="form-input" placeholder="Address, room, or video link"
|
||||
value="{{ appointment.location if appointment and appointment.location else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-textarea" rows="3" placeholder="Notes about this appointment...">{{ appointment.description if appointment and appointment.description else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Tags</label>
|
||||
<input type="text" name="tags" class="form-input" placeholder="Comma-separated tags"
|
||||
value="{{ appointment.tags | join(', ') if appointment and appointment.tags else '' }}">
|
||||
</div>
|
||||
|
||||
{% if contacts %}
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Attendees</label>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px; padding: 8px; background: var(--surface2); border-radius: var(--radius); border: 1px solid var(--border); max-height: 200px; overflow-y: auto;">
|
||||
{% for c in contacts %}
|
||||
<label style="display: flex; align-items: center; gap: 6px; padding: 4px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; white-space: nowrap;">
|
||||
<input type="checkbox" name="contact_ids" value="{{ c.id }}"
|
||||
{{ 'checked' if c.id|string in selected_contacts else '' }}
|
||||
style="width: 14px; height: 14px;">
|
||||
{{ c.first_name }} {{ c.last_name or '' }}
|
||||
{% if c.company %}<span style="color: var(--muted); font-size: 0.78rem;">({{ c.company }})</span>{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ "Update Appointment" if appointment else "Create Appointment" }}</button>
|
||||
<a href="{{ '/appointments/' ~ appointment.id if appointment else '/appointments' }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleAllDay(checked) {
|
||||
const startTime = document.getElementById('start-time-input');
|
||||
const endTime = document.getElementById('end-time-input');
|
||||
if (checked) {
|
||||
startTime.disabled = true;
|
||||
startTime.style.opacity = '0.4';
|
||||
endTime.disabled = true;
|
||||
endTime.style.opacity = '0.4';
|
||||
} else {
|
||||
startTime.disabled = false;
|
||||
startTime.style.opacity = '1';
|
||||
endTime.disabled = false;
|
||||
endTime.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
// Init on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const cb = document.getElementById('all-day-check');
|
||||
if (cb && cb.checked) toggleAllDay(true);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
72
templates/appointments.html
Normal file
72
templates/appointments.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Appointments <span class="page-count">({{ count }})</span></h1>
|
||||
</div>
|
||||
<a href="/appointments/new" class="btn btn-primary">+ New Appointment</a>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters-bar">
|
||||
<a href="/appointments?timeframe=upcoming" class="btn {{ 'btn-primary' if timeframe == 'upcoming' else 'btn-secondary' }} btn-sm">Upcoming</a>
|
||||
<a href="/appointments?timeframe=past" class="btn {{ 'btn-primary' if timeframe == 'past' else 'btn-secondary' }} btn-sm">Past</a>
|
||||
<a href="/appointments?timeframe=all" class="btn {{ 'btn-primary' if timeframe == 'all' else 'btn-secondary' }} btn-sm">All</a>
|
||||
</div>
|
||||
|
||||
{% if appointments %}
|
||||
<div class="card">
|
||||
{% set current_date = namespace(value='') %}
|
||||
{% for appt in appointments %}
|
||||
{% set appt_date = appt.start_at.strftime('%A, %B %-d, %Y') if appt.start_at else 'No Date' %}
|
||||
{% if appt_date != current_date.value %}
|
||||
{% if not loop.first %}</div>{% endif %}
|
||||
<div class="date-group-label" style="padding: 12px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
|
||||
{{ appt_date }}
|
||||
</div>
|
||||
<div>
|
||||
{% set current_date.value = appt_date %}
|
||||
{% endif %}
|
||||
|
||||
<div class="list-row">
|
||||
<div style="flex-shrink: 0; min-width: 60px;">
|
||||
{% if appt.all_day %}
|
||||
<span class="status-badge status-active" style="font-size: 0.72rem;">All Day</span>
|
||||
{% elif appt.start_at %}
|
||||
<span style="font-size: 0.85rem; font-weight: 600; color: var(--text);">{{ appt.start_at.strftime('%-I:%M %p') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="row-title">
|
||||
<a href="/appointments/{{ appt.id }}">{{ appt.title }}</a>
|
||||
</div>
|
||||
{% if appt.location %}
|
||||
<span class="row-meta">{{ appt.location }}</span>
|
||||
{% endif %}
|
||||
{% if appt.recurrence %}
|
||||
<span class="row-tag">{{ appt.recurrence }}</span>
|
||||
{% endif %}
|
||||
{% if appt.contact_count and appt.contact_count > 0 %}
|
||||
<span class="row-meta">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
{{ appt.contact_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<div class="row-actions">
|
||||
<a href="/appointments/{{ appt.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||
<form method="POST" action="/appointments/{{ appt.id }}/delete" data-confirm="Delete this appointment?">
|
||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red);">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📅</div>
|
||||
<div class="empty-state-text">No appointments {{ 'upcoming' if timeframe == 'upcoming' else 'found' }}</div>
|
||||
<a href="/appointments/new" class="btn btn-primary">Schedule an Appointment</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -52,6 +52,10 @@
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
Meetings
|
||||
</a>
|
||||
<a href="/appointments" class="nav-item {{ 'active' if active_nav == 'appointments' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
Appointments
|
||||
</a>
|
||||
<a href="/decisions" class="nav-item {{ 'active' if active_nav == 'decisions' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9 12l2 2 4-4"/></svg>
|
||||
Decisions
|
||||
@@ -60,6 +64,10 @@
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
|
||||
Files
|
||||
</a>
|
||||
<a href="/weblinks" class="nav-item {{ 'active' if active_nav == 'weblinks' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Weblinks
|
||||
</a>
|
||||
<a href="/capture" class="nav-item {{ 'active' if active_nav == 'capture' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
||||
Capture
|
||||
|
||||
32
templates/weblink_folder_form.html
Normal file
32
templates/weblink_folder_form.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ page_title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="/weblinks/folders/create">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Folder Name *</label>
|
||||
<input type="text" name="name" class="form-input" required placeholder="Folder name...">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Parent Folder</label>
|
||||
<select name="parent_id" class="form-select">
|
||||
<option value="">None (top-level)</option>
|
||||
{% for f in parent_folders %}
|
||||
<option value="{{ f.id }}">{{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Create Folder</button>
|
||||
<a href="/weblinks" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
50
templates/weblink_form.html
Normal file
50
templates/weblink_form.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ page_title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="{{ '/weblinks/' ~ item.id ~ '/edit' if item else '/weblinks/create' }}">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Label *</label>
|
||||
<input type="text" name="label" class="form-input" required
|
||||
value="{{ item.label if item else '' }}" placeholder="Display name...">
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">URL *</label>
|
||||
<input type="url" name="url" class="form-input" required
|
||||
value="{{ item.url if item else '' }}" placeholder="https://...">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Folder</label>
|
||||
<select name="folder_id" class="form-select">
|
||||
<option value="">None</option>
|
||||
{% for f in folders %}
|
||||
<option value="{{ f.id }}" {{ 'selected' if prefill_folder_id == f.id|string }}>{{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tags</label>
|
||||
<input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ..."
|
||||
value="{{ item.tags|join(', ') if item and item.tags else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-textarea" rows="2">{{ item.description if item and item.description else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Add Weblink' }}</button>
|
||||
<a href="/weblinks" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
76
templates/weblinks.html
Normal file
76
templates/weblinks.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Weblinks<span class="page-count">{{ items|length }}</span></h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="/weblinks/folders/create" class="btn btn-secondary">+ New Folder</a>
|
||||
<a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">+ New Weblink</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weblinks-layout">
|
||||
<!-- Folder sidebar -->
|
||||
<div class="weblinks-folders">
|
||||
<a href="/weblinks" class="weblink-folder-item {{ 'active' if not current_folder_id }}">
|
||||
All Weblinks
|
||||
</a>
|
||||
{% for folder in top_folders %}
|
||||
<a href="/weblinks?folder_id={{ folder.id }}" class="weblink-folder-item {{ 'active' if current_folder_id == folder.id|string }}">
|
||||
{{ folder.name }}
|
||||
{% if folder.link_count %}<span class="badge" style="margin-left: auto;">{{ folder.link_count }}</span>{% endif %}
|
||||
</a>
|
||||
{% for child in child_folder_map.get(folder.id|string, []) %}
|
||||
<a href="/weblinks?folder_id={{ child.id }}" class="weblink-folder-item {{ 'active' if current_folder_id == child.id|string }}" style="padding-left: 28px;">
|
||||
{{ child.name }}
|
||||
{% if child.link_count %}<span class="badge" style="margin-left: auto;">{{ child.link_count }}</span>{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Weblinks list -->
|
||||
<div class="weblinks-content">
|
||||
{% if current_folder %}
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 style="font-size: 1rem; font-weight: 600;">{{ current_folder.name }}</h2>
|
||||
<form action="/weblinks/folders/{{ current_folder.id }}/delete" method="post"
|
||||
data-confirm="Delete folder '{{ current_folder.name }}'?" style="display:inline">
|
||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Delete Folder</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if items %}
|
||||
<div class="card">
|
||||
{% for item in items %}
|
||||
<div class="list-row">
|
||||
<span class="row-title">
|
||||
<a href="{{ item.url }}" target="_blank" rel="noopener">{{ item.label }}</a>
|
||||
</span>
|
||||
<span class="row-meta text-xs" style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
{{ item.url }}
|
||||
</span>
|
||||
{% if item.tags %}
|
||||
{% for tag in item.tags %}
|
||||
<span class="row-tag">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<div class="row-actions">
|
||||
<a href="/weblinks/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||
<form action="/weblinks/{{ item.id }}/delete" method="post" data-confirm="Delete this weblink?" style="display:inline">
|
||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🔗</div>
|
||||
<div class="empty-state-text">No weblinks{{ ' in this folder' if current_folder }} yet</div>
|
||||
<a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">Add Weblink</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user