Compare commits

...

18 commits

Author SHA1 Message Date
f8e911c48f feat(avocet): add toss-zone overlays and grid-rise animation to LabelView 2026-03-05 13:41:52 -08:00
2bbd925c41 feat(avocet): replace swipe+HTML5-drag with unified pointer-events toss gesture 2026-03-05 10:38:52 -08:00
a8b1c89c62 feat(avocet): replace HTML5 drag events on LabelBucketGrid with hoveredBucket prop 2026-03-05 10:10:48 -08:00
f8aafb2974 feat: card crumples to small ball on drag pickup so buckets expand fully 2026-03-04 12:38:46 -08:00
d82db402a3 fix: keyboard shortcuts now work after labels load (lazy keymap evaluation)
useLabelKeyboard now accepts labels as Label[] | (() => Label[]).
The keymap is rebuilt on every keypress from the getter result instead of
being captured once at construction time — so keys 1–9 now fire correctly
after the async /api/config/labels fetch completes.

LabelView passes () => labels.value so the reactive ref is read lazily.

New test: 'evaluates labels getter on each keypress' covers the async-load
scenario (empty list → no match; push a label → key fires).
2026-03-04 12:32:25 -08:00
ba25ee47a5 fix: pin bucket grid to bottom of viewport with sticky footer; prevents mis-click from layout shift 2026-03-04 12:26:04 -08:00
cf69452e42 feat: implement FetchView — SSE progress bars, account selection, targeted fetch 2026-03-04 12:23:58 -08:00
a9f7ba1b0c feat: implement StatsView — label distribution bars, file info, download 2026-03-04 12:21:21 -08:00
d372155e4b feat: implement SettingsView — IMAP account management, test connection, display toggles 2026-03-04 12:20:30 -08:00
7fa62ae073 feat: add useApiSSE helper for Server-Sent Events connections 2026-03-04 12:17:46 -08:00
7bd37ef982 feat: add Vue Router + stow-able AppSidebar; stub Fetch/Stats/Settings views 2026-03-04 12:12:26 -08:00
f38c73db97 feat: add GET /api/fetch/stream SSE endpoint for real-time IMAP progress 2026-03-04 12:05:23 -08:00
965362f5e3 feat: add POST /api/accounts/test endpoint 2026-03-04 12:04:42 -08:00
f64be8bbe0 feat: add GET /api/stats and GET /api/stats/download endpoints 2026-03-04 12:04:11 -08:00
c5a74d3821 feat: add GET/POST /api/config endpoints for IMAP account management 2026-03-04 12:03:40 -08:00
1d1f25641b feat: extract IMAP logic to app/imap_fetch.py for reuse by API 2026-03-04 11:42:22 -08:00
8ec2dfddee fix: bucket grid now renders 3x3+1 numpad layout on all screen sizes 2026-03-04 11:31:36 -08:00
92da5902ba fix: UndoToast now emits expire after 5s so toast self-dismisses 2026-03-04 11:29:03 -08:00
24 changed files with 2810 additions and 112 deletions

View file

@ -7,6 +7,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import yaml
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
@ -16,6 +17,7 @@ from pydantic import BaseModel
_ROOT = Path(__file__).parent.parent _ROOT = Path(__file__).parent.parent
_DATA_DIR: Path = _ROOT / "data" # overridable in tests via set_data_dir() _DATA_DIR: Path = _ROOT / "data" # overridable in tests via set_data_dir()
_CONFIG_DIR: Path | None = None # None = use real path
def set_data_dir(path: Path) -> None: def set_data_dir(path: Path) -> None:
@ -24,6 +26,18 @@ def set_data_dir(path: Path) -> None:
_DATA_DIR = path _DATA_DIR = path
def set_config_dir(path: Path | None) -> None:
"""Override config directory — used by tests."""
global _CONFIG_DIR
_CONFIG_DIR = path
def _config_file() -> Path:
if _CONFIG_DIR is not None:
return _CONFIG_DIR / "label_tool.yaml"
return _ROOT / "config" / "label_tool.yaml"
def reset_last_action() -> None: def reset_last_action() -> None:
"""Reset undo state — used by tests.""" """Reset undo state — used by tests."""
global _last_action global _last_action
@ -206,6 +220,115 @@ def get_labels():
return _LABEL_META return _LABEL_META
@app.get("/api/config")
def get_config():
f = _config_file()
if not f.exists():
return {"accounts": [], "max_per_account": 500}
raw = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
return {"accounts": raw.get("accounts", []), "max_per_account": raw.get("max_per_account", 500)}
class ConfigPayload(BaseModel):
accounts: list[dict]
max_per_account: int = 500
@app.post("/api/config")
def post_config(payload: ConfigPayload):
f = _config_file()
f.parent.mkdir(parents=True, exist_ok=True)
tmp = f.with_suffix(".tmp")
tmp.write_text(yaml.dump(payload.model_dump(), allow_unicode=True, sort_keys=False),
encoding="utf-8")
tmp.rename(f)
return {"ok": True}
@app.get("/api/stats")
def get_stats():
records = _read_jsonl(_score_file())
counts: dict[str, int] = {}
for r in records:
lbl = r.get("label", "")
if lbl:
counts[lbl] = counts.get(lbl, 0) + 1
return {
"total": len(records),
"counts": counts,
"score_file_bytes": _score_file().stat().st_size if _score_file().exists() else 0,
}
@app.get("/api/stats/download")
def download_stats():
from fastapi.responses import FileResponse
if not _score_file().exists():
raise HTTPException(404, "No score file")
return FileResponse(
str(_score_file()),
filename="email_score.jsonl",
media_type="application/jsonlines",
headers={"Content-Disposition": 'attachment; filename="email_score.jsonl"'},
)
class AccountTestRequest(BaseModel):
account: dict
@app.post("/api/accounts/test")
def test_account(req: AccountTestRequest):
from app.imap_fetch import test_connection
ok, message, count = test_connection(req.account)
return {"ok": ok, "message": message, "count": count}
from fastapi.responses import StreamingResponse
@app.get("/api/fetch/stream")
def fetch_stream(
accounts: str = Query(default=""),
days_back: int = Query(default=90, ge=1, le=365),
limit: int = Query(default=150, ge=1, le=1000),
mode: str = Query(default="wide"),
):
from app.imap_fetch import fetch_account_stream
selected_names = {n.strip() for n in accounts.split(",") if n.strip()}
config = get_config() # reuse existing endpoint logic
selected = [a for a in config["accounts"] if a.get("name") in selected_names]
def generate():
known_keys = {_item_id(x) for x in _read_jsonl(_queue_file())}
total_added = 0
for acc in selected:
try:
batch_emails: list[dict] = []
for event in fetch_account_stream(acc, days_back, limit, known_keys):
if event["type"] == "done":
batch_emails = event.pop("emails", [])
total_added += event["added"]
yield f"data: {json.dumps(event)}\n\n"
# Write new emails to queue after each account
if batch_emails:
existing = _read_jsonl(_queue_file())
_write_jsonl(_queue_file(), existing + batch_emails)
except Exception as exc:
error_event = {"type": "error", "account": acc.get("name", "?"),
"message": str(exc)}
yield f"data: {json.dumps(error_event)}\n\n"
queue_size = len(_read_jsonl(_queue_file()))
complete = {"type": "complete", "total_added": total_added, "queue_size": queue_size}
yield f"data: {json.dumps(complete)}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
# Static SPA — MUST be last (catches all unmatched paths) # Static SPA — MUST be last (catches all unmatched paths)
_DIST = _ROOT / "web" / "dist" _DIST = _ROOT / "web" / "dist"
if _DIST.exists(): if _DIST.exists():

214
app/imap_fetch.py Normal file
View file

@ -0,0 +1,214 @@
"""Avocet — IMAP fetch utilities.
Shared between app/api.py (FastAPI SSE endpoint) and app/label_tool.py (Streamlit).
No Streamlit imports here stdlib + imaplib only.
"""
from __future__ import annotations
import email as _email_lib
import hashlib
import imaplib
import re
from datetime import datetime, timedelta
from email.header import decode_header as _raw_decode
from html.parser import HTMLParser
from typing import Any, Iterator
# ── HTML → plain text ────────────────────────────────────────────────────────
class _TextExtractor(HTMLParser):
def __init__(self):
super().__init__()
self._parts: list[str] = []
def handle_data(self, data: str) -> None:
stripped = data.strip()
if stripped:
self._parts.append(stripped)
def get_text(self) -> str:
return " ".join(self._parts)
def strip_html(html_str: str) -> str:
try:
ex = _TextExtractor()
ex.feed(html_str)
return ex.get_text()
except Exception:
return re.sub(r"<[^>]+>", " ", html_str).strip()
# ── IMAP decode helpers ───────────────────────────────────────────────────────
def _decode_str(value: str | None) -> str:
if not value:
return ""
parts = _raw_decode(value)
out = []
for part, enc in parts:
if isinstance(part, bytes):
out.append(part.decode(enc or "utf-8", errors="replace"))
else:
out.append(str(part))
return " ".join(out).strip()
def _extract_body(msg: Any) -> str:
if msg.is_multipart():
html_fallback: str | None = None
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/plain":
try:
charset = part.get_content_charset() or "utf-8"
return part.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
elif ct == "text/html" and html_fallback is None:
try:
charset = part.get_content_charset() or "utf-8"
raw = part.get_payload(decode=True).decode(charset, errors="replace")
html_fallback = strip_html(raw)
except Exception:
pass
return html_fallback or ""
else:
try:
charset = msg.get_content_charset() or "utf-8"
raw = msg.get_payload(decode=True).decode(charset, errors="replace")
if msg.get_content_type() == "text/html":
return strip_html(raw)
return raw
except Exception:
pass
return ""
def entry_key(e: dict) -> str:
"""Stable MD5 content-hash for dedup — matches label_tool.py _entry_key."""
key = (e.get("subject", "") + (e.get("body", "") or "")[:100])
return hashlib.md5(key.encode("utf-8", errors="replace")).hexdigest()
# ── Wide search terms ────────────────────────────────────────────────────────
_WIDE_TERMS = [
"interview", "phone screen", "video call", "zoom link", "schedule a call",
"offer letter", "job offer", "offer of employment", "pleased to offer",
"unfortunately", "not moving forward", "other candidates", "regret to inform",
"no longer", "decided not to", "decided to go with",
"opportunity", "interested in your background", "reached out", "great fit",
"exciting role", "love to connect",
"assessment", "questionnaire", "culture fit", "culture-fit", "online assessment",
"application received", "thank you for applying", "application confirmation",
"you applied", "your application for",
"reschedule", "rescheduled", "new time", "moved to", "postponed", "new date",
"job digest", "jobs you may like", "recommended jobs", "jobs for you",
"new jobs", "job alert",
"came across your profile", "reaching out about", "great fit for a role",
"exciting opportunity",
"welcome to the team", "start date", "onboarding", "first day", "we're excited to have you",
"application", "recruiter", "recruiting", "hiring", "candidate",
]
# ── Public API ────────────────────────────────────────────────────────────────
def test_connection(acc: dict) -> tuple[bool, str, int | None]:
"""Connect, login, select folder. Returns (ok, human_message, message_count|None)."""
host = acc.get("host", "")
port = int(acc.get("port", 993))
use_ssl = acc.get("use_ssl", True)
username = acc.get("username", "")
password = acc.get("password", "")
folder = acc.get("folder", "INBOX")
if not host or not username or not password:
return False, "Host, username, and password are all required.", None
try:
conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port)
conn.login(username, password)
_, data = conn.select(folder, readonly=True)
count_raw = data[0].decode() if data and data[0] else "0"
count = int(count_raw) if count_raw.isdigit() else 0
conn.logout()
return True, f"Connected — {count:,} message(s) in {folder}.", count
except Exception as exc:
return False, str(exc), None
def fetch_account_stream(
acc: dict,
days_back: int,
limit: int,
known_keys: set[str],
) -> Iterator[dict]:
"""Generator — yields progress dicts while fetching emails via IMAP.
Mutates `known_keys` in place for cross-account dedup within one fetch session.
Yields event dicts with "type" key:
{"type": "start", "account": str, "total_uids": int}
{"type": "progress", "account": str, "fetched": int, "total_uids": int}
{"type": "done", "account": str, "added": int, "skipped": int, "emails": list}
"""
name = acc.get("name", acc.get("username", "?"))
host = acc.get("host", "imap.gmail.com")
port = int(acc.get("port", 993))
use_ssl = acc.get("use_ssl", True)
username = acc["username"]
password = acc["password"]
folder = acc.get("folder", "INBOX")
since = (datetime.now() - timedelta(days=days_back)).strftime("%d-%b-%Y")
conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port)
conn.login(username, password)
conn.select(folder, readonly=True)
seen_uids: dict[bytes, None] = {}
for term in _WIDE_TERMS:
try:
_, data = conn.search(None, f'(SUBJECT "{term}" SINCE "{since}")')
for uid in (data[0] or b"").split():
seen_uids[uid] = None
except Exception:
pass
uids = list(seen_uids.keys())[: limit * 3]
yield {"type": "start", "account": name, "total_uids": len(uids)}
emails: list[dict] = []
skipped = 0
for i, uid in enumerate(uids):
if len(emails) >= limit:
break
if i % 5 == 0:
yield {"type": "progress", "account": name, "fetched": len(emails), "total_uids": len(uids)}
try:
_, raw_data = conn.fetch(uid, "(RFC822)")
if not raw_data or not raw_data[0]:
continue
msg = _email_lib.message_from_bytes(raw_data[0][1])
subj = _decode_str(msg.get("Subject", ""))
from_addr = _decode_str(msg.get("From", ""))
date = _decode_str(msg.get("Date", ""))
body = _extract_body(msg)[:800]
entry = {"subject": subj, "body": body, "from_addr": from_addr,
"date": date, "account": name}
k = entry_key(entry)
if k not in known_keys:
known_keys.add(k)
emails.append(entry)
else:
skipped += 1
except Exception:
skipped += 1
try:
conn.logout()
except Exception:
pass
yield {"type": "done", "account": name, "added": len(emails), "skipped": skipped,
"emails": emails}

