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)
This commit is contained in:
DavidSaylor
2026-05-22 07:46:53 -05:00
parent 77b076c7d7
commit fe4cbc3537
29 changed files with 1410 additions and 78 deletions

View File

@@ -8,6 +8,7 @@ LinkSyncServer is a self-hosted bookmark server with advanced collection and que
- **Build**: `docker-compose up -d --build` - **Build**: `docker-compose up -d --build`
- **Test**: `pytest tests/ -v` - **Test**: `pytest tests/ -v`
- **E2E Test**: `pytest tests/test_e2e.py -v --browser chromium`
- **Lint**: `ruff check .` && `mypy app.py models/` - **Lint**: `ruff check .` && `mypy app.py models/`
- **Dev**: `docker-compose up web` - **Dev**: `docker-compose up web`
- **Migrate**: `alembic upgrade head` - **Migrate**: `alembic upgrade head`
@@ -41,9 +42,48 @@ Query syntax: `('term1', 'term2') OR tagA AND tagB XOR url:example.com`
## Testing Protocol ## Testing Protocol
- All tests must pass before committing - 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% - 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 ## Conventions

View File

@@ -16,6 +16,53 @@ LinkSyncServer replaces the need for workarounds in existing bookmark sync solut
## Features ## 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 ### Collections
Two types of collections: Two types of collections:

View File

@@ -75,7 +75,24 @@
- [x] Integration tests with TestClient - [x] Integration tests with TestClient
- [x] Test configuration (tests/conftest.py) - [x] Test configuration (tests/conftest.py)
- [x] pytest.ini in pyproject.toml - [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 ## Documentation
- [x] API reference (via /api/docs OpenAPI) - [x] API reference (via /api/docs OpenAPI)
@@ -83,6 +100,8 @@
- [x] Developer guide (AGENTS.md, design.md) - [x] Developer guide (AGENTS.md, design.md)
- [x] Deployment guide (README.md) - [x] Deployment guide (README.md)
- [x] Query syntax reference (README.md) - [x] Query syntax reference (README.md)
- [x] UI/UX design documentation (design.md)
- [x] Implementation tasks (tasks.md)
## Security ## Security
- [x] Password hashing (bcrypt with cost factor 12) - [x] Password hashing (bcrypt with cost factor 12)

View File

@@ -44,8 +44,12 @@ def get_current_user_id(request: Request) -> Optional[str]:
from config.settings import settings from config.settings import settings
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
return payload.get("sub") return payload.get("sub")
except Exception: except jwt.ExpiredSignatureError:
pass 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 return None
@@ -98,6 +102,40 @@ async def list_collections(
db.close() 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) @router.get("/{collection_id}", response_model=dict)
async def get_collection(collection_id: str): async def get_collection(collection_id: str):
db = get_session() db = get_session()
@@ -112,7 +150,7 @@ async def get_collection(collection_id: str):
.filter(CollectionBookmark.collection_id == parse_uuid(collection_id)) .filter(CollectionBookmark.collection_id == parse_uuid(collection_id))
.all() .all()
) )
result["link_ids"] = [lb.bookmark_id for lb in links] result["link_ids"] = [lb.link_id for lb in links]
return result return result
finally: finally:
db.close() 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 lid = uuid.UUID(link_id) if isinstance(link_id, str) else link_id
except (ValueError, AttributeError): except (ValueError, AttributeError):
continue continue
cb = CollectionBookmark(collection_id=collection.id, bookmark_id=lid) cb = CollectionBookmark(collection_id=collection.id, link_id=lid)
db.add(cb) db.add(cb)
db.commit() 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") raise HTTPException(status_code=400, detail="Can only add links to static collections")
existing = { existing = {
cb.bookmark_id cb.link_id
for cb in db.query(CollectionBookmark) for cb in db.query(CollectionBookmark)
.filter(CollectionBookmark.collection_id == parsed_cid) .filter(CollectionBookmark.collection_id == parsed_cid)
.all() .all()
@@ -257,7 +295,7 @@ async def add_links_to_collection(collection_id: str, link_ids: List[str]):
except (ValueError, AttributeError): except (ValueError, AttributeError):
continue continue
if lid not in existing: 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 added += 1
db.commit() db.commit()
@@ -288,7 +326,7 @@ async def remove_links_from_collection(collection_id: str, link_ids: List[str]):
db.query(CollectionBookmark) db.query(CollectionBookmark)
.filter( .filter(
CollectionBookmark.collection_id == parsed_cid, CollectionBookmark.collection_id == parsed_cid,
CollectionBookmark.bookmark_id.in_(parsed_link_ids), CollectionBookmark.link_id.in_(parsed_link_ids),
) )
.delete(synchronize_session=False) .delete(synchronize_session=False)
) )

View File

