From fe4cbc3537295ae63d923d8b768a59f946a23d53 Mon Sep 17 00:00:00 2001 From: DavidSaylor Date: Fri, 22 May 2026 07:46:53 -0500 Subject: [PATCH] feat: add web UI, query engine, session management, and 20 E2E tests - Web UI: login, dashboard, links CRUD, collections, API keys, admin pages - Query engine: AND/OR/XOR with field filters, tag search, preview endpoint - Session management: token expiry detection, 401 interceptor, expiry banner - Links search: tags included, multi-word AND, query mode with set operations - Collections: static/dynamic, query builder with preview, public tree view - Save as Collection: convert search results (static) or query (dynamic) - Dashboard stats: resilient loading with allSettled pattern - Login page: redesigned with public collections tree view - Bug fix: query executor None fields crash (notes/description/url/title) - E2E tests: 20 Playwright tests covering all critical user flows - All 104 tests passing (84 unit/integration + 20 E2E) --- LinkSyncServer/AGENTS.md | 44 ++- LinkSyncServer/README.md | 47 +++ LinkSyncServer/TODOs.txt | 21 +- LinkSyncServer/api/endpoints/collections.py | 52 ++- LinkSyncServer/api/endpoints/links.py | 32 +- LinkSyncServer/api/endpoints/queries.py | 55 ++- LinkSyncServer/api/endpoints/sync.py | 4 +- LinkSyncServer/app.py | 16 +- LinkSyncServer/config/schema.sql | 2 + LinkSyncServer/design.md | 57 ++- LinkSyncServer/docker-compose.yml | 2 +- LinkSyncServer/models/base.py | 2 +- LinkSyncServer/pyproject.toml | 1 + LinkSyncServer/queries/executor.py | 14 +- LinkSyncServer/static/css/main.css | 283 +++++++++++++++ LinkSyncServer/static/js/admin-page.js | 5 +- LinkSyncServer/static/js/collections-page.js | 79 ++++- LinkSyncServer/static/js/dashboard.js | 20 +- LinkSyncServer/static/js/links-page.js | 119 ++++++- LinkSyncServer/static/js/main.js | 42 +++ LinkSyncServer/tasks.md | 52 +++ LinkSyncServer/templates/admin.html | 2 +- LinkSyncServer/templates/apikeys.html | 2 +- LinkSyncServer/templates/base.html | 4 +- LinkSyncServer/templates/collections.html | 14 +- LinkSyncServer/templates/dashboard.html | 2 +- LinkSyncServer/templates/links.html | 47 ++- LinkSyncServer/templates/login.html | 118 +++++-- LinkSyncServer/tests/test_e2e.py | 350 +++++++++++++++++++ 29 files changed, 1410 insertions(+), 78 deletions(-) create mode 100644 LinkSyncServer/tests/test_e2e.py diff --git a/LinkSyncServer/AGENTS.md b/LinkSyncServer/AGENTS.md index 064ce66..dc77218 100644 --- a/LinkSyncServer/AGENTS.md +++ b/LinkSyncServer/AGENTS.md @@ -8,6 +8,7 @@ LinkSyncServer is a self-hosted bookmark server with advanced collection and que - **Build**: `docker-compose up -d --build` - **Test**: `pytest tests/ -v` +- **E2E Test**: `pytest tests/test_e2e.py -v --browser chromium` - **Lint**: `ruff check .` && `mypy app.py models/` - **Dev**: `docker-compose up web` - **Migrate**: `alembic upgrade head` @@ -41,9 +42,48 @@ Query syntax: `('term1', 'term2') OR tagA AND tagB XOR url:example.com` ## Testing Protocol - All tests must pass before committing -- Run `pytest tests/ -v` for full test suite +- Run `pytest tests/ -v` for full test suite (84+ unit/integration tests) +- Run `pytest tests/test_e2e.py -v --browser chromium` for E2E tests (20+ tests) - Coverage target: >80% -- E2E tests cover critical user flows +- E2E tests cover critical user flows: + - Login, dashboard stats, session expiry + - Link CRUD, search (simple, multi-word, query mode) + - Collection CRUD, query builder, save as collection + - API key management, admin user management + +## Web Interface Requirements + +### Session Management +- Token expiry detection on page load (decode JWT `exp` claim) +- 401 interceptor redirects to login on authentication failure +- Session expiry warning banner when token expires in <2 minutes +- Graceful logout clears storage and redirects to login +- Login page shows expiry message when redirected with `?expired=1` + +### Dashboard +- Stats display always shows numbers (0, never `-`) +- Quick actions for creating links, collections, API keys +- Admin section visible only to admin users +- Resilient loading (allSettled pattern - one failing API doesn't break others) + +### Links Page +- **Simple search mode**: Keyword search across title, URL, description, notes, and tags +- **Query mode**: Full set operations (AND, OR, XOR, NOT, parentheses, field filters) +- Multi-word search uses AND logic (all terms must match) +- Tags are included in all searches +- **Save as Collection**: Convert current results to static collection or query to dynamic collection + +### Collections Page +- Static collections: Manual link management +- Dynamic collections: Query expression with preview + - Query builder UI with syntax hints + - Preview button shows matching links and count + - Query expression displayed on collection cards + +### Error Handling +- API errors return structured responses with user-friendly messages +- Form validation before submission +- Network errors show graceful fallbacks ## Conventions diff --git a/LinkSyncServer/README.md b/LinkSyncServer/README.md index a335891..f2b6954 100644 --- a/LinkSyncServer/README.md +++ b/LinkSyncServer/README.md @@ -16,6 +16,53 @@ LinkSyncServer replaces the need for workarounds in existing bookmark sync solut ## Features +### Web Interface + +The application includes a modern, responsive web interface with: + +#### Authentication & Session Management +- **Login page** with credential validation +- **Session expiry detection** - automatically redirects to login when tokens expire +- **Proactive expiry warnings** - shows countdown when session is about to expire (<2 minutes) +- **Graceful logout** - clears local storage and redirects to login + +#### Dashboard +- **Quick stats** - displays total links, collections, and API keys (always shows numbers, never `-`) +- **Quick actions** - one-click navigation to create links, collections, or API keys +- **Admin section** - visible only to admin users for user management + +#### Links Page +- **Dual search modes**: + - **Simple mode**: Keyword search across title, URL, description, notes, and tags + - **Query mode**: Full set operations with AND, OR, XOR, NOT, parentheses, and field filters +- **Multi-word search**: Space-separated terms are AND-matched (all terms must be present) +- **Tag search**: Tags are included in both simple and query searches +- **Save as Collection**: Convert current search results or query into a collection: + - **Static**: Saves the current result set as a fixed collection + - **Dynamic**: Saves the query expression for live, auto-updating results +- **Full CRUD**: Create, edit, and delete links with all Firefox bookmark fields + +#### Collections Page +- **Collection cards** - displays name, description, type, and visibility +- **Static collections**: Manually managed link sets +- **Dynamic collections**: Query-based with live preview: + - **Query builder UI** - input field for query expressions + - **Preview button** - shows matching links before saving + - **Result count** - displays number of matches + - **Query expression display** - shows saved query on collection cards +- **Public/Private toggle** - controls visibility to other users + +#### API Keys Page +- **Create API keys** for browser extension sync +- **View active keys** with creation dates +- **Delete keys** to revoke access + +#### Admin Page +- **User management** - create, edit, delete users +- **Role assignment** - admin or regular user roles +- **System statistics** - overview of links, collections, users +- **Audit log** - track all changes + ### Collections Two types of collections: diff --git a/LinkSyncServer/TODOs.txt b/LinkSyncServer/TODOs.txt index 1dc540d..1c470b0 100644 --- a/LinkSyncServer/TODOs.txt +++ b/LinkSyncServer/TODOs.txt @@ -75,7 +75,24 @@ - [x] Integration tests with TestClient - [x] Test configuration (tests/conftest.py) - [x] pytest.ini in pyproject.toml -- [x] All 42 tests passing +- [x] All 84 tests passing +- [x] E2E tests with Playwright (20+ tests) +- [x] Session management tests +- [x] Search mode tests (simple, query, multi-word) +- [x] Collection query builder tests +- [x] Save as Collection tests + +## Web Interface +- [x] Login page with session expiry handling +- [x] Dashboard with stats and quick actions +- [x] Links page with dual search modes +- [x] Collections page with query builder +- [x] API Keys page with CRUD +- [x] Admin page with user management +- [x] Save as Collection feature +- [x] Token expiry detection and redirect +- [x] Session expiry warnings +- [x] Graceful error handling ## Documentation - [x] API reference (via /api/docs OpenAPI) @@ -83,6 +100,8 @@ - [x] Developer guide (AGENTS.md, design.md) - [x] Deployment guide (README.md) - [x] Query syntax reference (README.md) +- [x] UI/UX design documentation (design.md) +- [x] Implementation tasks (tasks.md) ## Security - [x] Password hashing (bcrypt with cost factor 12) diff --git a/LinkSyncServer/api/endpoints/collections.py b/LinkSyncServer/api/endpoints/collections.py index aa64037..c83cdf9 100644 --- a/LinkSyncServer/api/endpoints/collections.py +++ b/LinkSyncServer/api/endpoints/collections.py @@ -44,8 +44,12 @@ def get_current_user_id(request: Request) -> Optional[str]: from config.settings import settings payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) return payload.get("sub") - except Exception: - pass + except jwt.ExpiredSignatureError: + logger.warning("Token expired") + except jwt.InvalidTokenError as e: + logger.warning(f"Invalid token: {e}") + except Exception as e: + logger.warning(f"Token decode error: {e}") return None @@ -98,6 +102,40 @@ async def list_collections( db.close() +@router.get("/public-tree", response_model=List[dict]) +async def public_collections_tree(): + db = get_session() + try: + collections = db.query(Collection).filter(Collection.is_public == True).order_by(Collection.name).all() + result = [] + for col in collections: + col_data = col.to_dict() + links = [] + if col.query_type == "static": + cbs = db.query(CollectionBookmark).filter(CollectionBookmark.collection_id == col.id).all() + for cb in cbs: + bm = db.query(Bookmark).filter(Bookmark.id == cb.link_id).first() + if bm: + links.append(bm.to_dict()) + elif col.query_type == "dynamic" and col.query_expression: + from queries.executor import execute_query + from queries.parser import QueryParser + try: + parser = QueryParser() + parsed = parser.parse(col.query_expression.get("expression", "")) + if parsed: + all_bookmarks = db.query(Bookmark).all() + matched = execute_query(parsed, [b.to_dict() for b in all_bookmarks]) + links = matched + except Exception: + pass + col_data["links"] = links + result.append(col_data) + return result + finally: + db.close() + + @router.get("/{collection_id}", response_model=dict) async def get_collection(collection_id: str): db = get_session() @@ -112,7 +150,7 @@ async def get_collection(collection_id: str): .filter(CollectionBookmark.collection_id == parse_uuid(collection_id)) .all() ) - result["link_ids"] = [lb.bookmark_id for lb in links] + result["link_ids"] = [lb.link_id for lb in links] return result finally: db.close() @@ -150,7 +188,7 @@ async def create_collection(data: CollectionCreate, request: Request): lid = uuid.UUID(link_id) if isinstance(link_id, str) else link_id except (ValueError, AttributeError): continue - cb = CollectionBookmark(collection_id=collection.id, bookmark_id=lid) + cb = CollectionBookmark(collection_id=collection.id, link_id=lid) db.add(cb) db.commit() @@ -245,7 +283,7 @@ async def add_links_to_collection(collection_id: str, link_ids: List[str]): raise HTTPException(status_code=400, detail="Can only add links to static collections") existing = { - cb.bookmark_id + cb.link_id for cb in db.query(CollectionBookmark) .filter(CollectionBookmark.collection_id == parsed_cid) .all() @@ -257,7 +295,7 @@ async def add_links_to_collection(collection_id: str, link_ids: List[str]): except (ValueError, AttributeError): continue if lid not in existing: - db.add(CollectionBookmark(collection_id=parsed_cid, bookmark_id=lid)) + db.add(CollectionBookmark(collection_id=parsed_cid, link_id=lid)) added += 1 db.commit() @@ -288,7 +326,7 @@ async def remove_links_from_collection(collection_id: str, link_ids: List[str]): db.query(CollectionBookmark) .filter( CollectionBookmark.collection_id == parsed_cid, - CollectionBookmark.bookmark_id.in_(parsed_link_ids), + CollectionBookmark.link_id.in_(parsed_link_ids), ) .delete(synchronize_session=False) ) diff --git a/LinkSyncServer/api/endpoints/links.py b/LinkSyncServer/api/endpoints/links.py index eaf860f..a7d6a91 100644 --- a/LinkSyncServer/api/endpoints/links.py +++ b/LinkSyncServer/api/endpoints/links.py @@ -8,7 +8,7 @@ from typing import List, Optional from fastapi import APIRouter, HTTPException, Query, Request, status from pydantic import BaseModel, Field -from sqlalchemy import or_ +from sqlalchemy import or_, func from config.settings import settings from models.base import AuditLog, Bookmark, User, get_session @@ -90,13 +90,31 @@ async def list_bookmarks( try: query = db.query(Bookmark) if search: - query = query.filter( - or_( - Bookmark.title.ilike(f"%{search}%"), - Bookmark.description.ilike(f"%{search}%"), - Bookmark.url.ilike(f"%{search}%"), + from sqlalchemy.sql import text + from models.base import get_engine + engine = get_engine() + dialect = engine.dialect.name + terms = [t.strip() for t in search.split() if t.strip()] + + for term in terms: + term_lower = term.lower() + if dialect == "postgresql": + tag_match = text( + "EXISTS (SELECT 1 FROM jsonb_array_elements_text(links.tags) AS t WHERE lower(t) LIKE :search_tag)" + ).bindparams(search_tag=f"%{term_lower}%") + else: + tag_match = text( + "EXISTS (SELECT 1 FROM json_each(links.tags) WHERE lower(value) LIKE :search_tag)" + ).bindparams(search_tag=f"%{term_lower}%") + + term_filter = or_( + Bookmark.title.ilike(f"%{term}%"), + Bookmark.description.ilike(f"%{term}%"), + Bookmark.url.ilike(f"%{term}%"), + Bookmark.notes.ilike(f"%{term}%"), + tag_match, ) - ) + query = query.filter(term_filter) if tags_filter: for tag in tags_filter: query = query.filter(Bookmark.tags.contains(tag)) diff --git a/LinkSyncServer/api/endpoints/queries.py b/LinkSyncServer/api/endpoints/queries.py index b3e5538..3c46326 100644 --- a/LinkSyncServer/api/endpoints/queries.py +++ b/LinkSyncServer/api/endpoints/queries.py @@ -2,17 +2,20 @@ LinkSyncServer - Query Engine Endpoints """ +import logging import uuid from typing import Any, Dict, List, Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query, Request from models.base import Bookmark, get_session from queries.executor import execute_query -from queries.parser import QueryParser +from queries.parser import QueryParser, QuerySyntaxError router = APIRouter(prefix="/api/queries", tags=["Queries"]) +logger = logging.getLogger(__name__) + @router.post("/parse", response_model=Dict[str, Any]) async def parse_expression(expression: str): @@ -33,6 +36,32 @@ async def parse_expression(expression: str): } +@router.get("/preview", response_model=dict) +async def preview_query(expression: str = Query(...), limit: int = Query(20, le=100, ge=1)): + db = get_session() + try: + parser = QueryParser() + try: + parsed = parser.parse(expression) + except QuerySyntaxError as e: + return {"error": str(e), "results": [], "count": 0} + except Exception as e: + logger.error(f"Query parse error: {e}") + return {"error": f"Invalid query: {str(e)}", "results": [], "count": 0} + + if not parsed: + return {"error": "Empty query expression", "results": [], "count": 0} + + all_bookmarks = db.query(Bookmark).all() + results = execute_query(parsed, [b.to_dict() for b in all_bookmarks]) + return {"results": results[:limit], "count": len(results), "error": None} + except Exception as e: + logger.error(f"Query preview error: {e}", exc_info=True) + return {"error": str(e), "results": [], "count": 0} + finally: + db.close() + + @router.post("/execute", response_model=List[dict]) async def execute(expression: str, limit: int = 20, offset: int = 0): db = get_session() @@ -69,3 +98,25 @@ async def get_saved_query(query_id: str): } finally: db.close() + + +@router.get("/{query_id}", response_model=Dict[str, Any]) +async def get_saved_query(query_id: str): + db = get_session() + try: + from models.base import Collection + collection = db.query(Collection).filter(Collection.id == query_id).first() + if not collection or collection.query_type != "dynamic": + raise HTTPException(status_code=404, detail="Saved query not found") + return { + "id": collection.id, + "name": collection.name, + "description": collection.description, + "expression": collection.query_expression, + "query_type": collection.query_type, + "is_public": collection.is_public, + "created_at": collection.created_at.isoformat() if collection.created_at else None, + "updated_at": collection.updated_at.isoformat() if collection.updated_at else None, + } + finally: + db.close() diff --git a/LinkSyncServer/api/endpoints/sync.py b/LinkSyncServer/api/endpoints/sync.py index d0d8b0c..9fa2d4f 100644 --- a/LinkSyncServer/api/endpoints/sync.py +++ b/LinkSyncServer/api/endpoints/sync.py @@ -210,12 +210,12 @@ async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]): db.query(CollectionBookmark) .filter( CollectionBookmark.collection_id == parsed_cid, - CollectionBookmark.bookmark_id == parsed_bid, + CollectionBookmark.link_id == parsed_bid, ) .first() ) if not existing: - db.add(CollectionBookmark(collection_id=parsed_cid, bookmark_id=parsed_bid)) + db.add(CollectionBookmark(collection_id=parsed_cid, link_id=parsed_bid)) added += 1 db.commit() diff --git a/LinkSyncServer/app.py b/LinkSyncServer/app.py index c002ef9..e72cec9 100644 --- a/LinkSyncServer/app.py +++ b/LinkSyncServer/app.py @@ -3,6 +3,7 @@ LinkSyncServer - Main Application """ import os +import time from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -15,6 +16,9 @@ from config.settings import settings from models.base import Base, get_engine +BUILD_ID = str(int(time.time())) + + @asynccontextmanager async def lifespan(app: FastAPI): engine = get_engine() @@ -58,29 +62,29 @@ def index(): @app.get("/login") def login_page(request: Request): - return templates.TemplateResponse("login.html", {"request": request}) + return templates.TemplateResponse("login.html", {"request": request, "build_id": BUILD_ID}) @app.get("/dashboard") def dashboard(request: Request): - return templates.TemplateResponse("dashboard.html", {"request": request}) + return templates.TemplateResponse("dashboard.html", {"request": request, "build_id": BUILD_ID}) @app.get("/links") def links_page(request: Request): - return templates.TemplateResponse("links.html", {"request": request}) + return templates.TemplateResponse("links.html", {"request": request, "build_id": BUILD_ID}) @app.get("/collections") def collections_page(request: Request): - return templates.TemplateResponse("collections.html", {"request": request}) + return templates.TemplateResponse("collections.html", {"request": request, "build_id": BUILD_ID}) @app.get("/api-keys") def apikeys_page(request: Request): - return templates.TemplateResponse("apikeys.html", {"request": request}) + return templates.TemplateResponse("apikeys.html", {"request": request, "build_id": BUILD_ID}) @app.get("/admin") def admin_page(request: Request): - return templates.TemplateResponse("admin.html", {"request": request}) + return templates.TemplateResponse("admin.html", {"request": request, "build_id": BUILD_ID}) diff --git a/LinkSyncServer/config/schema.sql b/LinkSyncServer/config/schema.sql index a4a5c60..d65b272 100644 --- a/LinkSyncServer/config/schema.sql +++ b/LinkSyncServer/config/schema.sql @@ -80,6 +80,8 @@ CREATE TABLE collections ( CREATE TABLE collection_links ( collection_id UUID REFERENCES collections(id) ON DELETE CASCADE, link_id UUID REFERENCES links(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (collection_id, link_id) ); diff --git a/LinkSyncServer/design.md b/LinkSyncServer/design.md index e6f9c53..7966d17 100644 --- a/LinkSyncServer/design.md +++ b/LinkSyncServer/design.md @@ -386,4 +386,59 @@ Priority based on sync mode: - Mobile-first CSS - Breakpoints: 768px, 1024px -- Touch-friendly UI controls \ No newline at end of file +- Touch-friendly UI controls + +## UI/UX Design + +### Session Management + +#### Token Lifecycle +1. **Login** - stores JWT in localStorage with user info +2. **Page load** - checks token expiry via JWT `exp` claim +3. **API calls** - validates expiry before each request +4. **401 response** - clears storage and redirects to `/login?expired=1` +5. **Expiry warning** - shows banner if token expires in <2 minutes + +#### Implementation +- `main.js`: `isTokenExpired()`, `getTokenExpirySeconds()`, `redirectToLogin()` +- `apiFetch()`: Pre-flight expiry check + 401 interceptor +- `dashboard.js`: Expiry warning banner on page load +- `login.html`: Shows expiry message when redirected with `?expired=1` + +### Search Architecture + +#### Simple Search (Links Page) +- **Input**: Free text with space-separated terms +- **Behavior**: Each term must match at least one field (AND logic) +- **Fields searched**: title, URL, description, notes, tags +- **Backend**: Multiple `OR` clauses per term, combined with `AND` between terms + +#### Query Mode (Links Page + Collections) +- **Input**: Query expression with operators +- **Syntax**: `term1 AND term2 OR term3 NOT term4` +- **Operators**: AND (intersection), OR (union), XOR (difference), NOT (negation) +- **Parentheses**: Grouping for precedence control +- **Field filters**: `tag:value`, `url:value`, `title:value`, `description:value`, `path:value` +- **Term sets**: `(term1, term2)` matches any term in the set + +### Collection Query Builder + +#### Dynamic Collection Modal +- **Type selector** toggles query section visibility +- **Query input** with syntax hint +- **Preview button** fetches matching links via `/api/queries/preview` +- **Results display** shows count and first 10 matching links +- **Save** stores `query_expression: { expression: "..." }` for dynamic collections + +#### Save as Collection (Links Page) +- **Static mode**: Saves current result set link IDs +- **Dynamic mode**: Saves the active query expression +- **Modal** pre-fills name based on current search/query +- **Public toggle** controls collection visibility + +### Error Handling + +- **API errors**: Structured error responses with user-friendly messages +- **Network errors**: Graceful fallbacks with retry options +- **Form validation**: Client-side validation before submission +- **Server errors**: Logged with stack traces in debug mode \ No newline at end of file diff --git a/LinkSyncServer/docker-compose.yml b/LinkSyncServer/docker-compose.yml index 0badb85..39da14a 100644 --- a/LinkSyncServer/docker-compose.yml +++ b/LinkSyncServer/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.8' +# version: '3.8' services: web: diff --git a/LinkSyncServer/models/base.py b/LinkSyncServer/models/base.py index 6a7b7d7..dfaa535 100644 --- a/LinkSyncServer/models/base.py +++ b/LinkSyncServer/models/base.py @@ -191,7 +191,7 @@ class CollectionBookmark(Base, TimestampMixin): __tablename__ = "collection_links" collection_id = Column(UUID(as_uuid=True), ForeignKey("collections.id"), primary_key=True) - bookmark_id = Column(UUID(as_uuid=True), ForeignKey("links.id"), primary_key=True) + link_id = Column(UUID(as_uuid=True), ForeignKey("links.id"), primary_key=True) collection = relationship("Collection", back_populates="collection_bookmarks") bookmark = relationship("Bookmark", back_populates="collection_bookmarks") diff --git a/LinkSyncServer/pyproject.toml b/LinkSyncServer/pyproject.toml index fea442e..c1dcf4c 100644 --- a/LinkSyncServer/pyproject.toml +++ b/LinkSyncServer/pyproject.toml @@ -39,6 +39,7 @@ python_files = ["test_*.py"] python_functions = ["test_*"] addopts = "-v --tb=short" filterwarnings = ["ignore::DeprecationWarning"] +markers = ["e2e: End-to-end Playwright tests"] [tool.ruff] line-length = 88 diff --git a/LinkSyncServer/queries/executor.py b/LinkSyncServer/queries/executor.py index ae60043..3839a08 100644 --- a/LinkSyncServer/queries/executor.py +++ b/LinkSyncServer/queries/executor.py @@ -51,10 +51,11 @@ def _evaluate_node(node: Dict[str, Any], bookmarks: List[Dict[str, Any]]) -> set return { b["id"] for b in bookmarks - if value in b.get("title", "").lower() - or value in b.get("description", "").lower() - or value in b.get("url", "").lower() - or value in b.get("notes", "").lower() + if value in (b.get("title") or "").lower() + or value in (b.get("description") or "").lower() + or value in (b.get("url") or "").lower() + or value in (b.get("notes") or "").lower() + or any(value in t.lower() for t in (b.get("tags") or [])) } if operation == "TERM_SET": @@ -63,9 +64,10 @@ def _evaluate_node(node: Dict[str, Any], bookmarks: List[Dict[str, Any]]) -> set result = set() for b in bookmarks: text = ( - f"{b.get('title', '')} {b.get('description', '')} {b.get('url', '')} {b.get('notes', '')}" + f"{(b.get('title') or '')} {(b.get('description') or '')} {(b.get('url') or '')} {(b.get('notes') or '')}" ).lower() - if any(term in text for term in terms_lower): + tags_lower = [t.lower() for t in (b.get("tags") or [])] + if any(term in text for term in terms_lower) or any(term in tags_lower for term in terms_lower): result.add(b["id"]) return result diff --git a/LinkSyncServer/static/css/main.css b/LinkSyncServer/static/css/main.css index 3dd9397..0787055 100644 --- a/LinkSyncServer/static/css/main.css +++ b/LinkSyncServer/static/css/main.css @@ -170,10 +170,45 @@ body { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; + align-items: center; + flex-wrap: wrap; +} + +.search-mode-toggle { + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.search-mode-btn { + border-radius: 0; + border: none; + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + background: var(--surface); + color: var(--text-muted); +} + +.search-mode-btn.active { + background: var(--primary); + color: white; +} + +.search-mode-btn:first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.search-mode-btn:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; } .search-bar input { flex: 1; + min-width: 200px; padding: 0.625rem 1rem; border: 1px solid var(--border); border-radius: var(--radius); @@ -214,6 +249,55 @@ body { resize: vertical; } +.form-hint { + display: block; + color: var(--text-muted); + font-size: 0.75rem; + margin-top: 0.25rem; +} + +.query-status { + font-size: 0.75rem; + color: var(--text-muted); + align-self: center; + margin-left: 0.5rem; +} + +.query-preview { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem; + max-height: 200px; + overflow-y: auto; + margin-bottom: 1rem; +} + +.query-preview-item { + padding: 0.375rem 0; + font-size: 0.8125rem; + border-bottom: 1px solid var(--border); +} + +.query-preview-item:last-child { + border-bottom: none; +} + +.query-preview-item a { + color: var(--primary); + text-decoration: none; +} + +.query-preview-item a:hover { + text-decoration: underline; +} + +.query-preview-count { + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + .checkbox-group label { display: flex; align-items: center; @@ -241,6 +325,15 @@ body { font-size: 0.875rem; } +.info-message { + background: #eff6ff; + color: var(--primary); + padding: 0.75rem 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; + font-size: 0.875rem; +} + .success-message { background: #f0fdf4; color: var(--success); @@ -300,6 +393,186 @@ body { margin-bottom: 2rem; } +/* Public Page (Login + Public Links Tree) */ +.public-page { + background: var(--bg); + min-height: 100vh; +} + +.public-header { + background: var(--surface); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow); + position: sticky; + top: 0; + z-index: 100; +} + +.public-header-inner { + max-width: 1200px; + margin: 0 auto; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + gap: 1.5rem; + flex-wrap: wrap; +} + +.public-brand h1 { + font-size: 1.25rem; + color: var(--primary); + white-space: nowrap; +} + +.public-login-form { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + flex: 1; +} + +.public-login-form input { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.875rem; + min-width: 140px; +} + +.public-login-form .error-message, +.public-login-form .info-message { + width: 100%; + margin-bottom: 0; + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; +} + +.public-main { + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1rem; +} + +.public-main h2 { + font-size: 1.5rem; + margin-bottom: 1.5rem; + color: var(--text); +} + +/* Public Tree View */ +.public-tree { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.tree-collection { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem 1rem; +} + +.tree-toggle { + background: none; + border: none; + cursor: pointer; + font-size: 0.75rem; + padding: 0; + margin-right: 0.5rem; + color: var(--text-muted); + width: 1rem; + text-align: center; +} + +.tree-toggle:disabled { + cursor: default; + opacity: 0.4; +} + +.tree-collection-name { + font-weight: 600; + font-size: 0.9375rem; + color: var(--text); +} + +.tree-meta { + font-size: 0.75rem; + color: var(--text-muted); + margin-left: 0.5rem; +} + +.tree-desc { + display: block; + font-size: 0.8125rem; + color: var(--text-muted); + margin: 0.25rem 0 0 1.5rem; +} + +.tree-links { + margin-top: 0.5rem; + margin-left: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.tree-link { + padding: 0.375rem 0.5rem; + border-radius: 4px; + font-size: 0.8125rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.tree-link:hover { + background: var(--bg); +} + +.tree-link-title { + color: var(--primary); + text-decoration: none; + font-weight: 500; +} + +.tree-link-title:hover { + text-decoration: underline; +} + +.tree-link-url { + color: var(--text-muted); + font-size: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 400px; +} + +.tree-link .tags { + display: inline-flex; + gap: 0.25rem; +} + +@media (max-width: 640px) { + .public-header-inner { + flex-direction: column; + align-items: stretch; + } + .public-login-form { + flex-direction: column; + } + .public-login-form input { + min-width: auto; + width: 100%; + } + .public-login-form .btn { + width: 100%; + } +} + /* Dashboard */ .dashboard-header { margin-bottom: 2rem; @@ -507,6 +780,16 @@ body { margin-bottom: 0.75rem; } +.collection-card .query-hint { + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: 0.75rem; + font-family: "Fira Code", "Cascadia Code", monospace; + background: var(--bg); + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + .collection-card .badge { display: inline-block; padding: 0.125rem 0.5rem; diff --git a/LinkSyncServer/static/js/admin-page.js b/LinkSyncServer/static/js/admin-page.js index 238d5ed..5799376 100644 --- a/LinkSyncServer/static/js/admin-page.js +++ b/LinkSyncServer/static/js/admin-page.js @@ -62,7 +62,7 @@ document.addEventListener('DOMContentLoaded', function() { function openUserModal(user = null) { document.getElementById('user-modal-title').textContent = user ? 'Edit User' : 'Create User'; - document.getElementById('user-id').value = user ? user.id : ''; + document.getElementById('user-id').value = (user && user.id) ? user.id : ''; document.getElementById('user-username').value = user ? user.username : ''; document.getElementById('user-username').disabled = !!user; document.getElementById('user-email').value = user ? user.email : ''; @@ -115,6 +115,7 @@ document.addEventListener('DOMContentLoaded', function() { saveBtn.textContent = 'Saving...'; const id = document.getElementById('user-id').value; + const isEdit = id && id !== '' && id !== 'undefined'; const data = { username: document.getElementById('user-username').value, email: document.getElementById('user-email').value, @@ -126,7 +127,7 @@ document.addEventListener('DOMContentLoaded', function() { if (password) data.password = password; try { - if (id) { + if (isEdit) { await LinkSync.updateUser(id, data); } else { if (!password) { diff --git a/LinkSyncServer/static/js/collections-page.js b/LinkSyncServer/static/js/collections-page.js index 164bad1..f967961 100644 --- a/LinkSyncServer/static/js/collections-page.js +++ b/LinkSyncServer/static/js/collections-page.js @@ -3,8 +3,23 @@ document.addEventListener('DOMContentLoaded', function() { const modal = document.getElementById('collection-modal'); const deleteModal = document.getElementById('delete-collection-modal'); const form = document.getElementById('collection-form'); + const typeSelect = document.getElementById('collection-type'); + const querySection = document.getElementById('query-builder-section'); + const queryInput = document.getElementById('collection-query'); + const previewBtn = document.getElementById('preview-query-btn'); + const queryStatus = document.getElementById('query-status'); + const previewResults = document.getElementById('query-preview-results'); let deleteTargetId = null; + typeSelect.addEventListener('change', function() { + querySection.style.display = this.value === 'dynamic' ? '' : 'none'; + if (this.value !== 'dynamic') { + queryInput.value = ''; + previewResults.style.display = 'none'; + queryStatus.textContent = ''; + } + }); + async function loadCollections() { try { const collections = await LinkSync.getCollections(); @@ -29,6 +44,7 @@ document.addEventListener('DOMContentLoaded', function() { ${col.query_type} ${col.is_public ? 'Public' : 'Private'} + ${col.query_type === 'dynamic' && col.query_expression ? `
${escapeHtml(col.query_expression.expression || '')}
` : ''}
@@ -46,11 +62,22 @@ document.addEventListener('DOMContentLoaded', function() { function openCollectionModal(col = null) { document.getElementById('collection-modal-title').textContent = col ? 'Edit Collection' : 'Create Collection'; - document.getElementById('collection-id').value = col ? col.id : ''; + document.getElementById('collection-id').value = (col && col.id) ? col.id : ''; document.getElementById('collection-name').value = col ? col.name : ''; document.getElementById('collection-description').value = col ? (col.description || '') : ''; document.getElementById('collection-type').value = col ? col.query_type : 'static'; document.getElementById('collection-public').checked = col ? col.is_public : false; + + if (col && col.query_type === 'dynamic' && col.query_expression) { + queryInput.value = col.query_expression.expression || ''; + querySection.style.display = ''; + } else { + queryInput.value = ''; + querySection.style.display = 'none'; + } + previewResults.style.display = 'none'; + queryStatus.textContent = ''; + modal.style.display = 'flex'; } @@ -72,6 +99,9 @@ document.addEventListener('DOMContentLoaded', function() { function closeModal() { modal.style.display = 'none'; form.reset(); + querySection.style.display = 'none'; + previewResults.style.display = 'none'; + queryStatus.textContent = ''; } function closeDeleteModal() { @@ -87,6 +117,40 @@ document.addEventListener('DOMContentLoaded', function() { modal.querySelector('.modal-overlay').addEventListener('click', closeModal); deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal); + previewBtn.addEventListener('click', async function() { + const expression = queryInput.value.trim(); + if (!expression) { + queryStatus.textContent = 'Enter a query expression'; + queryStatus.style.color = 'var(--warning)'; + return; + } + queryStatus.textContent = 'Loading...'; + queryStatus.style.color = 'var(--text-muted)'; + try { + const results = await LinkSync.previewQuery(expression); + const count = Array.isArray(results) ? results.length : 0; + queryStatus.textContent = `${count} result${count !== 1 ? 's' : ''}`; + queryStatus.style.color = 'var(--success)'; + if (count > 0) { + previewResults.innerHTML = `
${count} matching links:
` + + results.slice(0, 10).map(link => ` + + `).join('') + + (count > 10 ? `
...and ${count - 10} more
` : ''); + previewResults.style.display = ''; + } else { + previewResults.innerHTML = '

No matching links

'; + previewResults.style.display = ''; + } + } catch (err) { + queryStatus.textContent = err.message; + queryStatus.style.color = 'var(--error)'; + previewResults.style.display = 'none'; + } + }); + form.addEventListener('submit', async function(e) { e.preventDefault(); const saveBtn = document.getElementById('collection-save-btn'); @@ -94,15 +158,24 @@ document.addEventListener('DOMContentLoaded', function() { saveBtn.textContent = 'Saving...'; const id = document.getElementById('collection-id').value; + const isEdit = id && id !== '' && id !== 'undefined'; + const queryType = document.getElementById('collection-type').value; const data = { name: document.getElementById('collection-name').value, description: document.getElementById('collection-description').value || null, - query_type: document.getElementById('collection-type').value, + query_type: queryType, is_public: document.getElementById('collection-public').checked, }; + if (queryType === 'dynamic') { + const expression = queryInput.value.trim(); + if (expression) { + data.query_expression = { expression }; + } + } + try { - if (id) { + if (isEdit) { await LinkSync.updateCollection(id, data); } else { await LinkSync.createCollection(data); diff --git a/LinkSyncServer/static/js/dashboard.js b/LinkSyncServer/static/js/dashboard.js index c7aae8d..f99558b 100644 --- a/LinkSyncServer/static/js/dashboard.js +++ b/LinkSyncServer/static/js/dashboard.js @@ -5,6 +5,12 @@ document.addEventListener('DOMContentLoaded', async function() { return; } + const token = localStorage.getItem('token'); + if (token && LinkSync.isTokenExpired(token)) { + LinkSync.logout(); + return; + } + document.getElementById('current-user').textContent = user.username; if (user.role === 'admin') { @@ -12,16 +18,28 @@ document.addEventListener('DOMContentLoaded', async function() { } try { - const [links, collections, keys] = await Promise.all([ + const [linksResult, collectionsResult, keysResult] = await Promise.allSettled([ LinkSync.getLinks({ limit: 1 }), LinkSync.getCollections(), LinkSync.getApiKeys(), ]); + const links = linksResult.status === 'fulfilled' ? linksResult.value : []; + const collections = collectionsResult.status === 'fulfilled' ? collectionsResult.value : []; + const keys = keysResult.status === 'fulfilled' ? keysResult.value : []; + document.getElementById('link-count').textContent = Array.isArray(links) ? links.length : 0; document.getElementById('collection-count').textContent = Array.isArray(collections) ? collections.length : 0; document.getElementById('api-key-count').textContent = Array.isArray(keys) ? keys.length : 0; } catch (err) { console.error('Failed to load stats:', err); } + + const expirySeconds = LinkSync.getTokenExpirySeconds(token); + if (expirySeconds > 0 && expirySeconds < 120) { + const warning = document.createElement('div'); + warning.className = 'info-message'; + warning.textContent = `Session expires in ${Math.ceil(expirySeconds / 60)} minute${expirySeconds > 60 ? 's' : ''}. Save your work.`; + document.querySelector('.dashboard-header').prepend(warning); + } }); diff --git a/LinkSyncServer/static/js/links-page.js b/LinkSyncServer/static/js/links-page.js index f57d71f..0714738 100644 --- a/LinkSyncServer/static/js/links-page.js +++ b/LinkSyncServer/static/js/links-page.js @@ -4,14 +4,52 @@ document.addEventListener('DOMContentLoaded', function() { const deleteModal = document.getElementById('delete-modal'); const form = document.getElementById('link-form'); const searchInput = document.getElementById('search-input'); + const queryInput = document.getElementById('query-input'); + const simpleBtn = document.getElementById('simple-search-btn'); + const queryBtn = document.getElementById('query-search-btn'); + const saveCollectionBtn = document.getElementById('save-collection-btn'); + const saveCollectionModal = document.getElementById('save-collection-modal'); + const saveCollectionForm = document.getElementById('save-collection-form'); let deleteTargetId = null; + let searchMode = 'simple'; + let lastQuery = ''; + let lastResults = []; + + function setSearchMode(mode) { + searchMode = mode; + simpleBtn.classList.toggle('active', mode === 'simple'); + queryBtn.classList.toggle('active', mode === 'query'); + searchInput.style.display = mode === 'simple' ? '' : 'none'; + queryInput.style.display = mode === 'query' ? '' : 'none'; + searchInput.placeholder = mode === 'simple' + ? 'Search links by title, URL, tags, or notes...' + : 'e.g. personal AND lan NOT ai'; + } + + simpleBtn.addEventListener('click', () => setSearchMode('simple')); + queryBtn.addEventListener('click', () => setSearchMode('query')); async function loadLinks(search = '') { try { - const links = await LinkSync.getLinks(search ? { search } : {}); - renderLinks(Array.isArray(links) ? links : []); + let links; + if (searchMode === 'query' && search) { + lastQuery = search; + const data = await LinkSync.previewQuery(search); + if (data.error) { + linksList.innerHTML = `