View file

@ -152,3 +152,176 @@ def test_config_labels_returns_metadata(client):
assert "emoji" in labels[0] assert "emoji" in labels[0]
assert "color" in labels[0] assert "color" in labels[0]
assert "name" in labels[0] assert "name" in labels[0]
# ── /api/config ──────────────────────────────────────────────────────────────
@pytest.fixture
def config_dir(tmp_path):
"""Give the API a writable config directory."""
from app import api as api_module
api_module.set_config_dir(tmp_path)
yield tmp_path
api_module.set_config_dir(None) # reset to default
@pytest.fixture
def data_dir():
"""Expose the current _DATA_DIR set by the autouse reset_globals fixture."""
from app import api as api_module
return api_module._DATA_DIR
def test_get_config_returns_empty_when_no_file(client, config_dir):
r = client.get("/api/config")
assert r.status_code == 200
data = r.json()
assert data["accounts"] == []
assert data["max_per_account"] == 500
def test_post_config_writes_yaml(client, config_dir):
import yaml
payload = {
"accounts": [{"name": "Test", "host": "imap.test.com", "port": 993,
"use_ssl": True, "username": "u@t.com", "password": "pw",
"folder": "INBOX", "days_back": 30}],
"max_per_account": 200,
}
r = client.post("/api/config", json=payload)
assert r.status_code == 200
assert r.json()["ok"] is True
cfg_file = config_dir / "label_tool.yaml"
assert cfg_file.exists()
saved = yaml.safe_load(cfg_file.read_text())
assert saved["max_per_account"] == 200
assert saved["accounts"][0]["name"] == "Test"
def test_get_config_round_trips(client, config_dir):
payload = {"accounts": [{"name": "R", "host": "h", "port": 993, "use_ssl": True,
"username": "u", "password": "p", "folder": "INBOX",
"days_back": 90}], "max_per_account": 300}
client.post("/api/config", json=payload)
r = client.get("/api/config")
data = r.json()
assert data["max_per_account"] == 300
assert data["accounts"][0]["name"] == "R"
# ── /api/stats ───────────────────────────────────────────────────────────────
@pytest.fixture
def score_with_labels(tmp_path, data_dir):
"""Write a score file with 3 labels for stats tests."""
score_path = data_dir / "email_score.jsonl"
records = [
{"id": "a", "label": "interview_scheduled"},
{"id": "b", "label": "interview_scheduled"},
{"id": "c", "label": "rejected"},
]
score_path.write_text("\n".join(json.dumps(r) for r in records) + "\n")
return records
def test_stats_returns_counts(client, score_with_labels):
r = client.get("/api/stats")
assert r.status_code == 200
data = r.json()
assert data["total"] == 3
assert data["counts"]["interview_scheduled"] == 2
assert data["counts"]["rejected"] == 1
def test_stats_empty_when_no_file(client, data_dir):
r = client.get("/api/stats")
assert r.status_code == 200
data = r.json()
assert data["total"] == 0
assert data["counts"] == {}
assert data["score_file_bytes"] == 0
def test_stats_download_returns_file(client, score_with_labels):
r = client.get("/api/stats/download")
assert r.status_code == 200
assert "jsonlines" in r.headers.get("content-type", "")
def test_stats_download_404_when_no_file(client, data_dir):
r = client.get("/api/stats/download")
assert r.status_code == 404
# ── /api/accounts/test ───────────────────────────────────────────────────────
def test_account_test_missing_fields(client):
r = client.post("/api/accounts/test", json={"account": {"host": "", "username": "", "password": ""}})
assert r.status_code == 200
data = r.json()
assert data["ok"] is False
assert "required" in data["message"].lower()
def test_account_test_success(client):
from unittest.mock import MagicMock, patch
mock_conn = MagicMock()
mock_conn.select.return_value = ("OK", [b"99"])
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
r = client.post("/api/accounts/test", json={"account": {
"host": "imap.example.com", "port": 993, "use_ssl": True,
"username": "u@example.com", "password": "pw", "folder": "INBOX",
}})
assert r.status_code == 200
data = r.json()
assert data["ok"] is True
assert data["count"] == 99
# ── /api/fetch/stream (SSE) ──────────────────────────────────────────────────
def _parse_sse(content: bytes) -> list[dict]:
"""Parse SSE response body into list of event dicts."""
events = []
for line in content.decode().splitlines():
if line.startswith("data: "):
events.append(json.loads(line[6:]))
return events
def test_fetch_stream_no_accounts_configured(client, config_dir):
"""With no config, stream should immediately complete with 0 added."""
r = client.get("/api/fetch/stream?accounts=NoSuchAccount&days_back=30&limit=10")
assert r.status_code == 200
events = _parse_sse(r.content)
complete = next((e for e in events if e["type"] == "complete"), None)
assert complete is not None
assert complete["total_added"] == 0
def test_fetch_stream_with_mock_imap(client, config_dir, data_dir):
"""With one configured account, stream should yield start/done/complete events."""
import yaml
from unittest.mock import MagicMock, patch
# Write a config with one account
cfg = {"accounts": [{"name": "Mock", "host": "h", "port": 993, "use_ssl": True,
"username": "u", "password": "p", "folder": "INBOX",
"days_back": 30}], "max_per_account": 50}
(config_dir / "label_tool.yaml").write_text(yaml.dump(cfg))
raw_msg = (b"Subject: Interview\r\nFrom: a@b.com\r\n"
b"Date: Mon, 1 Mar 2026 12:00:00 +0000\r\n\r\nBody")
mock_conn = MagicMock()
mock_conn.search.return_value = ("OK", [b"1"])
mock_conn.fetch.return_value = ("OK", [(b"1 (RFC822 {N})", raw_msg)])
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
r = client.get("/api/fetch/stream?accounts=Mock&days_back=30&limit=50")
assert r.status_code == 200
events = _parse_sse(r.content)
types = [e["type"] for e in events]
assert "start" in types
assert "done" in types
assert "complete" in types

86
tests/test_imap_fetch.py Normal file
View file

@ -0,0 +1,86 @@
"""Tests for imap_fetch — IMAP calls mocked."""
from unittest.mock import MagicMock, patch
def test_test_connection_missing_fields():
from app.imap_fetch import test_connection
ok, msg, count = test_connection({"host": "", "username": "", "password": ""})
assert ok is False
assert "required" in msg.lower()
def test_test_connection_success():
from app.imap_fetch import test_connection
mock_conn = MagicMock()
mock_conn.select.return_value = ("OK", [b"42"])
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
ok, msg, count = test_connection({
"host": "imap.example.com", "port": 993, "use_ssl": True,
"username": "u@example.com", "password": "secret", "folder": "INBOX",
})
assert ok is True
assert count == 42
assert "42" in msg
def test_test_connection_auth_failure():
from app.imap_fetch import test_connection
import imaplib
with patch("app.imap_fetch.imaplib.IMAP4_SSL", side_effect=imaplib.IMAP4.error("auth failed")):
ok, msg, count = test_connection({
"host": "imap.example.com", "port": 993, "use_ssl": True,
"username": "u@example.com", "password": "wrong", "folder": "INBOX",
})
assert ok is False
assert count is None
def test_fetch_account_stream_yields_start_done(tmp_path):
from app.imap_fetch import fetch_account_stream
mock_conn = MagicMock()
mock_conn.search.return_value = ("OK", [b"1 2"])
raw_msg = b"Subject: Test\r\nFrom: a@b.com\r\nDate: Mon, 1 Mar 2026 12:00:00 +0000\r\n\r\nHello"
mock_conn.fetch.return_value = ("OK", [(b"1 (RFC822 {N})", raw_msg)])
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
events = list(fetch_account_stream(
acc={"host": "h", "port": 993, "use_ssl": True,
"username": "u", "password": "p", "folder": "INBOX", "name": "Test"},
days_back=30, limit=10, known_keys=set(),
))
types = [e["type"] for e in events]
assert "start" in types
assert "done" in types
def test_fetch_account_stream_deduplicates(tmp_path):
from app.imap_fetch import fetch_account_stream
raw_msg = b"Subject: Dupe\r\nFrom: a@b.com\r\nDate: Mon, 1 Mar 2026 12:00:00 +0000\r\n\r\nBody"
mock_conn = MagicMock()
mock_conn.search.return_value = ("OK", [b"1"])
mock_conn.fetch.return_value = ("OK", [(b"1 (RFC822 {N})", raw_msg)])
known = set()
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
events1 = list(fetch_account_stream(
{"host": "h", "port": 993, "use_ssl": True, "username": "u",
"password": "p", "folder": "INBOX", "name": "T"},
30, 10, known,
))
done1 = next(e for e in events1 if e["type"] == "done")
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
events2 = list(fetch_account_stream(
{"host": "h", "port": 993, "use_ssl": True, "username": "u",
"password": "p", "folder": "INBOX", "name": "T"},
30, 10, known,
))
done2 = next(e for e in events2 if e["type"] == "done")
assert done1["added"] == 1
assert done2["added"] == 0