@@ -8,7 +8,7 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, Request, status from fastapi import APIRouter, HTTPException, Query, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import or_ from sqlalchemy import or_, func
from config.settings import settings from config.settings import settings
from models.base import AuditLog, Bookmark, User, get_session from models.base import AuditLog, Bookmark, User, get_session
@@ -90,13 +90,31 @@ async def list_bookmarks(
try: try:
query = db.query(Bookmark) query = db.query(Bookmark)
if search: if search:
query = query.filter( from sqlalchemy.sql import text
or_( from models.base import get_engine
Bookmark.title.ilike(f"%{search}%"), engine = get_engine()
Bookmark.description.ilike(f"%{search}%"), dialect = engine.dialect.name
Bookmark.url.ilike(f"%{search}%"), 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: if tags_filter:
for tag in tags_filter: for tag in tags_filter:
query = query.filter(Bookmark.tags.contains(tag)) query = query.filter(Bookmark.tags.contains(tag))

View File

@@ -2,17 +2,20 @@
LinkSyncServer - Query Engine Endpoints LinkSyncServer - Query Engine Endpoints
""" """
import logging
import uuid import uuid
from typing import Any, Dict, List, Optional 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 models.base import Bookmark, get_session
from queries.executor import execute_query from queries.executor import execute_query
from queries.parser import QueryParser from queries.parser import QueryParser, QuerySyntaxError
router = APIRouter(prefix="/api/queries", tags=["Queries"]) router = APIRouter(prefix="/api/queries", tags=["Queries"])
logger = logging.getLogger(__name__)
@router.post("/parse", response_model=Dict[str, Any]) @router.post("/parse", response_model=Dict[str, Any])
async def parse_expression(expression: str): 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]) @router.post("/execute", response_model=List[dict])
async def execute(expression: str, limit: int = 20, offset: int = 0): async def execute(expression: str, limit: int = 20, offset: int = 0):
db = get_session() db = get_session()
@@ -69,3 +98,25 @@ async def get_saved_query(query_id: str):
} }
finally: finally:
db.close() 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()

View File

@@ -210,12 +210,12 @@ async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]):
db.query(CollectionBookmark) db.query(CollectionBookmark)
.filter( .filter(
CollectionBookmark.collection_id == parsed_cid, CollectionBookmark.collection_id == parsed_cid,
CollectionBookmark.bookmark_id == parsed_bid, CollectionBookmark.link_id == parsed_bid,
) )
.first() .first()
) )
if not existing: 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 added += 1
db.commit() db.commit()

View File

@@ -3,6 +3,7 @@ LinkSyncServer - Main Application
""" """
import os import os
import time
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -15,6 +16,9 @@ from config.settings import settings
from models.base import Base, get_engine from models.base import Base, get_engine
BUILD_ID = str(int(time.time()))
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
engine = get_engine() engine = get_engine()
@@ -58,29 +62,29 @@ def index():
@app.get("/login") @app.get("/login")
def login_page(request: Request): 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") @app.get("/dashboard")
def dashboard(request: Request): 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") @app.get("/links")
def links_page(request: Request): 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") @app.get("/collections")
def collections_page(request: Request): 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") @app.get("/api-keys")
def apikeys_page(request: Request): 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") @app.get("/admin")
def admin_page(request: Request): def admin_page(request: Request):
return templates.TemplateResponse("admin.html", {"request": request}) return templates.TemplateResponse("admin.html", {"request": request, "build_id": BUILD_ID})

View File