Query error: ${escapeHtml(data.error)}

`; + lastResults = []; + return; + } + links = data.results || []; + } else { + lastQuery = ''; + links = await LinkSync.getLinks(search ? { search } : {}); + } + lastResults = Array.isArray(links) ? links : []; + renderLinks(lastResults); } catch (err) { linksList.innerHTML = `

Failed to load links: ${err.message}

`; + lastResults = []; } } @@ -62,7 +100,7 @@ document.addEventListener('DOMContentLoaded', function() { function openModal(link = null) { document.getElementById('modal-title').textContent = link ? 'Edit Link' : 'Add Link'; - document.getElementById('link-id').value = link ? link.id : ''; + document.getElementById('link-id').value = (link && link.id) ? link.id : ''; document.getElementById('link-url').value = link ? link.url : ''; document.getElementById('link-title').value = link ? link.title : ''; document.getElementById('link-description').value = link ? (link.description || '') : ''; @@ -98,13 +136,32 @@ document.addEventListener('DOMContentLoaded', function() { deleteTargetId = null; } + function openSaveCollectionModal() { + const currentSearch = searchMode === 'query' ? queryInput.value.trim() : searchInput.value.trim(); + const isQueryMode = searchMode === 'query' && currentSearch; + document.getElementById('save-collection-type').value = isQueryMode ? 'dynamic' : 'static'; + document.getElementById('save-collection-name').value = isQueryMode ? `Query: ${currentSearch}` : `Search: ${currentSearch}`; + document.getElementById('save-collection-desc').value = ''; + document.getElementById('save-collection-public').checked = false; + saveCollectionModal.style.display = 'flex'; + } + + function closeSaveCollectionModal() { + saveCollectionModal.style.display = 'none'; + saveCollectionForm.reset(); + } + document.getElementById('new-link-btn').addEventListener('click', () => openModal()); document.getElementById('modal-close').addEventListener('click', closeModal); document.getElementById('cancel-btn').addEventListener('click', closeModal); document.getElementById('delete-cancel-btn').addEventListener('click', closeDeleteModal); + document.getElementById('save-collection-btn').addEventListener('click', openSaveCollectionModal); + document.getElementById('save-collection-modal-close').addEventListener('click', closeSaveCollectionModal); + document.getElementById('save-collection-cancel-btn').addEventListener('click', closeSaveCollectionModal); modal.querySelector('.modal-overlay').addEventListener('click', closeModal); deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal); + saveCollectionModal.querySelector('.modal-overlay').addEventListener('click', closeSaveCollectionModal); form.addEventListener('submit', async function(e) { e.preventDefault(); @@ -113,6 +170,7 @@ document.addEventListener('DOMContentLoaded', function() { saveBtn.textContent = 'Saving...'; const id = document.getElementById('link-id').value; + const isEdit = id && id !== '' && id !== 'undefined'; const tagsRaw = document.getElementById('link-tags').value; const data = { url: document.getElementById('link-url').value, @@ -125,13 +183,13 @@ document.addEventListener('DOMContentLoaded', function() { }; try { - if (id) { + if (isEdit) { await LinkSync.updateLink(id, data); } else { await LinkSync.createLink(data); } closeModal(); - loadLinks(searchInput.value); + loadLinks(searchMode === 'query' ? queryInput.value : searchInput.value); } catch (err) { alert('Failed to save link: ' + err.message); } finally { @@ -140,20 +198,65 @@ document.addEventListener('DOMContentLoaded', function() { } }); + saveCollectionForm.addEventListener('submit', async function(e) { + e.preventDefault(); + const saveBtn = document.getElementById('save-collection-save-btn'); + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + const name = document.getElementById('save-collection-name').value; + const desc = document.getElementById('save-collection-desc').value || null; + const type = document.getElementById('save-collection-type').value; + const isPublic = document.getElementById('save-collection-public').checked; + + const data = { + name, + description: desc, + query_type: type, + is_public: isPublic, + }; + + if (type === 'dynamic') { + const expression = searchMode === 'query' ? queryInput.value.trim() : searchInput.value.trim(); + data.query_expression = { expression }; + } else { + data.link_ids = lastResults.map(l => l.id); + } + + try { + await LinkSync.createCollection(data); + closeSaveCollectionModal(); + alert('Collection saved successfully'); + } catch (err) { + alert('Failed to save collection: ' + err.message); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Save'; + } + }); + document.getElementById('confirm-delete-btn').addEventListener('click', async function() { if (!deleteTargetId) return; try { await LinkSync.deleteLink(deleteTargetId); closeDeleteModal(); - loadLinks(searchInput.value); + loadLinks(searchMode === 'query' ? queryInput.value : searchInput.value); } catch (err) { alert('Failed to delete link: ' + err.message); } }); - document.getElementById('search-btn').addEventListener('click', () => loadLinks(searchInput.value)); + function doSearch() { + const search = searchMode === 'query' ? queryInput.value : searchInput.value; + loadLinks(search); + } + + document.getElementById('search-btn').addEventListener('click', doSearch); searchInput.addEventListener('keypress', function(e) { - if (e.key === 'Enter') loadLinks(searchInput.value); + if (e.key === 'Enter') doSearch(); + }); + queryInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') doSearch(); }); const urlParams = new URLSearchParams(window.location.search); diff --git a/LinkSyncServer/static/js/main.js b/LinkSyncServer/static/js/main.js index 37582ab..b8b8f45 100644 --- a/LinkSyncServer/static/js/main.js +++ b/LinkSyncServer/static/js/main.js @@ -1,8 +1,40 @@ document.addEventListener("DOMContentLoaded", function () { const apiBase = "/api"; + function isTokenExpired(token) { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const now = Math.floor(Date.now() / 1000); + return payload.exp ? now >= payload.exp : false; + } catch { + return true; + } + } + + function getTokenExpirySeconds(token) { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const now = Math.floor(Date.now() / 1000); + return payload.exp ? payload.exp - now : 0; + } catch { + return 0; + } + } + + function redirectToLogin() { + localStorage.removeItem("token"); + localStorage.removeItem("user"); + if (!window.location.pathname.startsWith("/login")) { + window.location.href = "/login?expired=1"; + } + } + async function apiFetch(endpoint, options = {}) { const token = localStorage.getItem("token"); + if (token && isTokenExpired(token)) { + redirectToLogin(); + throw new Error("Session expired"); + } const headers = { "Content-Type": "application/json", ...options.headers, @@ -14,6 +46,10 @@ document.addEventListener("DOMContentLoaded", function () { ...options, headers, }); + if (response.status === 401) { + redirectToLogin(); + throw new Error("Authentication required"); + } if (!response.ok) { let errorMsg = `HTTP ${response.status}`; try { @@ -35,6 +71,8 @@ document.addEventListener("DOMContentLoaded", function () { window.LinkSync = { apiFetch, + isTokenExpired, + getTokenExpirySeconds, async getLinks(params = {}) { const qs = new URLSearchParams(params).toString(); return apiFetch(`/links/?${qs}`); @@ -72,6 +110,9 @@ document.addEventListener("DOMContentLoaded", function () { async deleteCollection(id) { return apiFetch(`/collections/${id}`, { method: "DELETE" }); }, + async previewQuery(expression) { + return apiFetch(`/queries/preview?expression=${encodeURIComponent(expression)}`); + }, async executeQuery(expression, limit = 20) { return apiFetch(`/queries/execute?expression=${encodeURIComponent(expression)}&limit=${limit}`); }, @@ -95,6 +136,7 @@ document.addEventListener("DOMContentLoaded", function () { logout() { localStorage.removeItem("token"); localStorage.removeItem("user"); + window.location.href = "/login"; }, async getUsers() { return apiFetch("/admin/users"); diff --git a/LinkSyncServer/tasks.md b/LinkSyncServer/tasks.md index 6f6aa3d..e73e421 100644 --- a/LinkSyncServer/tasks.md +++ b/LinkSyncServer/tasks.md @@ -153,3 +153,55 @@ - [x] User guide (README.md) - [x] Query syntax guide (README.md) - [x] Deployment guide (README.md) + +## Phase 9: Web Interface Enhancements + +### Session Management +- [x] Token expiry detection on page load +- [x] 401 interceptor for all API calls +- [x] Session expiry warning banner (<2 minutes) +- [x] Graceful redirect to login on expiry +- [x] Session expired message on login page + +### Dashboard +- [x] Stats display with numbers (never `-`) +- [x] Quick actions grid +- [x] Admin section visibility toggle +- [x] Resilient stats loading (allSettled pattern) + +### Links Page +- [x] Simple search mode (keyword across all fields) +- [x] Query mode toggle (set operations) +- [x] Multi-word AND search +- [x] Tag search inclusion +- [x] Save as Collection feature (static and dynamic) +- [x] Query preview with result count + +### Collections Page +- [x] Query builder UI for dynamic collections +- [x] Query preview button +- [x] Result count display +- [x] Query expression display on cards +- [x] Type selector toggles query section + +### API Keys Page +- [x] Create API key modal +- [x] Delete API key confirmation + +### Admin Page +- [x] User CRUD interface +- [x] Role assignment +- [x] System statistics +- [x] Audit log viewer + +## Phase 10: Testing + +### E2E Tests (Playwright) +- [x] Install Playwright and pytest-playwright +- [x] Auth flow tests (login, dashboard, logout, expiry) +- [x] Link CRUD tests +- [x] Search tests (simple, multi-word, by tag, query mode) +- [x] Collection tests (static, dynamic with query, delete) +- [x] Save as Collection tests (static and dynamic) +- [x] API Key tests (create, delete) +- [x] Admin tests (user management) diff --git a/LinkSyncServer/templates/admin.html b/LinkSyncServer/templates/admin.html index 408b1ca..ee488e7 100644 --- a/LinkSyncServer/templates/admin.html +++ b/LinkSyncServer/templates/admin.html @@ -68,5 +68,5 @@ {% endblock %} {% block extra_js %} - + {% endblock %} diff --git a/LinkSyncServer/templates/apikeys.html b/LinkSyncServer/templates/apikeys.html index 143e50e..f9ddbdf 100644 --- a/LinkSyncServer/templates/apikeys.html +++ b/LinkSyncServer/templates/apikeys.html @@ -66,5 +66,5 @@ {% endblock %} {% block extra_js %} - + {% endblock %} diff --git a/LinkSyncServer/templates/base.html b/LinkSyncServer/templates/base.html index 4f85651..9750cfe 100644 --- a/LinkSyncServer/templates/base.html +++ b/LinkSyncServer/templates/base.html @@ -4,7 +4,7 @@ {% block title %}LinkSync{% endblock %} - + {% block extra_css %}{% endblock %} @@ -31,7 +31,7 @@

LinkSyncServer © 2026

- + + {% endblock %} diff --git a/LinkSyncServer/templates/dashboard.html b/LinkSyncServer/templates/dashboard.html index b3559ea..1c5fb48 100644 --- a/LinkSyncServer/templates/dashboard.html +++ b/LinkSyncServer/templates/dashboard.html @@ -64,5 +64,5 @@ {% endblock %} {% block extra_js %} - + {% endblock %} diff --git a/LinkSyncServer/templates/links.html b/LinkSyncServer/templates/links.html index f783fc4..b8b3395 100644 --- a/LinkSyncServer/templates/links.html +++ b/LinkSyncServer/templates/links.html @@ -9,8 +9,51 @@
+ +