304
web/package-lock.json generated
View file

@ -14,7 +14,8 @@
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.2.1", "@vueuse/integrations": "^14.2.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.25" "vue": "^3.5.25",
"vue-router": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
@ -90,6 +91,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@ -843,7 +860,6 @@
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@ -854,7 +870,6 @@
"version": "2.3.5", "version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
@ -865,7 +880,6 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@ -881,7 +895,6 @@
"version": "0.3.31", "version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
@ -2208,6 +2221,33 @@
"vscode-uri": "^3.0.8" "vscode-uri": "^3.0.8"
} }
}, },
"node_modules/@vue-macros/common": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz",
"integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==",
"license": "MIT",
"dependencies": {
"@vue/compiler-sfc": "^3.5.22",
"ast-kit": "^2.1.2",
"local-pkg": "^1.1.2",
"magic-string-ast": "^1.0.2",
"unplugin-utils": "^0.3.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/vue-macros"
},
"peerDependencies": {
"vue": "^2.7.0 || ^3.2.25"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
}
},
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.29", "version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
@ -2505,7 +2545,6 @@
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
@ -2567,6 +2606,38 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/ast-kit": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
"integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"pathe": "^2.0.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/ast-walker-scope": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz",
"integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.4",
"ast-kit": "^2.1.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -2627,7 +2698,6 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"readdirp": "^5.0.0" "readdirp": "^5.0.0"
@ -2680,7 +2750,6 @@
"version": "0.1.8", "version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/config-chain": { "node_modules/config-chain": {
@ -2940,11 +3009,16 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@ -3217,6 +3291,80 @@
} }
} }
}, },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/local-pkg": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
"license": "MIT",
"dependencies": {
"mlly": "^1.7.4",
"pkg-types": "^2.3.0",
"quansync": "^0.2.11"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/local-pkg/node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"license": "MIT"
},
"node_modules/local-pkg/node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"pathe": "^2.0.3"
}
},
"node_modules/local-pkg/node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "11.2.6", "version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
@ -3262,6 +3410,21 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/magic-string-ast": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz",
"integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==",
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.19"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/mdn-data": { "node_modules/mdn-data": {
"version": "2.12.2", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
@ -3305,7 +3468,6 @@
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
"integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"acorn": "^8.15.0", "acorn": "^8.15.0",
@ -3335,7 +3497,6 @@
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@ -3538,7 +3699,6 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/perfect-debounce": { "node_modules/perfect-debounce": {
@ -3557,7 +3717,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -3591,7 +3750,6 @@
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"confbox": "^0.1.8", "confbox": "^0.1.8",
@ -3665,7 +3823,6 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 20.19.0" "node": ">= 20.19.0"
@ -3759,6 +3916,12 @@
"node": ">=v12.22.7" "node": ">=v12.22.7"
} }
}, },
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT"
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@ -4006,7 +4169,6 @@
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -4118,7 +4280,6 @@
"version": "1.6.3", "version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unconfig": { "node_modules/unconfig": {
@ -4237,7 +4398,6 @@
"version": "0.3.1", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"pathe": "^2.0.3", "pathe": "^2.0.3",
@ -4438,6 +4598,98 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/vue-router": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
"integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==",
"license": "MIT",
"dependencies": {
"@babel/generator": "^7.28.6",
"@vue-macros/common": "^3.1.1",
"@vue/devtools-api": "^8.0.6",
"ast-walker-scope": "^0.8.3",
"chokidar": "^5.0.0",
"json5": "^2.2.3",
"local-pkg": "^1.1.2",
"magic-string": "^0.30.21",
"mlly": "^1.8.0",
"muggle-string": "^0.4.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"scule": "^1.3.0",
"tinyglobby": "^0.2.15",
"unplugin": "^3.0.0",
"unplugin-utils": "^0.3.1",
"yaml": "^2.8.2"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"@pinia/colada": ">=0.21.2",
"@vue/compiler-sfc": "^3.5.17",
"pinia": "^3.0.4",
"vue": "^3.5.0"
},
"peerDependenciesMeta": {
"@pinia/colada": {
"optional": true
},
"@vue/compiler-sfc": {
"optional": true
},
"pinia": {
"optional": true
}
}
},
"node_modules/vue-router/node_modules/@vue/devtools-api": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.7.tgz",
"integrity": "sha512-tc1TXAxclsn55JblLkFVcIRG7MeSJC4fWsPjfM7qu/IcmPUYnQ5Q8vzWwBpyDY24ZjmZTUCCwjRSNbx58IhlAA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^8.0.7"
}
},
"node_modules/vue-router/node_modules/@vue/devtools-kit": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.7.tgz",
"integrity": "sha512-H6esJGHGl5q0E9iV3m2EoBQHJ+V83WMW83A0/+Fn95eZ2iIvdsq4+UCS6yT/Fdd4cGZSchx/MdWDreM3WqMsDw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^8.0.7",
"birpc": "^2.6.1",
"hookable": "^5.5.3",
"perfect-debounce": "^2.0.0"
}
},
"node_modules/vue-router/node_modules/@vue/devtools-shared": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.7.tgz",
"integrity": "sha512-CgAb9oJH5NUmbQRdYDj/1zMiaICYSLtm+B1kxcP72LBrifGAjUmt8bx52dDH1gWRPlQgxGPqpAMKavzVirAEhA==",
"license": "MIT"
},
"node_modules/vue-router/node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"license": "MIT"
},
"node_modules/vue-router/node_modules/unplugin": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
"integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/vue-tsc": { "node_modules/vue-tsc": {
"version": "3.2.5", "version": "3.2.5",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.5.tgz", "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.5.tgz",
@ -4482,7 +4734,6 @@
"version": "0.6.2", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/whatwg-mimetype": { "node_modules/whatwg-mimetype": {
@ -4657,6 +4908,21 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
} }
} }
} }

View file

@ -17,7 +17,8 @@
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.2.1", "@vueuse/integrations": "^14.2.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.25" "vue": "^3.5.25",
"vue-router": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^24.10.1",

View file

@ -1,14 +1,18 @@
<template> <template>
<div id="app" :class="{ 'rich-motion': motion.rich.value }"> <div id="app" :class="{ 'rich-motion': motion.rich.value }">
<LabelView /> <AppSidebar />
<main class="app-main">
<RouterView />
</main>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useMotion } from './composables/useMotion' import { useMotion } from './composables/useMotion'
import { useHackerMode } from './composables/useEasterEgg' import { useHackerMode } from './composables/useEasterEgg'
import LabelView from './views/LabelView.vue' import AppSidebar from './components/AppSidebar.vue'
const motion = useMotion() const motion = useMotion()
const { restore } = useHackerMode() const { restore } = useHackerMode()
@ -34,6 +38,15 @@ body {
} }
#app { #app {
display: flex;
min-height: 100dvh; min-height: 100dvh;
overflow-x: hidden;
}
.app-main {
flex: 1;
min-width: 0; /* prevent flex blowout */
margin-left: var(--sidebar-width, 200px);
transition: margin-left 250ms ease;
} }
</style> </style>

View file