@@ -80,6 +80,8 @@ CREATE TABLE collections (
CREATE TABLE collection_links ( CREATE TABLE collection_links (
collection_id UUID REFERENCES collections(id) ON DELETE CASCADE, collection_id UUID REFERENCES collections(id) ON DELETE CASCADE,
link_id UUID REFERENCES links(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) PRIMARY KEY (collection_id, link_id)
); );

View File

@@ -387,3 +387,58 @@ Priority based on sync mode:
- Mobile-first CSS - Mobile-first CSS
- Breakpoints: 768px, 1024px - Breakpoints: 768px, 1024px
- Touch-friendly UI controls - 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

View File

@@ -1,4 +1,4 @@
version: '3.8' # version: '3.8'
services: services:
web: web:

View File

@@ -191,7 +191,7 @@ class CollectionBookmark(Base, TimestampMixin):
__tablename__ = "collection_links" __tablename__ = "collection_links"
collection_id = Column(UUID(as_uuid=True), ForeignKey("collections.id"), primary_key=True) 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") collection = relationship("Collection", back_populates="collection_bookmarks")
bookmark = relationship("Bookmark", back_populates="collection_bookmarks") bookmark = relationship("Bookmark", back_populates="collection_bookmarks")

View File

@@ -39,6 +39,7 @@ python_files = ["test_*.py"]
python_functions = ["test_*"] python_functions = ["test_*"]
addopts = "-v --tb=short" addopts = "-v --tb=short"
filterwarnings = ["ignore::DeprecationWarning"] filterwarnings = ["ignore::DeprecationWarning"]
markers = ["e2e: End-to-end Playwright tests"]
[tool.ruff] [tool.ruff]
line-length = 88 line-length = 88

View File

@@ -51,10 +51,11 @@ def _evaluate_node(node: Dict[str, Any], bookmarks: List[Dict[str, Any]]) -> set
return { return {
b["id"] b["id"]
for b in bookmarks for b in bookmarks
if value in b.get("title", "").lower() if value in (b.get("title") or "").lower()
or value in b.get("description", "").lower() or value in (b.get("description") or "").lower()
or value in b.get("url", "").lower() or value in (b.get("url") or "").lower()
or value in b.get("notes", "").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": if operation == "TERM_SET":
@@ -63,9 +64,10 @@ def _evaluate_node(node: Dict[str, Any], bookmarks: List[Dict[str, Any]]) -> set
result = set() result = set()
for b in bookmarks: for b in bookmarks:
text = ( 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() ).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"]) result.add(b["id"])
return result return result

View File

@@ -170,10 +170,45 @@ body {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 1.5rem; 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 { .search-bar input {
flex: 1; flex: 1;
min-width: 200px;
padding: 0.625rem 1rem; padding: 0.625rem 1rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
@@ -214,6 +249,55 @@ body {
resize: vertical; 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 { .checkbox-group label {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -241,6 +325,15 @@ body {
font-size: 0.875rem; 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 { .success-message {
background: #f0fdf4; background: #f0fdf4;
color: var(--success); color: var(--success);
@@ -300,6 +393,186 @@ body {
margin-bottom: 2rem; 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 */
.dashboard-header { .dashboard-header {
margin-bottom: 2rem; margin-bottom: 2rem;
@@ -507,6 +780,16 @@ body {
margin-bottom: 0.75rem; 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 { .collection-card .badge {
display: inline-block; display: inline-block;
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;

View File

@@ -62,7 +62,7 @@ document.addEventListener('DOMContentLoaded', function() {
function openUserModal(user = null) { function openUserModal(user = null) {
document.getElementById('user-modal-title').textContent = user ? 'Edit User' : 'Create User'; 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').value = user ? user.username : '';
document.getElementById('user-username').disabled = !!user; document.getElementById('user-username').disabled = !!user;
document.getElementById('user-email').value = user ? user.email : ''; document.getElementById('user-email').value = user ? user.email : '';
@@ -115,6 +115,7 @@ document.addEventListener('DOMContentLoaded', function() {
saveBtn.textContent = 'Saving...'; saveBtn.textContent = 'Saving...';
const id = document.getElementById('user-id').value; const id = document.getElementById('user-id').value;
const isEdit = id && id !== '' && id !== 'undefined';
const data = { const data = {
username: document.getElementById('user-username').value, username: document.getElementById('user-username').value,
email: document.getElementById('user-email').value, email: document.getElementById('user-email').value,
@@ -126,7 +127,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (password) data.password = password; if (password) data.password = password;
try { try {
if (id) { if (isEdit) {
await LinkSync.updateUser(id, data); await LinkSync.updateUser(id, data);
} else { } else {
if (!password) { if (!password) {

View File

@@ -3,8 +3,23 @@ document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('collection-modal'); const modal = document.getElementById('collection-modal');
const deleteModal = document.getElementById('delete-collection-modal'); const deleteModal = document.getElementById('delete-collection-modal');
const form = document.getElementById('collection-form'); 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; 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() { async function loadCollections() {
try { try {
const collections = await LinkSync.getCollections(); const collections = await LinkSync.getCollections();
@@ -29,6 +44,7 @@ document.addEventListener('DOMContentLoaded', function() {
<span class="badge badge-${col.query_type}">${col.query_type}</span> <span class="badge badge-${col.query_type}">${col.query_type}</span>
<span class="badge ${col.is_public ? 'badge-public' : 'badge-private'}">${col.is_public ? 'Public' : 'Private'}</span> <span class="badge ${col.is_public ? 'badge-public' : 'badge-private'}">${col.is_public ? 'Public' : 'Private'}</span>
</div> </div>
${col.query_type === 'dynamic' && col.query_expression ? `<div class="query-hint"><small>${escapeHtml(col.query_expression.expression || '')}</small></div>` : ''}
<div class="actions"> <div class="actions">
<button class="btn btn-sm btn-outline" data-action="edit" data-id="${col.id}">Edit</button> <button class="btn btn-sm btn-outline" data-action="edit" data-id="${col.id}">Edit</button>
<button class="btn btn-sm btn-danger" data-action="delete" data-id="${col.id}">Delete</button> <button class="btn btn-sm btn-danger" data-action="delete" data-id="${col.id}">Delete</button>
@@ -46,11 +62,22 @@ document.addEventListener('DOMContentLoaded', function() {
function openCollectionModal(col = null) { function openCollectionModal(col = null) {
document.getElementById('collection-modal-title').textContent = col ? 'Edit Collection' : 'Create Collection'; 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-name').value = col ? col.name : '';
document.getElementById('collection-description').value = col ? (col.description || '') : ''; document.getElementById('collection-description').value = col ? (col.description || '') : '';
document.getElementById('collection-type').value = col ? col.query_type : 'static'; document.getElementById('collection-type').value = col ? col.query_type : 'static';
document.getElementById('collection-public').checked = col ? col.is_public : false; 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'; modal.style.display = 'flex';
} }
@@ -72,6 +99,9 @@ document.addEventListener('DOMContentLoaded', function() {
function closeModal() { function closeModal() {
modal.style.display = 'none'; modal.style.display = 'none';
form.reset(); form.reset();
querySection.style.display = 'none';
previewResults.style.display = 'none';
queryStatus.textContent = '';
} }
function closeDeleteModal() { function closeDeleteModal() {
@@ -87,6 +117,40 @@ document.addEventListener('DOMContentLoaded', function() {
modal.querySelector('.modal-overlay').addEventListener('click', closeModal); modal.querySelector('.modal-overlay').addEventListener('click', closeModal);
deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal); 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 = `<div class="query-preview-count">${count} matching links:</div>` +
results.slice(0, 10).map(link => `
<div class="query-preview-item">
<a href="${escapeHtml(link.url)}" target="_blank">${escapeHtml(link.title)}</a>
</div>
`).join('') +
(count > 10 ? `<div class="query-preview-count">...and ${count - 10} more</div>` : '');
previewResults.style.display = '';
} else {
previewResults.innerHTML = '<div class="empty-state"><p>No matching links</p></div>';
previewResults.style.display = '';
}
} catch (err) {
queryStatus.textContent = err.message;
queryStatus.style.color = 'var(--error)';
previewResults.style.display = 'none';
}
});
form.addEventListener('submit', async function(e) { form.addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
const saveBtn = document.getElementById('collection-save-btn'); const saveBtn = document.getElementById('collection-save-btn');
@@ -94,15 +158,24 @@ document.addEventListener('DOMContentLoaded', function() {
saveBtn.textContent = 'Saving...'; saveBtn.textContent = 'Saving...';
const id = document.getElementById('collection-id').value; const id = document.getElementById('collection-id').value;
const isEdit = id && id !== '' && id !== 'undefined';
const queryType = document.getElementById('collection-type').value;
const data = { const data = {
name: document.getElementById('collection-name').value, name: document.getElementById('collection-name').value,
description: document.getElementById('collection-description').value || null, description: document.getElementById('collection-description').value || null,
query_type: document.getElementById('collection-type').value, query_type: queryType,
is_public: document.getElementById('collection-public').checked, is_public: document.getElementById('collection-public').checked,
}; };
if (queryType === 'dynamic') {
const expression = queryInput.value.trim();
if (expression) {
data.query_expression = { expression };
}
}
try { try {
if (id) { if (isEdit) {
await LinkSync.updateCollection(id, data); await LinkSync.updateCollection(id, data);
} else { } else {
await LinkSync.createCollection(data); await LinkSync.createCollection(data);

View File

@@ -5,6 +5,12 @@ document.addEventListener('DOMContentLoaded', async function() {
return; return;
} }
const token = localStorage.getItem('token');
if (token && LinkSync.isTokenExpired(token)) {
LinkSync.logout();
return;
}
document.getElementById('current-user').textContent = user.username; document.getElementById('current-user').textContent = user.username;
if (user.role === 'admin') { if (user.role === 'admin') {
@@ -12,16 +18,28 @@ document.addEventListener('DOMContentLoaded', async function() {
} }
try { try {
const [links, collections, keys] = await Promise.all([ const [linksResult, collectionsResult, keysResult] = await Promise.allSettled([
LinkSync.getLinks({ limit: 1 }), LinkSync.getLinks({ limit: 1 }),
LinkSync.getCollections(), LinkSync.getCollections(),
LinkSync.getApiKeys(), 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('link-count').textContent = Array.isArray(links) ? links.length : 0;
document.getElementById('collection-count').textContent = Array.isArray(collections) ? collections.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; document.getElementById('api-key-count').textContent = Array.isArray(keys) ? keys.length : 0;
} catch (err) { } catch (err) {
console.error('Failed to load stats:', 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);
}
}); });

View File

@@ -4,14 +4,52 @@ document.addEventListener('DOMContentLoaded', function() {
const deleteModal = document.getElementById('delete-modal'); const deleteModal = document.getElementById('delete-modal');
const form = document.getElementById('link-form'); const form = document.getElementById('link-form');
const searchInput = document.getElementById('search-input'); 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 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 = '') { async function loadLinks(search = '') {
try { try {
const links = await LinkSync.getLinks(search ? { search } : {}); let links;
renderLinks(Array.isArray(links) ? links : []); if (searchMode === 'query' && search) {
lastQuery = search;
const data = await LinkSync.previewQuery(search);
if (data.error) {
linksList.innerHTML = `<div class="empty-state"><p>Query error: ${escapeHtml(data.error)}</p></div>`;
lastResults = [];
return;
}
links = data.results || [];
} else {
lastQuery = '';
links = await LinkSync.getLinks(search ? { search } : {});
}
lastResults = Array.isArray(links) ? links : [];
renderLinks(lastResults);
} catch (err) { } catch (err) {
linksList.innerHTML = `<div class="empty-state"><p>Failed to load links: ${err.message}</p></div>`; linksList.innerHTML = `<div class="empty-state"><p>Failed to load links: ${err.message}</p></div>`;
lastResults = [];
} }
} }
@@ -62,7 +100,7 @@ document.addEventListener('DOMContentLoaded', function() {
function openModal(link = null) { function openModal(link = null) {
document.getElementById('modal-title').textContent = link ? 'Edit Link' : 'Add Link'; 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-url').value = link ? link.url : '';
document.getElementById('link-title').value = link ? link.title : ''; document.getElementById('link-title').value = link ? link.title : '';
document.getElementById('link-description').value = link ? (link.description || '') : ''; document.getElementById('link-description').value = link ? (link.description || '') : '';
@@ -98,13 +136,32 @@ document.addEventListener('DOMContentLoaded', function() {
deleteTargetId = null; 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('new-link-btn').addEventListener('click', () => openModal());
document.getElementById('modal-close').addEventListener('click', closeModal); document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('cancel-btn').addEventListener('click', closeModal); document.getElementById('cancel-btn').addEventListener('click', closeModal);
document.getElementById('delete-cancel-btn').addEventListener('click', closeDeleteModal); 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); modal.querySelector('.modal-overlay').addEventListener('click', closeModal);
deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal); deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal);
saveCollectionModal.querySelector('.modal-overlay').addEventListener('click', closeSaveCollectionModal);
form.addEventListener('submit', async function(e) { form.addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
@@ -113,6 +170,7 @@ document.addEventListener('DOMContentLoaded', function() {
saveBtn.textContent = 'Saving...'; saveBtn.textContent = 'Saving...';
const id = document.getElementById('link-id').value; const id = document.getElementById('link-id').value;
const isEdit = id && id !== '' && id !== 'undefined';
const tagsRaw = document.getElementById('link-tags').value; const tagsRaw = document.getElementById('link-tags').value;
const data = { const data = {
url: document.getElementById('link-url').value, url: document.getElementById('link-url').value,
@@ -125,13 +183,13 @@ document.addEventListener('DOMContentLoaded', function() {
}; };
try { try {
if (id) { if (isEdit) {
await LinkSync.updateLink(id, data); await LinkSync.updateLink(id, data);
} else { } else {
await LinkSync.createLink(data); await LinkSync.createLink(data);
} }
closeModal(); closeModal();
loadLinks(searchInput.value); loadLinks(searchMode === 'query' ? queryInput.value : searchInput.value);
} catch (err) { } catch (err) {
alert('Failed to save link: ' + err.message); alert('Failed to save link: ' + err.message);
} finally { } 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() { document.getElementById('confirm-delete-btn').addEventListener('click', async function() {
if (!deleteTargetId) return; if (!deleteTargetId) return;
try { try {
await LinkSync.deleteLink(deleteTargetId); await LinkSync.deleteLink(deleteTargetId);
closeDeleteModal(); closeDeleteModal();
loadLinks(searchInput.value); loadLinks(searchMode === 'query' ? queryInput.value : searchInput.value);
} catch (err) { } catch (err) {
alert('Failed to delete link: ' + err.message); 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) { 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); const urlParams = new URLSearchParams(window.location.search);

View File

@@ -1,8 +1,40 @@
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const apiBase = "/api"; 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 = {}) { async function apiFetch(endpoint, options = {}) {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (token && isTokenExpired(token)) {
redirectToLogin();
throw new Error("Session expired");
}
const headers = { const headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
...options.headers, ...options.headers,
@@ -14,6 +46,10 @@ document.addEventListener("DOMContentLoaded", function () {
...options, ...options,
headers, headers,
}); });
if (response.status === 401) {
redirectToLogin();
throw new Error("Authentication required");
}
if (!response.ok) { if (!response.ok) {
let errorMsg = `HTTP ${response.status}`; let errorMsg = `HTTP ${response.status}`;
try { try {
@@ -35,6 +71,8 @@ document.addEventListener("DOMContentLoaded", function () {
window.LinkSync = { window.LinkSync = {
apiFetch, apiFetch,
isTokenExpired,
getTokenExpirySeconds,
async getLinks(params = {}) { async getLinks(params = {}) {
const qs = new URLSearchParams(params).toString(); const qs = new URLSearchParams(params).toString();
return apiFetch(`/links/?${qs}`); return apiFetch(`/links/?${qs}`);
@@ -72,6 +110,9 @@ document.addEventListener("DOMContentLoaded", function () {
async deleteCollection(id) { async deleteCollection(id) {
return apiFetch(`/collections/${id}`, { method: "DELETE" }); return apiFetch(`/collections/${id}`, { method: "DELETE" });
}, },
async previewQuery(expression) {
return apiFetch(`/queries/preview?expression=${encodeURIComponent(expression)}`);
},
async executeQuery(expression, limit = 20) { async executeQuery(expression, limit = 20) {
return apiFetch(`/queries/execute?expression=${encodeURIComponent(expression)}&limit=${limit}`); return apiFetch(`/queries/execute?expression=${encodeURIComponent(expression)}&limit=${limit}`);
}, },
@@ -95,6 +136,7 @@ document.addEventListener("DOMContentLoaded", function () {
logout() { logout() {
localStorage.removeItem("token"); localStorage.removeItem("token");
localStorage.removeItem("user"); localStorage.removeItem("user");
window.location.href = "/login";
}, },
async getUsers() { async getUsers() {
return apiFetch("/admin/users"); return apiFetch("/admin/users");

View File

@@ -153,3 +153,55 @@
- [x] User guide (README.md) - [x] User guide (README.md)
- [x] Query syntax guide (README.md) - [x] Query syntax guide (README.md)
- [x] Deployment 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)

View File

@@ -68,5 +68,5 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="/static/js/admin-page.js"></script> <script src="/static/js/admin-page.js?v={{ build_id }}"></script>
{% endblock %} {% endblock %}

View File

@@ -66,5 +66,5 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="/static/js/apikeys-page.js"></script> <script src="/static/js/apikeys-page.js?v={{ build_id }}"></script>
{% endblock %} {% endblock %}

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}LinkSync{% endblock %}</title> <title>{% block title %}LinkSync{% endblock %}</title>
<link rel="stylesheet" href="/static/css/main.css"> <link rel="stylesheet" href="/static/css/main.css?v={{ build_id }}">
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
</head> </head>
<body> <body>
@@ -31,7 +31,7 @@
<footer class="footer"> <footer class="footer">
<p>LinkSyncServer &copy; 2026</p> <p>LinkSyncServer &copy; 2026</p>
</footer> </footer>
<script src="/static/js/main.js"></script> <script src="/static/js/main.js?v={{ build_id }}"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');

View File

@@ -36,6 +36,18 @@
<option value="dynamic">Dynamic (query-based)</option> <option value="dynamic">Dynamic (query-based)</option>
</select> </select>
</div> </div>
<div id="query-builder-section" style="display: none;">
<div class="form-group">
<label for="collection-query">Query Expression</label>
<input type="text" id="collection-query" placeholder="e.g. personal AND lan NOT ai">
<small class="form-hint">Supports: AND, OR, XOR, parentheses, field filters (tag:, url:, title:)</small>
</div>
<div class="form-actions" style="margin-top: 0; margin-bottom: 1rem;">
<button type="button" class="btn btn-sm btn-secondary" id="preview-query-btn">Preview Results</button>
<span id="query-status" class="query-status"></span>
</div>
<div id="query-preview-results" class="query-preview" style="display: none;"></div>
</div>
<div class="form-group checkbox-group"> <div class="form-group checkbox-group">
<label> <label>
<input type="checkbox" id="collection-public"> <input type="checkbox" id="collection-public">
@@ -64,5 +76,5 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="/static/js/collections-page.js"></script> <script src="/static/js/collections-page.js?v={{ build_id }}"></script>
{% endblock %} {% endblock %}

View File

@@ -64,5 +64,5 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="/static/js/dashboard.js"></script> <script src="/static/js/dashboard.js?v={{ build_id }}"></script>
{% endblock %} {% endblock %}

View File

@@ -9,8 +9,51 @@
</div> </div>
<div class="search-bar"> <div class="search-bar">
<input type="text" id="search-input" placeholder="Search links by title or URL..."> <div class="search-mode-toggle">
<button class="btn btn-sm btn-outline search-mode-btn active" data-mode="simple" id="simple-search-btn">Simple</button>
<button class="btn btn-sm btn-outline search-mode-btn" data-mode="query" id="query-search-btn">Query</button>
</div>
<input type="text" id="search-input" placeholder="Search links by title, URL, tags, or notes...">
<input type="text" id="query-input" placeholder="e.g. personal AND lan NOT ai" style="display: none;">
<button class="btn btn-secondary" id="search-btn">Search</button> <button class="btn btn-secondary" id="search-btn">Search</button>
<button class="btn btn-outline" id="save-collection-btn" title="Save current results as a collection">Save as Collection</button>
</div>
<div id="save-collection-modal" class="modal" style="display: none;">
<div class="modal-overlay"></div>
<div class="modal-content modal-sm">
<div class="modal-header">
<h2>Save as Collection</h2>
<button class="modal-close" id="save-collection-modal-close">&times;</button>
</div>
<form id="save-collection-form">
<div class="form-group">
<label for="save-collection-name">Collection Name *</label>
<input type="text" id="save-collection-name" required>
</div>
<div class="form-group">
<label for="save-collection-desc">Description</label>
<textarea id="save-collection-desc" rows="2"></textarea>
</div>
<div class="form-group">
<label for="save-collection-type">Type</label>
<select id="save-collection-type">
<option value="static">Static (current results)</option>
<option value="dynamic">Dynamic (query-based)</option>
</select>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="save-collection-public">
Public
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="save-collection-cancel-btn">Cancel</button>
<button type="submit" class="btn btn-primary" id="save-collection-save-btn">Save</button>
</div>
</form>
</div>
</div> </div>
<div id="links-list" class="links-table"> <div id="links-list" class="links-table">
@@ -76,5 +119,5 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="/static/js/links-page.js"></script> <script src="/static/js/links-page.js?v={{ build_id }}"></script>
{% endblock %} {% endblock %}

View File

@@ -3,29 +3,80 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - LinkSync</title> <title>LinkSync</title>
<link rel="stylesheet" href="/static/css/main.css"> <link rel="stylesheet" href="/static/css/main.css?v={{ build_id }}">
</head> </head>
<body class="login-page"> <body class="public-page">
<div class="login-container"> <header class="public-header">
<div class="login-card"> <div class="public-header-inner">
<div class="public-brand">
<h1>LinkSync</h1> <h1>LinkSync</h1>
<p class="login-subtitle">Sign in to your account</p>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div> </div>
<form id="login-form" class="public-login-form">
<div id="session-expired" class="info-message" style="display: none;">Session expired. Sign in again.</div>
<div id="login-error" class="error-message" style="display: none;"></div> <div id="login-error" class="error-message" style="display: none;"></div>
<button type="submit" class="btn btn-primary btn-full" id="login-btn">Sign In</button> <input type="text" id="username" placeholder="Username" required autofocus>
<input type="password" id="password" placeholder="Password" required>
<button type="submit" class="btn btn-primary" id="login-btn">Sign In</button>
</form> </form>
</div> </div>
</header>
<main class="public-main">
<h2>Public Links</h2>
<div id="public-tree" class="public-tree">
<div class="loading">Loading public links...</div>
</div> </div>
</main>
<script> <script>
async function loadPublicTree() {
try {
const response = await fetch('/api/collections/public-tree');
if (!response.ok) throw new Error('Failed to load');
const collections = await response.json();
const container = document.getElementById('public-tree');
if (!collections || collections.length === 0) {
container.innerHTML = '<div class="empty-state"><p>No public collections yet.</p></div>';
return;
}
container.innerHTML = collections.map(col => {
const links = col.links || [];
const hasLinks = links.length > 0;
return `
<div class="tree-collection">
<button class="tree-toggle ${hasLinks ? 'expanded' : ''}" ${hasLinks ? '' : 'disabled'}>${hasLinks ? '▼' : '▶'}</button>
<span class="tree-collection-name">${escapeHtml(col.name)}</span>
<span class="tree-meta">${col.query_type} &middot; ${links.length} link${links.length !== 1 ? 's' : ''}</span>
${col.description ? `<span class="tree-desc">${escapeHtml(col.description)}</span>` : ''}
<div class="tree-links" ${hasLinks ? '' : 'style="display:none"'}>
${links.map(link => `
<div class="tree-link">
<a href="${escapeHtml(link.url)}" target="_blank" class="tree-link-title">${escapeHtml(link.title)}</a>
<span class="tree-link-url">${escapeHtml(link.url)}</span>
${(link.tags || []).length > 0 ? `<span class="tags">${link.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`;
}).join('');
container.querySelectorAll('.tree-toggle').forEach(btn => {
btn.addEventListener('click', function() {
const links = this.parentElement.querySelector('.tree-links');
const isHidden = links.style.display === 'none';
links.style.display = isHidden ? '' : 'none';
this.textContent = isHidden ? '▼' : '▶';
});
});
} catch (err) {
document.getElementById('public-tree').innerHTML = '<div class="empty-state"><p>Could not load public links.</p></div>';
}
}
document.getElementById('login-form').addEventListener('submit', async function(e) { document.getElementById('login-form').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
const btn = document.getElementById('login-btn'); const btn = document.getElementById('login-btn');
@@ -67,7 +118,34 @@
}); });
if (localStorage.getItem('token')) { if (localStorage.getItem('token')) {
try {
const token = localStorage.getItem('token');
const payload = JSON.parse(atob(token.split('.')[1]));
const now = Math.floor(Date.now() / 1000);
if (payload.exp && now < payload.exp) {
window.location.href = '/dashboard'; window.location.href = '/dashboard';
} else {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
} catch {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
}
const params = new URLSearchParams(window.location.search);
if (params.get('expired') === '1') {
document.getElementById('session-expired').style.display = 'block';
}
loadPublicTree();
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
} }
</script> </script>
</body> </body>

View File

@@ -0,0 +1,350 @@
"""
LinkSyncServer - End-to-End Playwright Tests
"""
import os
import subprocess
import time
import pytest
pytestmark = pytest.mark.e2e
import requests as _requests
def login(page, base_url, username="admin", password="admin123"):
try:
_requests.get(f"{base_url}/health", timeout=5)
except Exception:
pytest.skip("Server not healthy")
page.goto(f"{base_url}/login")
page.fill("#username", username)
page.fill("#password", password)
page.click("#login-btn")
page.wait_for_url("**/dashboard")
@pytest.fixture(scope="session")
def e2e_server():
"""Start the dev server for E2E tests."""
import requests
env = os.environ.copy()
env["DATABASE_URL"] = "sqlite:///test_e2e.db"
env["SECRET_KEY"] = "e2e-test-secret-key"
env["ADMIN_USERNAME"] = "admin"
env["ADMIN_PASSWORD"] = "admin123"
env["DEBUG"] = "False"
env["PORT"] = "5001"
proc = subprocess.Popen(
["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
base_url = "http://localhost:5001"
for _ in range(30):
try:
resp = requests.get(f"{base_url}/health", timeout=2)
if resp.ok:
break
except requests.RequestException:
pass
time.sleep(1)
else:
proc.terminate()
proc.wait(timeout=5)
raise RuntimeError("Server failed to start")
yield base_url
proc.terminate()
proc.wait(timeout=5)
try:
if os.path.exists("test_e2e.db"):
os.remove("test_e2e.db")
except PermissionError:
pass
@pytest.fixture(autouse=True)
def setup_page(page, e2e_server):
"""Set base URL for all E2E tests."""
page.set_default_timeout(30000)
return page
class TestAuth:
def test_login_and_dashboard(self, page, e2e_server):
login(page, e2e_server)
assert page.locator("#current-user").text_content() == "admin"
assert page.locator("#link-count").is_visible()
assert page.locator("#collection-count").is_visible()
assert page.locator("#api-key-count").is_visible()
def test_dashboard_stats_show_numbers(self, page, e2e_server):
login(page, e2e_server)
page.locator("#link-count:not(:text('-'))").wait_for(timeout=5000)
link_count = page.locator("#link-count").text_content()
assert link_count != "-"
collection_count = page.locator("#collection-count").text_content()
assert collection_count != "-"
api_key_count = page.locator("#api-key-count").text_content()
assert api_key_count != "-"
def test_logout_redirects_to_login(self, page, e2e_server):
login(page, e2e_server)
page.click("#logout-btn")
page.wait_for_url("**/login")
assert page.url.endswith("/login")
def test_session_expired_redirect(self, page, e2e_server):
page.goto(f"{e2e_server}/login?expired=1")
assert page.locator("#session-expired").is_visible()
class TestLinks:
def test_create_link(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/links")
page.wait_for_timeout(500)
page.click("#new-link-btn")
page.wait_for_selector("#link-modal")
page.fill("#link-url", "https://example.com/test")
page.fill("#link-title", "Test Link")
page.fill("#link-description", "A test link")
page.fill("#link-tags", "test, example")
page.click("#save-btn")
page.wait_for_timeout(500)
assert page.locator("#links-list").locator("text=Test Link").first.is_visible()
def test_search_links_simple(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/links")
page.wait_for_timeout(500)
page.click("#new-link-btn")
page.wait_for_selector("#link-modal")
page.fill("#link-url", "https://example.com/searchable")
page.fill("#link-title", "Searchable Link")
page.fill("#link-tags", "search, demo")
page.click("#save-btn")
page.wait_for_timeout(500)
page.fill("#search-input", "Searchable")
page.click("#search-btn")
page.wait_for_timeout(500)
assert page.locator("#links-list").locator("text=Searchable Link").first.is_visible()
def test_search_links_by_tag(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/links")
page.wait_for_timeout(500)
page.click("#new-link-btn")
page.wait_for_selector("#link-modal")
page.fill("#link-url", "https://example.com/tagged")
page.fill("#link-title", "Tagged Link")
page.fill("#link-tags", "mytag, unique")
page.click("#save-btn")
page.wait_for_timeout(500)
page.fill("#search-input", "mytag")
page.click("#search-btn")
page.wait_for_timeout(500)
assert page.locator("#links-list").locator("text=Tagged Link").first.is_visible()
def test_multi_word_search(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/links")
page.wait_for_timeout(500)
page.click("#new-link-btn")
page.wait_for_selector("#link-modal")
page.fill("#link-url", "https://example.com/multiword")
page.fill("#link-title", "MultiWord Link")
page.fill("#link-tags", "alpha, beta")
page.click("#save-btn")
page.wait_for_timeout(500)
page.fill("#search-input", "MultiWord alpha")
page.click("#search-btn")
page.wait_for_timeout(500)
assert page.locator("#links-list").locator("text=MultiWord Link").first.is_visible()
def _do_query_search(self, page, query):
page.click("#query-search-btn")
page.wait_for_timeout(300)
page.fill("#query-input", query)
page.click("#search-btn")
page.wait_for_timeout(1000)
page.wait_for_selector("#links-list .data-table, #links-list .empty-state", timeout=10000)
def test_query_mode_search(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/links")
page.wait_for_selector("#links-list .data-table", timeout=10000)
self._do_query_search(page, "alpha")
assert page.locator("#links-list").locator("text=MultiWord Link").first.is_visible()
def test_query_mode_with_and(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/links")
page.wait_for_selector("#links-list .data-table", timeout=10000)
self._do_query_search(page, "MultiWord AND alpha")
assert page.locator("#links-list").locator("text=MultiWord Link").first.is_visible()
def test_query_mode_with_or(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/links")
page.wait_for_selector("#links-list .data-table", timeout=10000)
self._do_query_search(page, "MultiWord OR nonexistent")
assert page.locator("#links-list").locator("text=MultiWord Link").first.is_visible()
def test_delete_link(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/links")
page.wait_for_timeout(500)
page.click("#new-link-btn")
page.wait_for_selector("#link-modal")
page.fill("#link-url", "https://example.com/deletable")
page.fill("#link-title", "Deletable Link")
page.click("#save-btn")
page.wait_for_timeout(500)
page.locator('[data-action="delete"]').first.click()
page.wait_for_selector("#delete-modal")
page.click("#confirm-delete-btn")
page.wait_for_timeout(500)
assert not page.locator("#links-list").locator("text=Deletable Link").first.is_visible()
def test_save_search_as_static_collection(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/links")
page.wait_for_timeout(500)
page.fill("#search-input", "MultiWord")
page.click("#search-btn")
page.wait_for_timeout(500)
page.click("#save-collection-btn")
page.wait_for_selector("#save-collection-modal")
page.fill("#save-collection-name", "Saved Search Collection")
page.click("#save-collection-save-btn")
page.wait_for_timeout(500)
page.goto(f"{e2e_server}/collections")
page.wait_for_timeout(500)
assert page.locator(".collection-card").locator("text=Saved Search Collection").first.is_visible()
def test_save_query_as_dynamic_collection(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/links")
page.wait_for_timeout(500)
page.click("#query-search-btn")
page.wait_for_selector("#query-input")
page.fill("#query-input", "alpha")
page.click("#search-btn")
page.wait_for_timeout(500)
page.click("#save-collection-btn")
page.wait_for_selector("#save-collection-modal")
page.fill("#save-collection-name", "Dynamic Query Collection")
page.click("#save-collection-save-btn")
page.wait_for_timeout(500)
page.goto(f"{e2e_server}/collections")
page.wait_for_timeout(500)
assert page.locator(".collection-card").locator("text=Dynamic Query Collection").first.is_visible()
class TestCollections:
def test_create_static_collection(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/collections")
page.click("#new-collection-btn")
page.wait_for_selector("#collection-modal")
page.fill("#collection-name", "Static Test Collection")
page.fill("#collection-description", "A static collection")
page.select_option("#collection-type", "static")
page.click("#collection-save-btn")
page.wait_for_timeout(500)
assert page.locator(".collection-card").locator("text=Static Test Collection").first.is_visible()
def test_create_dynamic_collection_with_query(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/collections")
page.click("#new-collection-btn")
page.wait_for_selector("#collection-modal")
page.fill("#collection-name", "Dynamic Test Collection")
page.select_option("#collection-type", "dynamic")
page.wait_for_selector("#query-builder-section")
page.fill("#collection-query", "alpha OR beta")
page.click("#preview-query-btn")
page.wait_for_timeout(500)
assert page.locator("#query-preview-results").is_visible()
page.click("#collection-save-btn")
page.wait_for_timeout(500)
assert page.locator(".collection-card").locator("text=Dynamic Test Collection").first.is_visible()
def test_delete_collection(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/collections")
page.click("#new-collection-btn")
page.wait_for_selector("#collection-modal")
page.fill("#collection-name", "Delete Me Collection")
page.click("#collection-save-btn")
page.wait_for_timeout(500)
page.locator('[data-action="delete"]').first.click()
page.wait_for_selector("#delete-collection-modal")
page.click("#confirm-delete-collection-btn")
page.wait_for_timeout(500)
assert not page.locator(".collection-card").locator("text=Delete Me Collection").first.is_visible()
class TestApiKeys:
def test_create_api_key(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/api-keys")
page.click("#new-key-btn")
page.wait_for_selector("#key-modal")
page.fill("#key-name", "Test Key")
page.click("#key-save-btn")
page.wait_for_selector("#key-result-modal", timeout=5000)
page.click("#key-done-btn")
page.wait_for_timeout(500)
assert page.locator("#api-keys-list").locator("text=Test Key").first.is_visible()
def test_delete_api_key(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/api-keys")
page.click("#new-key-btn")
page.wait_for_selector("#key-modal")
page.fill("#key-name", "Delete Me Key")
page.click("#key-save-btn")
page.wait_for_selector("#key-result-modal", timeout=5000)
page.click("#key-done-btn")
page.wait_for_timeout(500)
page.locator('[data-action="delete"]').first.click()
page.wait_for_selector("#delete-key-modal")
page.click("#confirm-delete-key-btn")
page.wait_for_timeout(500)
assert not page.locator(".data-table").locator("text=Delete Me Key").first.is_visible()
class TestAdmin:
def test_admin_user_management(self, page, e2e_server):
login(page, e2e_server)
page.goto(f"{e2e_server}/admin")
page.wait_for_selector("#users-list .data-table", timeout=10000)
page.click("#new-user-btn")
page.wait_for_selector("#user-modal")
page.fill("#user-username", "testuser")
page.fill("#user-email", "test@example.com")
page.fill("#user-password", "testpass123")
page.click("#user-save-btn")
page.wait_for_selector("#users-list .data-table td", timeout=5000)
assert page.locator("#users-list .data-table").locator("text=testuser").first.is_visible()