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 ? `
No matching links
Query error: ${escapeHtml(data.error)}
Failed to load links: ${err.message}
Sign in to your account
-