@ -0,0 +1,274 @@
<template>
<!-- Mobile backdrop scrim -->
<div
v-if="isMobile && !stowed"
class="sidebar-scrim"
aria-hidden="true"
@click="stow()"
/>
<nav
class="sidebar"
:class="{ stowed, mobile: isMobile }"
:style="{ '--sidebar-w': stowed ? '56px' : '200px' }"
aria-label="App navigation"
>
<!-- Logo + stow toggle -->
<div class="sidebar-header">
<span v-if="!stowed" class="sidebar-logo">
<span class="logo-icon">🐦</span>
<span class="logo-name">Avocet</span>
</span>
<button
class="stow-btn"
:aria-label="stowed ? 'Expand navigation' : 'Collapse navigation'"
@click="toggle()"
>
{{ stowed ? '' : '' }}
</button>
</div>
<!-- Nav items -->
<ul class="nav-list" role="list">
<li v-for="item in navItems" :key="item.path">
<RouterLink
:to="item.path"
class="nav-item"
:title="stowed ? item.label : ''"
@click="isMobile && stow()"
>
<span class="nav-icon" aria-hidden="true">{{ item.icon }}</span>
<span v-if="!stowed" class="nav-label">{{ item.label }}</span>
</RouterLink>
</li>
</ul>
</nav>
<!-- Mobile hamburger button rendered outside the sidebar so it's visible when stowed -->
<button
v-if="isMobile && stowed"
class="mobile-hamburger"
aria-label="Open navigation"
@click="toggle()"
>
</button>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
const LS_KEY = 'cf-avocet-nav-stowed'
const navItems = [
{ path: '/', icon: '🃏', label: 'Label' },
{ path: '/fetch', icon: '📥', label: 'Fetch' },
{ path: '/stats', icon: '📊', label: 'Stats' },
{ path: '/settings', icon: '⚙️', label: 'Settings' },
]
const stowed = ref(localStorage.getItem(LS_KEY) === 'true')
const winWidth = ref(window.innerWidth)
const isMobile = computed(() => winWidth.value < 640)
function toggle() {
stowed.value = !stowed.value
localStorage.setItem(LS_KEY, String(stowed.value))
// Update CSS variable on :root so .app-main margin-left syncs
document.documentElement.style.setProperty('--sidebar-width', stowed.value ? '56px' : '200px')
}
function stow() {
stowed.value = true
localStorage.setItem(LS_KEY, 'true')
document.documentElement.style.setProperty('--sidebar-width', '56px')
}
function onResize() { winWidth.value = window.innerWidth }
onMounted(() => {
window.addEventListener('resize', onResize)
// Apply persisted sidebar width to :root on mount
document.documentElement.style.setProperty('--sidebar-width', stowed.value ? '56px' : '200px')
// On mobile, default to stowed
if (isMobile.value && !localStorage.getItem(LS_KEY)) {
stowed.value = true
document.documentElement.style.setProperty('--sidebar-width', '56px')
}
})
onUnmounted(() => window.removeEventListener('resize', onResize))
</script>
<style scoped>
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-w, 200px);
background: var(--color-surface-raised, #e4ebf5);
border-right: 1px solid var(--color-border, #d0d7e8);
display: flex;
flex-direction: column;
z-index: 200;
transition: width 250ms ease;
overflow: hidden;
}
.sidebar.stowed {
width: 56px;
}
/* Mobile: slide in/out from left */
.sidebar.mobile {
box-shadow: 2px 0 16px rgba(0, 0, 0, 0.15);
}
.sidebar.mobile.stowed {
transform: translateX(-100%);
width: 200px; /* keep width so slide-in looks right */
transition: transform 250ms ease, width 250ms ease;
}
.sidebar.mobile:not(.stowed) {
transform: translateX(0);
transition: transform 250ms ease;
}
.sidebar-scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 199;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0.5rem 0.75rem 0.75rem;
border-bottom: 1px solid var(--color-border, #d0d7e8);
min-height: 52px;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 0.4rem;
overflow: hidden;
white-space: nowrap;
}
.logo-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.logo-name {
font-family: var(--font-display, var(--font-body, sans-serif));
font-size: 1rem;
font-weight: 700;
color: var(--app-primary, #2A6080);
}
.stow-btn {
flex-shrink: 0;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--color-text-secondary, #6b7a99);
cursor: pointer;
font-size: 1.1rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.stow-btn:hover {
background: var(--color-border, #d0d7e8);
}
.nav-list {
list-style: none;
padding: 0.5rem 0;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.75rem;
color: var(--color-text, #1a2338);
text-decoration: none;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
position: relative;
transition: background 0.15s, color 0.15s;
}
.nav-item:hover {
background: color-mix(in srgb, var(--app-primary, #2A6080) 10%, transparent);
}
.nav-item.router-link-active {
background: color-mix(in srgb, var(--app-primary, #2A6080) 15%, transparent);
color: var(--app-primary, #2A6080);
font-weight: 600;
}
.nav-item.router-link-active::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--app-primary, #2A6080);
border-radius: 0 2px 2px 0;
}
.nav-icon {
font-size: 1.1rem;
flex-shrink: 0;
width: 24px;
text-align: center;
}
.nav-label {
overflow: hidden;
text-overflow: ellipsis;
}
/* Mobile hamburger — visible when sidebar is stowed on mobile */
.mobile-hamburger {
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 201;
width: 36px;
height: 36px;
border: 1px solid var(--color-border, #d0d7e8);
background: var(--color-surface-raised, #e4ebf5);
border-radius: 0.375rem;
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
@media (prefers-reduced-motion: reduce) {
.sidebar,
.sidebar.mobile,
.sidebar.mobile.stowed {
transition: none;
}
}
</style>

View file

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import EmailCardStack from './EmailCardStack.vue' import EmailCardStack from './EmailCardStack.vue'
import { describe, it, expect } from 'vitest' import { describe, it, expect, vi } from 'vitest'
const item = { const item = {
id: 'abc', id: 'abc',
@ -45,15 +45,70 @@ describe('EmailCardStack', () => {
expect(wrapperClasses).not.toContain('dismiss-skip') expect(wrapperClasses).not.toContain('dismiss-skip')
}) })
it('emits drag-start on dragstart event', async () => { // JSDOM doesn't implement setPointerCapture — mock it on the element.
// Also use dispatchEvent(new PointerEvent) directly because @vue/test-utils
// .trigger() tries to assign clientX on a MouseEvent (read-only in JSDOM).
function mockPointerCapture(element: Element) {
;(element as any).setPointerCapture = vi.fn()
;(element as any).releasePointerCapture = vi.fn()
}
function fire(element: Element, type: string, init: PointerEventInit) {
element.dispatchEvent(new PointerEvent(type, { bubbles: true, ...init }))
}
it('emits drag-start on pointerdown', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
await w.find('.card-stack').trigger('dragstart') const el = w.find('.card-wrapper').element
mockPointerCapture(el)
fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('drag-start')).toBeTruthy() expect(w.emitted('drag-start')).toBeTruthy()
}) })
it('emits drag-end on dragend event', async () => { it('emits drag-end on pointerup', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
await w.find('.card-stack').trigger('dragend') const el = w.find('.card-wrapper').element
mockPointerCapture(el)
fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 200, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('drag-end')).toBeTruthy() expect(w.emitted('drag-end')).toBeTruthy()
}) })
it('emits discard when released in left zone (x < 7% viewport)', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
// JSDOM window.innerWidth defaults to 1024; 7% = 71.7px
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
fire(el, 'pointermove', { pointerId: 1, clientX: 30, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 30, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeTruthy()
})
it('emits skip when released in right zone (x > 93% viewport)', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
// JSDOM window.innerWidth defaults to 1024; 93% = 952px
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
fire(el, 'pointermove', { pointerId: 1, clientX: 1000, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 1000, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('skip')).toBeTruthy()
})
it('does not emit action on pointerup without movement past zone', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 512, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeFalsy()
expect(w.emitted('skip')).toBeFalsy()
expect(w.emitted('label')).toBeFalsy()
})
}) })

View file

@ -1,10 +1,7 @@
<template> <template>
<div <div
class="card-stack" class="card-stack"
ref="stackEl" :class="{ 'bucket-mode': isBucketMode && motion.rich.value }"
:draggable="motion.rich.value"
@dragstart="onDragStart"
@dragend="onDragEnd"
> >
<!-- Depth shadow cards (visual stack effect) --> <!-- Depth shadow cards (visual stack effect) -->
<div class="card-shadow card-shadow-2" aria-hidden="true" /> <div class="card-shadow card-shadow-2" aria-hidden="true" />
@ -14,8 +11,12 @@
<div <div
class="card-wrapper" class="card-wrapper"
ref="cardEl" ref="cardEl"
:class="dismissClass" :class="[dismissClass, { 'is-held': isHeld }]"
:style="cardStyle" :style="cardStyle"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
> >
<EmailCard <EmailCard
:item="item" :item="item"
@ -29,7 +30,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useSwipe } from '@vueuse/core'
import { useMotion } from '../composables/useMotion' import { useMotion } from '../composables/useMotion'
import EmailCard from './EmailCard.vue' import EmailCard from './EmailCard.vue'
import type { QueueItem } from '../stores/label' import type { QueueItem } from '../stores/label'
@ -41,30 +41,112 @@ const props = defineProps<{
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
label: [name: string] label: [name: string]
skip: [] skip: []
discard: [] discard: []
'drag-start': [] 'drag-start': []
'drag-end': [] 'drag-end': []
'zone-hover': ['discard' | 'skip' | null]
'bucket-hover': [string | null]
}>() }>()
const motion = useMotion() const motion = useMotion()
const cardEl = ref<HTMLElement | null>(null) const cardEl = ref<HTMLElement | null>(null)
const stackEl = ref<HTMLElement | null>(null)
const isExpanded = ref(false) const isExpanded = ref(false)
const dragX = ref(0)
const { isSwiping, lengthX } = useSwipe(cardEl, { // Toss gesture state
threshold: 60, const isHeld = ref(false)
onSwipeEnd(_, dir) { const pickupX = ref(0)
if (dir === 'left') emit('discard') const pickupY = ref(0)
if (dir === 'right') emit('skip') const deltaX = ref(0)
dragX.value = 0 const deltaY = ref(0)
}, const hoveredZone = ref<'discard' | 'skip' | null>(null)
onSwipe() { const hoveredBucketName = ref<string | null>(null)
if (motion.rich.value) dragX.value = lengthX.value * -1
}, // Zone threshold: 7% of viewport width on each side
}) const ZONE_PCT = 0.07
function onPointerDown(e: PointerEvent) {
if (!motion.rich.value) return
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
pickupX.value = e.clientX
pickupY.value = e.clientY
deltaX.value = 0
deltaY.value = 0
isHeld.value = true
hoveredZone.value = null
hoveredBucketName.value = null
emit('drag-start')
}
function onPointerMove(e: PointerEvent) {
if (!isHeld.value) return
deltaX.value = e.clientX - pickupX.value
deltaY.value = e.clientY - pickupY.value
const vw = window.innerWidth
const zone: 'discard' | 'skip' | null =
e.clientX < vw * ZONE_PCT ? 'discard' :
e.clientX > vw * (1 - ZONE_PCT) ? 'skip' :
null
if (zone !== hoveredZone.value) {
hoveredZone.value = zone
emit('zone-hover', zone)
}
// Bucket detection via hit-test (works through overlapping elements).
// Optional chain guards against JSDOM in tests (doesn't implement elementsFromPoint).
const els = document.elementsFromPoint?.(e.clientX, e.clientY) ?? []
const bucket = els.find(el => el.hasAttribute('data-label-key'))
const bucketName = bucket?.getAttribute('data-label-key') ?? null
if (bucketName !== hoveredBucketName.value) {
hoveredBucketName.value = bucketName
emit('bucket-hover', bucketName)
}
}
function onPointerUp(e: PointerEvent) {
if (!isHeld.value) return
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
isHeld.value = false
emit('drag-end')
emit('zone-hover', null)
emit('bucket-hover', null)
if (hoveredZone.value === 'discard') {
hoveredZone.value = null
hoveredBucketName.value = null
emit('discard')
} else if (hoveredZone.value === 'skip') {
hoveredZone.value = null
hoveredBucketName.value = null
emit('skip')
} else if (hoveredBucketName.value) {
const name = hoveredBucketName.value
hoveredZone.value = null
hoveredBucketName.value = null
emit('label', name)
} else {
// Snap back reset deltas
deltaX.value = 0
deltaY.value = 0
hoveredZone.value = null
hoveredBucketName.value = null
}
}
function onPointerCancel(e: PointerEvent) {
if (!isHeld.value) return
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
isHeld.value = false
deltaX.value = 0
deltaY.value = 0
hoveredZone.value = null
hoveredBucketName.value = null
emit('drag-end')
emit('zone-hover', null)
emit('bucket-hover', null)
}
const dismissClass = computed(() => { const dismissClass = computed(() => {
if (!props.dismissType) return null if (!props.dismissType) return null
@ -72,28 +154,56 @@ const dismissClass = computed(() => {
}) })
const cardStyle = computed(() => { const cardStyle = computed(() => {
if (!motion.rich.value || !isSwiping.value) return {} if (!motion.rich.value || !isHeld.value) return {}
const tilt = dragX.value * 0.05
const opacity = Math.abs(dragX.value) > 20 ? 0.9 : 1 // Aura color: zone > bucket > neutral
const color = dragX.value < -20 ? 'rgba(244,67,54,0.15)' const aura =
: dragX.value > 20 ? 'rgba(255,152,0,0.15)' hoveredZone.value === 'discard' ? 'rgba(244,67,54,0.25)' :
: 'transparent' hoveredZone.value === 'skip' ? 'rgba(255,152,0,0.25)' :
hoveredBucketName.value ? 'rgba(42,96,128,0.20)' :
'transparent'
return { return {
transform: `translateX(${dragX.value}px) rotate(${tilt}deg)`, transform: `translate(${deltaX.value}px, ${deltaY.value}px) scale(0.35)`,
opacity, borderRadius: '50%',
background: color, background: aura,
transition: isSwiping.value ? 'none' : 'all 0.3s ease', transition: 'border-radius 150ms ease, background 150ms ease',
cursor: 'grabbing',
zIndex: 100,
userSelect: 'none',
} }
}) })
function onDragStart() { emit('drag-start') }
function onDragEnd() { emit('drag-end') }
</script> </script>
<style scoped> <style scoped>
.card-stack { .card-stack {
position: relative; position: relative;
min-height: 200px; min-height: 200px;
max-height: 2000px; /* effectively unlimited — needed for max-height transition */
overflow: hidden;
transition:
max-height 280ms cubic-bezier(0.34, 1.56, 0.64, 1),
min-height 280ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Bucket mode: collapse card stack to a small pill so buckets get more room */
.card-stack.bucket-mode {
min-height: 0;
max-height: 90px;
display: flex;
justify-content: center;
align-items: flex-start;
}
.card-stack.bucket-mode .card-shadow {
opacity: 0;
transition: opacity 180ms ease;
}
.card-stack.bucket-mode .card-wrapper {
clip-path: inset(35% 15% round 0.75rem);
opacity: 0.35;
pointer-events: none;
} }
.card-shadow { .card-shadow {
@ -102,6 +212,7 @@ function onDragEnd() { emit('drag-end') }
border-radius: var(--radius-card, 1rem); border-radius: var(--radius-card, 1rem);
background: var(--color-surface-raised, #fff); background: var(--color-surface-raised, #fff);
border: 1px solid var(--color-border, #e0e4ed); border: 1px solid var(--color-border, #e0e4ed);
transition: opacity 180ms ease;
} }
.card-shadow-1 { transform: translateY(8px) scale(0.97); opacity: 0.6; } .card-shadow-1 { transform: translateY(8px) scale(0.97); opacity: 0.6; }
.card-shadow-2 { transform: translateY(16px) scale(0.94); opacity: 0.35; } .card-shadow-2 { transform: translateY(16px) scale(0.94); opacity: 0.35; }
@ -111,17 +222,28 @@ function onDragEnd() { emit('drag-end') }
z-index: 1; z-index: 1;
border-radius: var(--radius-card, 1rem); border-radius: var(--radius-card, 1rem);
background: var(--color-surface-raised, #fff); background: var(--color-surface-raised, #fff);
will-change: transform, opacity; will-change: clip-path, opacity;
clip-path: inset(0% 0% round 1rem);
touch-action: none; /* prevent scroll from stealing the gesture */
cursor: grab;
transition:
clip-path 260ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 220ms ease;
}
.card-wrapper.is-held {
cursor: grabbing;
} }
/* Dismissal animations — only active under .rich-motion on root */ /* Dismissal animations dismiss class is only applied during the motion.rich await window,
:global(.rich-motion) .card-wrapper.dismiss-label { so no ancestor guard needed; :global(.rich-motion) was being miscompiled by Vue's scoped
CSS transformer (dropping the descendant selector entirely). */
.card-wrapper.dismiss-label {
animation: fileAway var(--card-dismiss, 350ms ease-in) forwards; animation: fileAway var(--card-dismiss, 350ms ease-in) forwards;
} }
:global(.rich-motion) .card-wrapper.dismiss-discard { .card-wrapper.dismiss-discard {
animation: crumple var(--card-dismiss, 350ms ease-in) forwards; animation: crumple var(--card-dismiss, 350ms ease-in) forwards;
} }
:global(.rich-motion) .card-wrapper.dismiss-skip { .card-wrapper.dismiss-skip {
animation: slideUnder var(--card-skip, 300ms ease-out) forwards; animation: slideUnder var(--card-skip, 300ms ease-out) forwards;
} }
@ -135,4 +257,11 @@ function onDragEnd() { emit('drag-end') }
@keyframes slideUnder { @keyframes slideUnder {
to { transform: translateX(110%) rotate(5deg); opacity: 0; } to { transform: translateX(110%) rotate(5deg); opacity: 0; }
} }
@media (prefers-reduced-motion: reduce) {
.card-stack,
.card-wrapper {
transition: none;
}
}
</style> </style>

View file

@ -31,4 +31,22 @@ describe('LabelBucketGrid', () => {
expect(btn.text()).toContain('1') expect(btn.text()).toContain('1')
expect(btn.text()).toContain('🗓️') expect(btn.text()).toContain('🗓️')
}) })
it('marks button as drop-target when hoveredBucket matches label name', () => {
const w = mount(LabelBucketGrid, {
props: { labels, isBucketMode: true, hoveredBucket: 'interview_scheduled' },
})
const btns = w.findAll('[data-testid="label-btn"]')
expect(btns[0].classes()).toContain('is-drop-target')
expect(btns[1].classes()).not.toContain('is-drop-target')
})
it('no button marked as drop-target when hoveredBucket is null', () => {
const w = mount(LabelBucketGrid, {
props: { labels, isBucketMode: false, hoveredBucket: null },
})
w.findAll('[data-testid="label-btn"]').forEach(btn => {
expect(btn.classes()).not.toContain('is-drop-target')
})
})
}) })

View file

@ -4,14 +4,12 @@
v-for="label in labels" v-for="label in labels"
:key="label.key" :key="label.key"
data-testid="label-btn" data-testid="label-btn"
:data-label-key="label.name"
class="label-btn" class="label-btn"
:class="{ 'is-drop-target': dragOverLabel === label.name }" :class="{ 'is-drop-target': props.hoveredBucket === label.name }"
:style="{ '--label-color': label.color }" :style="{ '--label-color': label.color }"
:aria-label="`Label as ${label.name.replace(/_/g, ' ')} (key: ${label.key})`" :aria-label="`Label as ${label.name.replace(/_/g, ' ')} (key: ${label.key})`"
@click="$emit('label', label.name)" @click="$emit('label', label.name)"
@dragover.prevent="dragOverLabel = label.name"
@dragleave="dragOverLabel = null"
@drop.prevent="onDrop(label.name)"
> >
<span class="key-hint" aria-hidden="true">{{ label.key }}</span> <span class="key-hint" aria-hidden="true">{{ label.key }}</span>
<span class="emoji" aria-hidden="true">{{ label.emoji }}</span> <span class="emoji" aria-hidden="true">{{ label.emoji }}</span>
@ -21,34 +19,30 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
interface Label { name: string; emoji: string; color: string; key: string } interface Label { name: string; emoji: string; color: string; key: string }
const props = defineProps<{ labels: Label[]; isBucketMode: boolean }>() const props = defineProps<{
const emit = defineEmits<{ label: [name: string] }>() labels: Label[]
isBucketMode: boolean
const dragOverLabel = ref<string | null>(null) hoveredBucket?: string | null
}>()
function onDrop(name: string) { const emit = defineEmits<{ label: [name: string] }>()
dragOverLabel.value = null
emit('label', name)
}
</script> </script>
<style scoped> <style scoped>
.label-grid { .label-grid {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 0.5rem; gap: 0.5rem;
transition: all var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)); transition: gap var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
padding var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1));
} }
/* Mobile: 3-column numpad layout */ /* 10th button (hired / key h) — centered below the 3×3 like a numpad 0 */
@media (max-width: 480px) { .label-btn:last-child {
.label-grid { grid-column: 1 / -1;
grid-template-columns: repeat(3, 1fr); max-width: calc(33.333% - 0.34rem);
} justify-self: center;
} }
.label-grid.bucket-mode { .label-grid.bucket-mode {
@ -69,7 +63,14 @@ function onDrop(name: string) {
background: transparent; background: transparent;
color: var(--color-text, #1a2338); color: var(--color-text, #1a2338);
cursor: pointer; cursor: pointer;
transition: all var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)); transition: min-height var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
padding var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
border-width var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
font-size var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
background var(--transition, 200ms ease),
transform var(--transition, 200ms ease),
box-shadow var(--transition, 200ms ease),
opacity var(--transition, 200ms ease);
font-family: var(--font-body, sans-serif); font-family: var(--font-body, sans-serif);
} }

View file

@ -71,4 +71,19 @@ describe('UndoToast', () => {
const w = mount(UndoToast, { props: { action: labelAction } }) const w = mount(UndoToast, { props: { action: labelAction } })
expect(w.find('[role="status"]').exists()).toBe(true) expect(w.find('[role="status"]').exists()).toBe(true)
}) })
it('emits expire when tick fires with timestamp beyond DURATION', async () => {
let capturedTick: FrameRequestCallback | null = null
vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
capturedTick = fn
return 1
})
vi.spyOn(performance, 'now').mockReturnValue(0)
const w = mount(UndoToast, { props: { action: labelAction } })
await import('vue').then(v => v.nextTick())
// Simulate a tick timestamp 6 seconds in — beyond the 5-second DURATION
if (capturedTick) capturedTick(6000)
await import('vue').then(v => v.nextTick())
expect(w.emitted('expire')).toBeTruthy()
})
}) })

View file

@ -13,7 +13,7 @@ import { ref, onMounted, onUnmounted, computed } from 'vue'
import type { LastAction } from '../stores/label' import type { LastAction } from '../stores/label'
const props = defineProps<{ action: LastAction }>() const props = defineProps<{ action: LastAction }>()
defineEmits<{ undo: [] }>() const emit = defineEmits<{ undo: []; expire: [] }>()
const DURATION = 5000 const DURATION = 5000
const elapsed = ref(0) const elapsed = ref(0)
@ -30,14 +30,15 @@ const label = computed(() => {
}) })
function tick(ts: number) { function tick(ts: number) {
if (!start) start = ts
elapsed.value = ts - start elapsed.value = ts - start
if (elapsed.value < DURATION) { if (elapsed.value < DURATION) {
raf = requestAnimationFrame(tick) raf = requestAnimationFrame(tick)
} else {
emit('expire')
} }
} }
onMounted(() => { raf = requestAnimationFrame(tick) }) onMounted(() => { start = performance.now(); raf = requestAnimationFrame(tick) })
onUnmounted(() => cancelAnimationFrame(raf)) onUnmounted(() => cancelAnimationFrame(raf))
</script> </script>

View file

@ -18,3 +18,33 @@ export async function useApiFetch<T>(
return { data: null, error: { kind: 'network', message: String(e) } } return { data: null, error: { kind: 'network', message: String(e) } }
} }
} }
/**
* Open an SSE connection. Returns a cleanup function.
* onEvent receives each parsed JSON payload.
* onComplete is called when the server sends a {"type":"complete"} event.
* onError is called on connection error.
*/
export function useApiSSE(
url: string,
onEvent: (data: Record<string, unknown>) => void,
onComplete?: () => void,
onError?: (e: Event) => void,
): () => void {
const es = new EventSource(url)
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as Record<string, unknown>
onEvent(data)
if (data.type === 'complete') {
es.close()
onComplete?.()
}
} catch { /* ignore malformed events */ }
}
es.onerror = (e) => {
onError?.(e)
es.close()
}
return () => es.close()
}

View file

@ -89,4 +89,18 @@ describe('useLabelKeyboard', () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: '1' })) window.dispatchEvent(new KeyboardEvent('keydown', { key: '1' }))
expect(onLabel).not.toHaveBeenCalled() expect(onLabel).not.toHaveBeenCalled()
}) })
it('evaluates labels getter on each keypress', () => {
const labelList: { name: string; key: string; emoji: string; color: string }[] = []
const onLabel = vi.fn()
const { cleanup } = useLabelKeyboard({ labels: () => labelList, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
cleanups.push(cleanup)
// Before labels loaded — pressing '1' does nothing
window.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }))
expect(onLabel).not.toHaveBeenCalled()
// Add a label (simulating async load)
labelList.push({ name: 'interview_scheduled', key: '1', emoji: '🗓️', color: '#4CAF50' })
window.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }))
expect(onLabel).toHaveBeenCalledWith('interview_scheduled')
})
}) })

View file

@ -3,7 +3,7 @@ import { onUnmounted, getCurrentInstance } from 'vue'
interface Label { name: string; key: string; emoji: string; color: string } interface Label { name: string; key: string; emoji: string; color: string }
interface Options { interface Options {
labels: Label[] labels: Label[] | (() => Label[])
onLabel: (name: string) => void onLabel: (name: string) => void
onSkip: () => void onSkip: () => void
onDiscard: () => void onDiscard: () => void
@ -12,12 +12,13 @@ interface Options {
} }
export function useLabelKeyboard(opts: Options) { export function useLabelKeyboard(opts: Options) {
const keyMap = new Map(opts.labels.map(l => [l.key.toLowerCase(), l.name]))
function handler(e: KeyboardEvent) { function handler(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement) return if (e.target instanceof HTMLInputElement) return
if (e.target instanceof HTMLTextAreaElement) return if (e.target instanceof HTMLTextAreaElement) return
const k = e.key.toLowerCase() const k = e.key.toLowerCase()
// Evaluate labels lazily so reactive updates work
const labelList = typeof opts.labels === 'function' ? opts.labels() : opts.labels
const keyMap = new Map(labelList.map(l => [l.key.toLowerCase(), l.name]))
if (keyMap.has(k)) { opts.onLabel(keyMap.get(k)!); return } if (keyMap.has(k)) { opts.onLabel(keyMap.get(k)!); return }
if (k === 'h') { opts.onLabel('hired'); return } if (k === 'h') { opts.onLabel('hired'); return }
if (k === 's') { opts.onSkip(); return } if (k === 's') { opts.onSkip(); return }

View file

@ -1,5 +1,6 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { router } from './router'
// Self-hosted fonts — no Google Fonts CDN (privacy requirement) // Self-hosted fonts — no Google Fonts CDN (privacy requirement)
import '@fontsource/fraunces/400.css' import '@fontsource/fraunces/400.css'
import '@fontsource/fraunces/700.css' import '@fontsource/fraunces/700.css'
@ -11,6 +12,9 @@ import './assets/theme.css'
import './assets/avocet.css' import './assets/avocet.css'
import App from './App.vue' import App from './App.vue'
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router)
app.mount('#app') app.mount('#app')

17
web/src/router/index.ts Normal file
View file

@ -0,0 +1,17 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import LabelView from '../views/LabelView.vue'
// Views are lazy-loaded to keep initial bundle small
const FetchView = () => import('../views/FetchView.vue')
const StatsView = () => import('../views/StatsView.vue')
const SettingsView = () => import('../views/SettingsView.vue')
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: LabelView, meta: { title: 'Label' } },
{ path: '/fetch', component: FetchView, meta: { title: 'Fetch' } },
{ path: '/stats', component: StatsView, meta: { title: 'Stats' } },
{ path: '/settings', component: SettingsView, meta: { title: 'Settings' } },
],
})

459
web/src/views/FetchView.vue Normal file
View file

@ -0,0 +1,459 @@
<template>
<div class="fetch-view">
<h1 class="page-title">📥 Fetch Emails</h1>
<!-- No accounts -->
<div v-if="!loading && accounts.length === 0" class="empty-notice">
No accounts configured.
<RouterLink to="/settings" class="link">Go to Settings </RouterLink>
</div>
<template v-else>
<!-- Account selection -->
<section class="section">
<h2 class="section-title">Accounts</h2>
<label
v-for="acc in accounts"
:key="acc.name"
class="account-check"
>
<input v-model="selectedAccounts" type="checkbox" :value="acc.name" />
{{ acc.name || acc.username }}
</label>
</section>
<!-- Standard fetch options -->
<section class="section">
<h2 class="section-title">Options</h2>
<label class="field field-inline">
<span class="field-label">Days back</span>
<input v-model.number="daysBack" type="range" min="7" max="365" class="slider" />
<span class="field-value">{{ daysBack }}</span>
</label>
<label class="field field-inline">
<span class="field-label">Max per account</span>
<input v-model.number="limitPerAccount" type="number" min="10" max="2000" class="field-num" />
</label>
</section>
<!-- Fetch button -->
<button
class="btn-primary btn-fetch"
:disabled="fetching || selectedAccounts.length === 0"
@click="startFetch"
>
{{ fetching ? 'Fetching…' : '📥 Fetch from IMAP' }}
</button>
<!-- Progress bars -->
<div v-if="progress.length > 0" class="progress-section">
<div v-for="p in progress" :key="p.account" class="progress-row">
<span class="progress-name">{{ p.account }}</span>
<div class="progress-track">
<div
class="progress-fill"
:class="{ done: p.done, error: p.error }"
:style="{ width: `${p.pct}%` }"
/>
</div>
<span class="progress-label">{{ p.label }}</span>
</div>
<div v-if="completeMsg" class="complete-msg">{{ completeMsg }}</div>
</div>
<!-- Targeted fetch (collapsible) -->
<details class="targeted-section">
<summary class="targeted-summary">🎯 Targeted fetch (by date range + keyword)</summary>
<div class="targeted-fields">
<div class="field-row">
<label class="field field-grow">
<span>From</span>
<input v-model="targetSince" type="date" />
</label>
<label class="field field-grow">
<span>To</span>
<input v-model="targetBefore" type="date" />
</label>
</div>
<label class="field">
<span>Search term (optional)</span>
<input v-model="targetTerm" type="text" placeholder="e.g. interview" />
</label>
<label class="field">
<span>Match field</span>
<select v-model="targetField" class="field-select">
<option value="either">Subject or From</option>
<option value="subject">Subject only</option>
<option value="from">From only</option>
<option value="none">No filter (date range only)</option>
</select>
</label>
<button class="btn-secondary" :disabled="fetching" @click="startTargetedFetch">
🎯 Targeted fetch
</button>
<p class="targeted-note">
Targeted fetch uses the same SSE stream progress appears above.
</p>
</div>
</details>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useApiFetch, useApiSSE } from '../composables/useApi'
interface Account { name: string; username: string; days_back: number }
interface ProgressRow {
account: string
pct: number
label: string
done: boolean
error: boolean
}
const accounts = ref<Account[]>([])
const selectedAccounts = ref<string[]>([])
const daysBack = ref(90)
const limitPerAccount = ref(150)
const loading = ref(true)
const fetching = ref(false)
const progress = ref<ProgressRow[]>([])
const completeMsg = ref('')
// Targeted fetch
const targetSince = ref('')
const targetBefore = ref('')
const targetTerm = ref('')
const targetField = ref('either')
async function loadConfig() {
loading.value = true
const { data } = await useApiFetch<{ accounts: Account[]; max_per_account: number }>('/api/config')
loading.value = false
if (data) {
accounts.value = data.accounts
selectedAccounts.value = data.accounts.map(a => a.name)
limitPerAccount.value = data.max_per_account
}
}
function initProgress() {
progress.value = selectedAccounts.value.map(name => ({
account: name, pct: 0, label: 'waiting…', done: false, error: false,
}))
completeMsg.value = ''
}
function startFetch() {
if (fetching.value || selectedAccounts.value.length === 0) return
fetching.value = true
initProgress()
const params = new URLSearchParams({
accounts: selectedAccounts.value.join(','),
days_back: String(daysBack.value),
limit: String(limitPerAccount.value),
mode: 'wide',
})
useApiSSE(
`/api/fetch/stream?${params}`,
(data) => handleEvent(data as Record<string, unknown>),
() => { fetching.value = false },
() => { fetching.value = false },
)
}
function startTargetedFetch() {
if (fetching.value || selectedAccounts.value.length === 0) return
fetching.value = true
initProgress()
const params = new URLSearchParams({
accounts: selectedAccounts.value.join(','),
days_back: String(daysBack.value),
limit: String(limitPerAccount.value),
mode: 'targeted',
since: targetSince.value,
before: targetBefore.value,
term: targetTerm.value,
field: targetField.value,
})
useApiSSE(
`/api/fetch/stream?${params}`,
(data) => handleEvent(data as Record<string, unknown>),
() => { fetching.value = false },
() => { fetching.value = false },
)
}
function handleEvent(data: Record<string, unknown>) {
const type = data.type as string
const account = data.account as string | undefined
const row = account ? progress.value.find(p => p.account === account) : null
if (type === 'start' && row) {
row.label = `0 / ${data.total_uids} found`
row.pct = 2 // show a sliver immediately
} else if (type === 'progress' && row) {
const total = (data.total_uids as number) || 1
const fetched = (data.fetched as number) || 0
row.pct = Math.round((fetched / total) * 95)
row.label = `${fetched} fetched…`
} else if (type === 'done' && row) {
row.pct = 100
row.done = true
row.label = `${data.added} added, ${data.skipped} skipped`
} else if (type === 'error' && row) {
row.error = true
row.label = String(data.message || 'Error')
} else if (type === 'complete') {
completeMsg.value =
`Done — ${data.total_added} new email(s) added · Queue: ${data.queue_size}`
}
}
onMounted(loadConfig)
</script>
<style scoped>
.fetch-view {
max-width: 640px;
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page-title {
font-family: var(--font-display, var(--font-body, sans-serif));
font-size: 1.4rem;
font-weight: 700;
color: var(--app-primary, #2A6080);
}
.section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7a99);
text-transform: uppercase;
letter-spacing: 0.04em;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--color-border, #d0d7e8);
}
.account-check {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
cursor: pointer;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.88rem;
}
.field-inline {
flex-direction: row;
align-items: center;
gap: 0.75rem;
}
.field-label {
min-width: 120px;
color: var(--color-text-secondary, #6b7a99);
font-size: 0.85rem;
}
.field-value {
min-width: 32px;
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
}
.slider {
flex: 1;
accent-color: var(--app-primary, #2A6080);
}
.field-num {
width: 90px;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.375rem;
font-size: 0.9rem;
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
}
.btn-primary {
padding: 0.6rem 1.5rem;
border-radius: 0.5rem;
border: none;
background: var(--app-primary, #2A6080);
color: #fff;
font-size: 1rem;
font-family: var(--font-body, sans-serif);
cursor: pointer;
align-self: flex-start;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: var(--app-primary-dark, #1d4d65); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-fetch { min-width: 200px; }
.progress-section {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.progress-row {
display: grid;
grid-template-columns: 10rem 1fr 8rem;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.progress-name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-track {
height: 12px;
background: var(--color-surface-raised, #e4ebf5);
border-radius: 99px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--app-primary, #2A6080);
border-radius: 99px;
transition: width 0.3s ease;
}
.progress-fill.done { background: #4CAF50; }
.progress-fill.error { background: var(--color-error, #ef4444); }
.progress-label {
font-size: 0.78rem;
color: var(--color-text-secondary, #6b7a99);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.complete-msg {
font-size: 0.9rem;
font-weight: 600;
color: #155724;
background: #d4edda;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
margin-top: 0.5rem;
}
.targeted-section {
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.5rem;
overflow: hidden;
}
.targeted-summary {
padding: 0.6rem 0.75rem;
background: var(--color-surface-raised, #e4ebf5);
cursor: pointer;
font-size: 0.88rem;
font-weight: 600;
user-select: none;
}
.targeted-fields {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
background: var(--color-surface, #fff);
}
.field-row {
display: flex;
gap: 0.5rem;
}
.field-grow { flex: 1; }
.field select,
.field input[type="date"],
.field input[type="text"] {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.375rem;
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
font-size: 0.88rem;
}
.field-select { width: 100%; }
.btn-secondary {
padding: 0.4rem 0.9rem;
border-radius: 0.375rem;
border: 1px solid var(--color-border, #d0d7e8);
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
font-size: 0.85rem;
cursor: pointer;
font-family: var(--font-body, sans-serif);
align-self: flex-start;
transition: background 0.15s;
}
.btn-secondary:hover { background: var(--color-surface-raised, #e4ebf5); }
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
.targeted-note {
font-size: 0.78rem;
color: var(--color-text-secondary, #6b7a99);
}
.empty-notice {
color: var(--color-text-secondary, #6b7a99);
font-size: 0.9rem;
padding: 1rem;
border: 1px dashed var(--color-border, #d0d7e8);
border-radius: 0.5rem;
}
.link {
color: var(--app-primary, #2A6080);
text-decoration: underline;
}
</style>

View file

@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import LabelView from './LabelView.vue' import LabelView from './LabelView.vue'
import EmailCardStack from '../components/EmailCardStack.vue'
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock fetch globally // Mock fetch globally
@ -43,4 +44,49 @@ describe('LabelView', () => {
expect(w.text()).toContain('Skip') expect(w.text()).toContain('Skip')
expect(w.text()).toContain('Discard') expect(w.text()).toContain('Discard')
}) })
const queueItem = {
id: 'test-1', subject: 'Test Email', body: 'Test body',
from: 'test@test.com', date: '2026-03-05', source: 'test',
}
// Return queue items for /api/queue, empty array for /api/config/labels
function mockFetchWithQueue() {
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) =>
Promise.resolve({
ok: true,
json: async () => (url as string).includes('/api/queue')
? { items: [queueItem], total: 1 }
: [],
text: async () => '',
})
))
}
it('renders toss zone overlays when isHeld is true (after drag-start)', async () => {
mockFetchWithQueue()
const w = mount(LabelView, { global: { plugins: [createPinia()] } })
await new Promise(r => setTimeout(r, 0))
await w.vm.$nextTick()
// Zone overlays should not exist before drag
expect(w.find('.toss-zone-left').exists()).toBe(false)
// Emit drag-start from EmailCardStack child
const cardStack = w.findComponent(EmailCardStack)
cardStack.vm.$emit('drag-start')
await w.vm.$nextTick()
expect(w.find('.toss-zone-left').exists()).toBe(true)
expect(w.find('.toss-zone-right').exists()).toBe(true)
})
it('bucket-grid-footer has grid-active class while card is held', async () => {
mockFetchWithQueue()
const w = mount(LabelView, { global: { plugins: [createPinia()] } })
await new Promise(r => setTimeout(r, 0))
await w.vm.$nextTick()
expect(w.find('.bucket-grid-footer').classes()).not.toContain('grid-active')
const cardStack = w.findComponent(EmailCardStack)
cardStack.vm.$emit('drag-start')
await w.vm.$nextTick()
expect(w.find('.bucket-grid-footer').classes()).toContain('grid-active')
})
}) })

View file

@ -3,9 +3,11 @@
<!-- Header bar --> <!-- Header bar -->
<header class="lv-header"> <header class="lv-header">
<span class="queue-count"> <span class="queue-count">
<template v-if="store.totalRemaining > 0"> <span v-if="loading" class="queue-status">Loading</span>
<template v-else-if="store.totalRemaining > 0">
{{ store.totalRemaining }} remaining {{ store.totalRemaining }} remaining
</template> </template>
<span v-else class="queue-status">Queue empty</span>
<span v-if="onRoll" class="badge badge-roll">🔥 On a roll!</span> <span v-if="onRoll" class="badge badge-roll">🔥 On a roll!</span>
<span v-if="speedRound" class="badge badge-speed"> Speed round!</span> <span v-if="speedRound" class="badge badge-speed"> Speed round!</span>
<span v-if="fiftyDeep" class="badge badge-fifty">🎯 Fifty deep!</span> <span v-if="fiftyDeep" class="badge badge-fifty">🎯 Fifty deep!</span>
@ -34,24 +36,47 @@
<!-- Card stack + label grid --> <!-- Card stack + label grid -->
<template v-else> <template v-else>
<!-- Toss edge zones thin trip-wires at screen edges, visible only while card is held -->
<Transition name="zone-fade">
<div
v-if="isHeld && motion.rich.value"
class="toss-zone toss-zone-left"
:class="{ active: hoveredZone === 'discard' }"
aria-hidden="true"
></div>
</Transition>
<Transition name="zone-fade">
<div
v-if="isHeld && motion.rich.value"
class="toss-zone toss-zone-right"
:class="{ active: hoveredZone === 'skip' }"
aria-hidden="true"
></div>
</Transition>
<div class="card-stack-wrapper"> <div class="card-stack-wrapper">
<EmailCardStack <EmailCardStack
:item="store.current" :item="store.current"
:is-bucket-mode="isDragging" :is-bucket-mode="isHeld"
:dismiss-type="dismissType" :dismiss-type="dismissType"
@label="handleLabel" @label="handleLabel"
@skip="handleSkip" @skip="handleSkip"
@discard="handleDiscard" @discard="handleDiscard"
@drag-start="isDragging = true" @drag-start="isHeld = true"
@drag-end="isDragging = false" @drag-end="isHeld = false"
@zone-hover="hoveredZone = $event"
@bucket-hover="hoveredBucket = $event"
/> />
</div> </div>
<LabelBucketGrid <div class="bucket-grid-footer" :class="{ 'grid-active': isHeld }">
:labels="labels" <LabelBucketGrid
:is-bucket-mode="isDragging" :labels="labels"
@label="handleLabel" :is-bucket-mode="isHeld"
/> :hovered-bucket="hoveredBucket"
@label="handleLabel"
/>
</div>
</template> </template>
<!-- Undo toast --> <!-- Undo toast -->
@ -59,6 +84,7 @@
v-if="store.lastAction" v-if="store.lastAction"
:action="store.lastAction" :action="store.lastAction"
@undo="handleUndo" @undo="handleUndo"
@expire="store.clearLastAction()"
/> />
</div> </div>
</template> </template>
@ -81,7 +107,9 @@ const motion = useMotion() // only needed to pass to child — actual value u
const loading = ref(true) const loading = ref(true)
const apiError = ref(false) const apiError = ref(false)
const isDragging = ref(false) const isHeld = ref(false)
const hoveredZone = ref<'discard' | 'skip' | null>(null)
const hoveredBucket = ref<string | null>(null)
const labels = ref<any[]>([]) const labels = ref<any[]>([])
const dismissType = ref<'label' | 'skip' | 'discard' | null>(null) const dismissType = ref<'label' | 'skip' | 'discard' | null>(null)
@ -115,7 +143,10 @@ async function fetchBatch() {
apiError.value = false apiError.value = false
const { data, error } = await useApiFetch<{ items: any[]; total: number }>('/api/queue?limit=10') const { data, error } = await useApiFetch<{ items: any[]; total: number }>('/api/queue?limit=10')
loading.value = false loading.value = false
if (error || !data) { apiError.value = true; return } if (error || !data) {
apiError.value = true
return
}
store.queue = data.items store.queue = data.items
store.totalRemaining = data.total store.totalRemaining = data.total
@ -246,7 +277,7 @@ async function handleUndo() {
} }
useLabelKeyboard({ useLabelKeyboard({
labels: [], // will be updated after labels load keyboard not active until queue loads labels: () => labels.value, // getter evaluated on each keypress
onLabel: handleLabel, onLabel: handleLabel,
onSkip: handleSkip, onSkip: handleSkip,
onDiscard: handleDiscard, onDiscard: handleDiscard,
@ -286,6 +317,11 @@ onUnmounted(() => {
min-height: 100dvh; min-height: 100dvh;
} }
.queue-status {
opacity: 0.6;
font-style: italic;
}
.lv-header { .lv-header {
display: flex; display: flex;
align-items: center; align-items: center;
@ -382,5 +418,51 @@ onUnmounted(() => {
.card-stack-wrapper { .card-stack-wrapper {
flex: 1; flex: 1;
/* Give bottom breathing room so grid doesn't overlap content */
padding-bottom: 0.5rem;
} }
/* Bucket grid stays pinned to the bottom of the viewport while the email card
can be scrolled freely. "hired" (10th button) may clip on very small screens
that is intentional per design. */
.bucket-grid-footer {
position: sticky;
bottom: 0;
background: var(--color-bg, var(--color-surface, #f0f4fc));
padding: 0.5rem 0 0.75rem;
z-index: 10;
transition: transform 250ms cubic-bezier(0.34, 1.56, 0.64, 1),
background 200ms ease;
}
.bucket-grid-footer.grid-active {
transform: translateY(-8px);
}
/* ── Toss edge zones ── */
.toss-zone {
position: fixed;
top: 0;
bottom: 0;
width: 7%;
z-index: 50;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
opacity: 0.25;
transition: opacity 200ms ease, background 200ms ease;
}
.toss-zone-left { left: 0; background: rgba(244, 67, 54, 0.12); color: #ef4444; }
.toss-zone-right { right: 0; background: rgba(255, 152, 0, 0.12); color: #f97316; }
.toss-zone.active {
opacity: 0.85;
background: color-mix(in srgb, currentColor 25%, transparent);
}
/* Zone transition */
.zone-fade-enter-active,
.zone-fade-leave-active { transition: opacity 180ms ease; }
.zone-fade-enter-from,
.zone-fade-leave-to { opacity: 0; }
</style> </style>

View file

@ -0,0 +1,431 @@
<template>
<div class="settings-view">
<h1 class="page-title"> Settings</h1>
<!-- IMAP Accounts -->
<section class="section">
<h2 class="section-title">IMAP Accounts</h2>
<div v-if="accounts.length === 0" class="empty-notice">
No accounts configured yet. Click <strong> Add account</strong> to get started.
</div>
<details
v-for="(acc, i) in accounts"
:key="i"
class="account-panel"
open
>
<summary class="account-summary">
{{ acc.name || acc.username || `Account ${i + 1}` }}
</summary>
<div class="account-fields">
<label class="field">
<span>Display name</span>
<input v-model="acc.name" type="text" placeholder="e.g. Gmail Personal" />
</label>
<div class="field-row">
<label class="field field-grow">
<span>IMAP host</span>
<input v-model="acc.host" type="text" placeholder="imap.gmail.com" />
</label>
<label class="field field-short">
<span>Port</span>
<input v-model.number="acc.port" type="number" min="1" max="65535" />
</label>
<label class="field field-check">
<span>SSL</span>
<input v-model="acc.use_ssl" type="checkbox" />
</label>
</div>
<label class="field">
<span>Username</span>
<input v-model="acc.username" type="text" autocomplete="off" />
</label>
<label class="field">
<span>Password</span>
<div class="password-wrap">
<input
v-model="acc.password"
:type="showPassword[i] ? 'text' : 'password'"
autocomplete="new-password"
/>
<button type="button" class="btn-icon" @click="togglePassword(i)">
{{ showPassword[i] ? '🙈' : '👁' }}
</button>
</div>
</label>
<div class="field-row">
<label class="field field-grow">
<span>Folder</span>
<input v-model="acc.folder" type="text" placeholder="INBOX" />
</label>
<label class="field field-short">
<span>Days back</span>
<input v-model.number="acc.days_back" type="number" min="1" max="3650" />
</label>
</div>
<div class="account-actions">
<button class="btn-secondary" @click="testAccount(i)">🔌 Test connection</button>
<button class="btn-danger" @click="removeAccount(i)">🗑 Remove</button>
<span
v-if="testResults[i]"
class="test-result"
:class="testResults[i]?.ok ? 'result-ok' : 'result-err'"
>
{{ testResults[i]?.message }}
</span>
</div>
</div>
</details>
<button class="btn-secondary btn-add" @click="addAccount"> Add account</button>
</section>
<!-- Global settings -->
<section class="section">
<h2 class="section-title">Global</h2>
<label class="field field-inline">
<span>Max emails per account per fetch</span>
<input v-model.number="maxPerAccount" type="number" min="10" max="2000" class="field-num" />
</label>
</section>
<!-- Display settings -->
<section class="section">
<h2 class="section-title">Display</h2>
<label class="field field-inline">
<input v-model="richMotion" type="checkbox" @change="onMotionChange" />
<span>Rich animations &amp; haptic feedback</span>
</label>
<label class="field field-inline">
<input v-model="keyHints" type="checkbox" @change="onKeyHintsChange" />
<span>Show keyboard shortcut hints on label buttons</span>
</label>
</section>
<!-- Save / Reload -->
<div class="save-bar">
<button class="btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : '💾 Save' }}
</button>
<button class="btn-secondary" @click="reload"> Reload from disk</button>
<span v-if="saveMsg" class="save-msg" :class="saveOk ? 'msg-ok' : 'msg-err'">
{{ saveMsg }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
interface Account {
name: string; host: string; port: number; use_ssl: boolean
username: string; password: string; folder: string; days_back: number
}
const accounts = ref<Account[]>([])
const maxPerAccount = ref(500)
const showPassword = ref<boolean[]>([])
const testResults = ref<Array<{ ok: boolean; message: string } | null>>([])
const saving = ref(false)
const saveMsg = ref('')
const saveOk = ref(true)
const richMotion = ref(localStorage.getItem('cf-avocet-rich-motion') !== 'false')
const keyHints = ref(localStorage.getItem('cf-avocet-key-hints') !== 'false')
async function reload() {
const { data } = await useApiFetch<{ accounts: Account[]; max_per_account: number }>('/api/config')
if (data) {
accounts.value = data.accounts
maxPerAccount.value = data.max_per_account
showPassword.value = new Array(data.accounts.length).fill(false)
testResults.value = new Array(data.accounts.length).fill(null)
}
}
async function save() {
saving.value = true
saveMsg.value = ''
const { error } = await useApiFetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accounts: accounts.value, max_per_account: maxPerAccount.value }),
})
saving.value = false
if (error) {
saveOk.value = false
saveMsg.value = '✗ Save failed'
} else {
saveOk.value = true
saveMsg.value = '✓ Saved'
setTimeout(() => { saveMsg.value = '' }, 3000)
}
}
async function testAccount(i: number) {
testResults.value[i] = null
const { data } = await useApiFetch<{ ok: boolean; message: string; count: number | null }>(
'/api/accounts/test',
{ method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account: accounts.value[i] }) },
)
if (data) {
testResults.value[i] = { ok: data.ok, message: data.message }
// Easter egg: > 5000 messages
if (data.ok && data.count !== null && data.count > 5000) {
setTimeout(() => {
if (testResults.value[i]?.ok) {
testResults.value[i] = { ok: true, message: `${data.message} That's a lot of email 📬` }
}
}, 800)
}
}
}
function addAccount() {
accounts.value.push({
name: '', host: 'imap.gmail.com', port: 993, use_ssl: true,
username: '', password: '', folder: 'INBOX', days_back: 90,
})
showPassword.value.push(false)
testResults.value.push(null)
}
function removeAccount(i: number) {
accounts.value.splice(i, 1)
showPassword.value.splice(i, 1)
testResults.value.splice(i, 1)
}
function togglePassword(i: number) {
showPassword.value[i] = !showPassword.value[i]
}
function onMotionChange() {
localStorage.setItem('cf-avocet-rich-motion', String(richMotion.value))
}
function onKeyHintsChange() {
localStorage.setItem('cf-avocet-key-hints', String(keyHints.value))
document.documentElement.classList.toggle('hide-key-hints', !keyHints.value)
}
onMounted(reload)
</script>
<style scoped>
.settings-view {
max-width: 680px;
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.page-title {
font-family: var(--font-display, var(--font-body, sans-serif));
font-size: 1.4rem;
font-weight: 700;
color: var(--app-primary, #2A6080);
}
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #1a2338);
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--color-border, #d0d7e8);
}
.account-panel {
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.5rem;
overflow: hidden;
}
.account-summary {
padding: 0.6rem 0.75rem;
background: var(--color-surface-raised, #e4ebf5);
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
user-select: none;
}
.account-fields {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
background: var(--color-surface, #fff);
}
.field {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
}
.field span:first-child {
color: var(--color-text-secondary, #6b7a99);
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.field input[type="text"],
.field input[type="password"],
.field input[type="number"] {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.375rem;
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
font-size: 0.9rem;
font-family: var(--font-body, sans-serif);
}
.field-row {
display: flex;
gap: 0.5rem;
align-items: flex-end;
}
.field-grow { flex: 1; }
.field-short { width: 80px; }
.field-check { width: 48px; align-items: center; }
.field-inline {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.field-num {
width: 100px;
}
.password-wrap {
display: flex;
gap: 0.4rem;
}
.password-wrap input {
flex: 1;
}
.btn-icon {
border: 1px solid var(--color-border, #d0d7e8);
background: transparent;
border-radius: 0.375rem;
padding: 0.3rem 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
.account-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
padding-top: 0.25rem;
}
.test-result {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
}
.result-ok { background: #d4edda; color: #155724; }
.result-err { background: #f8d7da; color: #721c24; }
.btn-add { margin-top: 0.25rem; }
.btn-primary, .btn-secondary, .btn-danger {
padding: 0.4rem 0.9rem;
border-radius: 0.375rem;
font-size: 0.85rem;
cursor: pointer;
border: 1px solid;
font-family: var(--font-body, sans-serif);
transition: background 0.15s, color 0.15s;
}
.btn-primary {
border-color: var(--app-primary, #2A6080);
background: var(--app-primary, #2A6080);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--app-primary-dark, #1d4d65);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
border-color: var(--color-border, #d0d7e8);
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
}
.btn-secondary:hover {
background: var(--color-surface-raised, #e4ebf5);
}
.btn-danger {
border-color: var(--color-error, #ef4444);
background: transparent;
color: var(--color-error, #ef4444);
}
.btn-danger:hover {
background: #fef2f2;
}
.save-bar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.save-msg {
font-size: 0.85rem;
padding: 0.2rem 0.6rem;
border-radius: 0.25rem;
}
.msg-ok { background: #d4edda; color: #155724; }
.msg-err { background: #f8d7da; color: #721c24; }
.empty-notice {
color: var(--color-text-secondary, #6b7a99);
font-size: 0.9rem;
padding: 0.75rem;
border: 1px dashed var(--color-border, #d0d7e8);
border-radius: 0.5rem;
}
</style>

245
web/src/views/StatsView.vue Normal file
View file

@ -0,0 +1,245 @@
<template>
<div class="stats-view">
<h1 class="page-title">📊 Statistics</h1>
<div v-if="loading" class="loading">Loading</div>
<div v-else-if="error" class="error-notice" role="alert">
{{ error }} <button class="btn-secondary" @click="load">Retry</button>
</div>
<template v-else>
<p class="total-count">
<strong>{{ stats.total.toLocaleString() }}</strong> emails labeled total
</p>
<div v-if="stats.total === 0" class="empty-notice">
No labeled emails yet go to <strong>Label</strong> to start labeling.
</div>
<div v-else class="label-bars">
<div
v-for="row in rows"
:key="row.name"
class="bar-row"
>
<span class="bar-emoji" aria-hidden="true">{{ row.emoji }}</span>
<span class="bar-label">{{ row.name.replace(/_/g, '\u00a0') }}</span>
<div class="bar-track" :title="`${row.count} (${row.pct}%)`">
<div
class="bar-fill"
:style="{ width: `${row.pct}%`, background: row.color }"
/>
</div>
<span class="bar-count">{{ row.count.toLocaleString() }}</span>
</div>
</div>
<div class="file-info">
<span class="file-path">Score file: <code>data/email_score.jsonl</code></span>
<span class="file-size">{{ fileSizeLabel }}</span>
</div>
<div class="action-bar">
<button class="btn-secondary" @click="load">🔄 Refresh</button>
<a class="btn-secondary" href="/api/stats/download" download="email_score.jsonl">
Download
</a>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
interface StatsResponse {
total: number
counts: Record<string, number>
score_file_bytes: number
}
// Canonical label order + metadata
const LABEL_META: Record<string, { emoji: string; color: string }> = {
interview_scheduled: { emoji: '🗓️', color: '#4CAF50' },
offer_received: { emoji: '🎉', color: '#2196F3' },
rejected: { emoji: '❌', color: '#F44336' },
positive_response: { emoji: '👍', color: '#FF9800' },
survey_received: { emoji: '📋', color: '#9C27B0' },
neutral: { emoji: '⬜', color: '#607D8B' },
event_rescheduled: { emoji: '🔄', color: '#FF5722' },
digest: { emoji: '📰', color: '#00BCD4' },
new_lead: { emoji: '🤝', color: '#009688' },
hired: { emoji: '🎊', color: '#FFC107' },
}
const CANONICAL_ORDER = Object.keys(LABEL_META)
const stats = ref<StatsResponse>({ total: 0, counts: {}, score_file_bytes: 0 })
const loading = ref(true)
const error = ref('')
const rows = computed(() => {
const max = Math.max(...Object.values(stats.value.counts), 1)
const allLabels = [
...CANONICAL_ORDER,
...Object.keys(stats.value.counts).filter(k => !CANONICAL_ORDER.includes(k)),
].filter(k => stats.value.counts[k] > 0)
return allLabels.map(name => {
const count = stats.value.counts[name] ?? 0
const meta = LABEL_META[name] ?? { emoji: '🏷️', color: '#607D8B' }
return {
name,
count,
emoji: meta.emoji,
color: meta.color,
pct: Math.round((count / max) * 100),
}
})
})
const fileSizeLabel = computed(() => {
const b = stats.value.score_file_bytes
if (b === 0) return '(file not found)'
if (b < 1024) return `${b} B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
return `${(b / 1024 / 1024).toFixed(2)} MB`
})
async function load() {
loading.value = true
error.value = ''
const { data, error: err } = await useApiFetch<StatsResponse>('/api/stats')
loading.value = false
if (err || !data) {
error.value = 'Could not reach Avocet API.'
} else {
stats.value = data
}
}
onMounted(load)
</script>
<style scoped>
.stats-view {
max-width: 640px;
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page-title {
font-family: var(--font-display, var(--font-body, sans-serif));
font-size: 1.4rem;
font-weight: 700;
color: var(--app-primary, #2A6080);
}
.total-count {
font-size: 1rem;
color: var(--color-text-secondary, #6b7a99);
}
.label-bars {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.bar-row {
display: grid;
grid-template-columns: 1.5rem 11rem 1fr 3.5rem;
align-items: center;
gap: 0.5rem;
font-size: 0.88rem;
}
.bar-emoji { text-align: center; }
.bar-label {
font-family: var(--font-mono, monospace);
font-size: 0.78rem;
color: var(--color-text, #1a2338);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar-track {
height: 14px;
background: var(--color-surface-raised, #e4ebf5);
border-radius: 99px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.4s ease;
}
.bar-count {
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary, #6b7a99);
font-size: 0.82rem;
}
.file-info {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.8rem;
color: var(--color-text-secondary, #6b7a99);
}
.file-path code {
font-family: var(--font-mono, monospace);
background: var(--color-surface-raised, #e4ebf5);
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
}
.action-bar {
display: flex;
gap: 0.75rem;
align-items: center;
}
.btn-secondary {
padding: 0.4rem 0.9rem;
border-radius: 0.375rem;
border: 1px solid var(--color-border, #d0d7e8);
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
font-size: 0.85rem;
cursor: pointer;
text-decoration: none;
font-family: var(--font-body, sans-serif);
transition: background 0.15s;
}
.btn-secondary:hover {
background: var(--color-surface-raised, #e4ebf5);
}
.loading, .error-notice, .empty-notice {
color: var(--color-text-secondary, #6b7a99);
font-size: 0.9rem;
padding: 1rem;
}
@media (max-width: 480px) {
.bar-row {
grid-template-columns: 1.5rem 1fr 1fr 3rem;
}
.bar-label {
display: none;
}
}
</style>