chore(release): v0.9.4
Messaging overhaul: expandable email timeline with lazy body loading, sticky compose bar replacing always-visible action buttons, layout height fixed to 100dvh. Accessibility fixes for contrast failures on orange/amber backgrounds. Theme-aware replacements for hardcoded colors in Interviews, References, and JobReview. Indeed alert parser, Oracle HCM scraper, manage.sh compose engine detection.
This commit is contained in:
parent
5d185650d9
commit
293f0aba53
14 changed files with 726 additions and 112 deletions
47
CHANGELOG.md
47
CHANGELOG.md
|
|
@ -9,6 +9,53 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.9.4] — 2026-05-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Messages view — expandable email timeline** — click any email item to lazy-load
|
||||||
|
and read the full body inline (HTML stripped to plain text via `DOMParser`).
|
||||||
|
Bodies are fetched on-demand via the new `GET /api/contacts/{id}` endpoint to avoid
|
||||||
|
loading 50KB+ email bodies on every page view.
|
||||||
|
- **Messages view — compose bar** — action buttons (Log call, Log note, Use template,
|
||||||
|
Draft reply with LLM, Call via Osprey) moved from the always-visible header into a
|
||||||
|
sticky bottom compose bar triggered by a + New toggle. Reduces visual clutter when
|
||||||
|
just reading the thread.
|
||||||
|
- **Home view — "Skip review" checkbox** — when adding jobs by URL, a checkbox (default
|
||||||
|
on) sends them directly to the Apply queue, bypassing Job Review.
|
||||||
|
- **ContactsView — sync status** — shows last completed sync time and a spinner when
|
||||||
|
an email sync is running.
|
||||||
|
- **imap_sync: Indeed alert parser** — `parse_indeed_alert()` extracts job title,
|
||||||
|
company, location, salary, and canonical URL from Indeed Job Alert digest emails.
|
||||||
|
- **scrape_url: Oracle HCM support** — Playwright-based scraper for Oracle HCM
|
||||||
|
CandidateExperience portals (React SPAs requiring JS execution).
|
||||||
|
- **manage.sh** — compose engine auto-detection (docker compose / podman compose /
|
||||||
|
podman-compose), `build` command, and cloud/demo stack shortcuts.
|
||||||
|
- **theme.css** — `--color-overlay` token for modal/dialog backdrops.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Messages view layout** — changed `height: 100%` to `height: 100dvh` with a mobile
|
||||||
|
override for the 56px tab bar. `height: 100%` was resolving to "shrink-wrap" because
|
||||||
|
`.app-main` has no explicit height; compose bar is now correctly pinned to the bottom.
|
||||||
|
- **Accessibility: danger button contrast** — `btn--danger` used `color: white` on
|
||||||
|
`--app-accent` (Talon Orange), yielding 2.8:1 contrast (fails WCAG AA 4.5:1 for
|
||||||
|
normal text). Fixed to `color: var(--app-accent-text)` (dark navy, 5.5:1).
|
||||||
|
- **Accessibility: warning badge contrast** — `tab-badge` in Job Review used `color: white`
|
||||||
|
on `--color-warning` (amber). Same fix applied.
|
||||||
|
- **Theme: Interviews signal banners** — hardcoded `rgba(245,158,11,…)` / `rgba(39,174,…)`
|
||||||
|
/ `rgba(192,57,…)` replaced with `color-mix()` against `--color-warning/success/error`.
|
||||||
|
- **Theme: Interviews signal count** — `color: #e67e22` hardcode replaced with
|
||||||
|
`var(--app-accent)`.
|
||||||
|
- **Theme: References academic tag chip** — `color: #7c3aed` hardcode replaced with
|
||||||
|
`var(--status-synced)`; background uses `color-mix()` with the same token.
|
||||||
|
- **Theme: Interviews signal-move button** — `color: #fff` on `--color-primary` fails
|
||||||
|
in dark mode (light green bg); fixed to `var(--color-text-inverse)`.
|
||||||
|
- **Modal backdrops** — `rgba(0,0,0,0.5)` replaced with `var(--color-overlay)` for
|
||||||
|
theme consistency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.9.3] — 2026-05-05
|
## [0.9.3] — 2026-05-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
75
dev-api.py
75
dev-api.py
|
|
@ -114,6 +114,38 @@ app.include_router(_feedback_router, prefix="/api/feedback")
|
||||||
|
|
||||||
_log = logging.getLogger("peregrine.session")
|
_log = logging.getLogger("peregrine.session")
|
||||||
|
|
||||||
|
# ── Structured auth logging ───────────────────────────────────────────────────
|
||||||
|
# Writes one JSON line per request to /devl/peregrine-logs/auth.log when in
|
||||||
|
# cloud mode. Rotates at 10 MB, keeps 5 files. Also logs to stdout in dev.
|
||||||
|
_AUTH_LOG_DIR = Path(os.environ.get("PEREGRINE_LOG_DIR", "/devl/peregrine-logs"))
|
||||||
|
|
||||||
|
class _JsonFormatter(logging.Formatter):
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
payload = {
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"msg": record.getMessage(),
|
||||||
|
}
|
||||||
|
if hasattr(record, "auth_event"):
|
||||||
|
payload.update(record.auth_event)
|
||||||
|
return json.dumps(payload)
|
||||||
|
|
||||||
|
def _setup_auth_logging() -> None:
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
_AUTH_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
handler = RotatingFileHandler(
|
||||||
|
_AUTH_LOG_DIR / "auth.log", maxBytes=10 * 1024 * 1024, backupCount=5
|
||||||
|
)
|
||||||
|
handler.setFormatter(_JsonFormatter())
|
||||||
|
handler.setLevel(logging.INFO)
|
||||||
|
_log.addHandler(handler)
|
||||||
|
_log.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
_setup_auth_logging()
|
||||||
|
|
||||||
|
_seen_users: set[str] = set() # track first-access events within this process lifetime
|
||||||
|
|
||||||
|
|
||||||
def _demo_guard() -> None:
|
def _demo_guard() -> None:
|
||||||
"""Raise 403 if running in demo mode. Call at the top of any write endpoint."""
|
"""Raise 403 if running in demo mode. Call at the top of any write endpoint."""
|
||||||
|
|
@ -158,6 +190,16 @@ def _resolve_cf_user_id(cookie_str: str) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_log(event: str, **kwargs) -> None:
|
||||||
|
"""Emit a structured INFO log line to the auth logger."""
|
||||||
|
record = logging.LogRecord(
|
||||||
|
name="peregrine.session", level=logging.INFO,
|
||||||
|
pathname="", lineno=0, msg=event, args=(), exc_info=None,
|
||||||
|
)
|
||||||
|
record.auth_event = {"event": event, **kwargs}
|
||||||
|
_log.handle(record)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def cloud_session_middleware(request: Request, call_next):
|
async def cloud_session_middleware(request: Request, call_next):
|
||||||
"""In cloud mode, resolve per-user staging.db from the X-CF-Session header."""
|
"""In cloud mode, resolve per-user staging.db from the X-CF-Session header."""
|
||||||
|
|
@ -165,16 +207,36 @@ async def cloud_session_middleware(request: Request, call_next):
|
||||||
cookie_header = request.headers.get("X-CF-Session", "")
|
cookie_header = request.headers.get("X-CF-Session", "")
|
||||||
user_id = _resolve_cf_user_id(cookie_header)
|
user_id = _resolve_cf_user_id(cookie_header)
|
||||||
if user_id:
|
if user_id:
|
||||||
|
first_access = user_id not in _seen_users
|
||||||
|
if first_access:
|
||||||
|
_seen_users.add(user_id)
|
||||||
user_db = str(_CLOUD_DATA_ROOT / user_id / "peregrine" / "staging.db")
|
user_db = str(_CLOUD_DATA_ROOT / user_id / "peregrine" / "staging.db")
|
||||||
if user_db not in _migrated_db_paths:
|
if user_db not in _migrated_db_paths:
|
||||||
from scripts.db_migrate import migrate_db
|
from scripts.db_migrate import migrate_db
|
||||||
migrate_db(Path(user_db))
|
migrate_db(Path(user_db))
|
||||||
_migrated_db_paths.add(user_db)
|
_migrated_db_paths.add(user_db)
|
||||||
|
_auth_log(
|
||||||
|
"session_resolved",
|
||||||
|
user_id=user_id,
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
first_access=first_access,
|
||||||
|
)
|
||||||
token = _request_db.set(user_db)
|
token = _request_db.set(user_db)
|
||||||
try:
|
try:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
finally:
|
finally:
|
||||||
_request_db.reset(token)
|
_request_db.reset(token)
|
||||||
|
else:
|
||||||
|
# Only log failures on non-trivial paths (skip health checks / static assets)
|
||||||
|
if request.url.path.startswith("/api/"):
|
||||||
|
_auth_log(
|
||||||
|
"session_failed",
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
reason="no_user_id",
|
||||||
|
has_cookie=bool(cookie_header),
|
||||||
|
)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1677,6 +1739,16 @@ def list_contacts(job_id: Optional[int] = None, direction: Optional[str] = None,
|
||||||
return {"total": total, "contacts": [dict(r) for r in rows]}
|
return {"total": total, "contacts": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/contacts/{contact_id}")
|
||||||
|
def get_contact(contact_id: int):
|
||||||
|
db = _get_db()
|
||||||
|
row = db.execute("SELECT * FROM job_contacts WHERE id = ?", (contact_id,)).fetchone()
|
||||||
|
db.close()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
# ── References ─────────────────────────────────────────────────────────────────
|
# ── References ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ReferencePayload(BaseModel):
|
class ReferencePayload(BaseModel):
|
||||||
|
|
@ -2112,6 +2184,7 @@ def bulk_purge_jobs(body: BulkPurgeBody):
|
||||||
|
|
||||||
class AddJobsBody(BaseModel):
|
class AddJobsBody(BaseModel):
|
||||||
urls: List[str]
|
urls: List[str]
|
||||||
|
skip_review: bool = True
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/jobs/add", status_code=202)
|
@app.post("/api/jobs/add", status_code=202)
|
||||||
|
|
@ -2123,6 +2196,7 @@ def add_jobs_by_url(body: AddJobsBody):
|
||||||
from scripts.task_runner import submit_task
|
from scripts.task_runner import submit_task
|
||||||
db_path = _db_path()
|
db_path = _db_path()
|
||||||
existing = get_existing_urls(db_path)
|
existing = get_existing_urls(db_path)
|
||||||
|
status = "approved" if body.skip_review else "pending"
|
||||||
queued = 0
|
queued = 0
|
||||||
for raw_url in body.urls:
|
for raw_url in body.urls:
|
||||||
url = canonicalize_url(raw_url.strip())
|
url = canonicalize_url(raw_url.strip())
|
||||||
|
|
@ -2132,6 +2206,7 @@ def add_jobs_by_url(body: AddJobsBody):
|
||||||
"title": "Importing...", "company": "", "url": url,
|
"title": "Importing...", "company": "", "url": url,
|
||||||
"source": "manual", "location": "", "description": "",
|
"source": "manual", "location": "", "description": "",
|
||||||
"date_found": _dt.now().isoformat()[:10],
|
"date_found": _dt.now().isoformat()[:10],
|
||||||
|
"status": status,
|
||||||
})
|
})
|
||||||
if job_id:
|
if job_id:
|
||||||
submit_task(db_path, "scrape_url", job_id)
|
submit_task(db_path, "scrape_url", job_id)
|
||||||
|
|
|
||||||
71
manage.sh
71
manage.sh
|
|
@ -15,6 +15,11 @@ cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
PROFILE="${PROFILE:-remote}"
|
PROFILE="${PROFILE:-remote}"
|
||||||
|
|
||||||
|
# ── Compose engine detection ──────────────────────────────────────────────────
|
||||||
|
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
|
||||||
|
&& echo "docker compose" \
|
||||||
|
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
|
||||||
|
|
||||||
# ── Usage ────────────────────────────────────────────────────────────────────
|
# ── Usage ────────────────────────────────────────────────────────────────────
|
||||||
usage() {
|
usage() {
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -28,9 +33,10 @@ usage() {
|
||||||
echo -e " ${GREEN}start${NC} Start Peregrine (preflight → up)"
|
echo -e " ${GREEN}start${NC} Start Peregrine (preflight → up)"
|
||||||
echo -e " ${GREEN}stop${NC} Stop all services"
|
echo -e " ${GREEN}stop${NC} Stop all services"
|
||||||
echo -e " ${GREEN}restart${NC} Restart all services"
|
echo -e " ${GREEN}restart${NC} Restart all services"
|
||||||
|
echo -e " ${GREEN}build [service]${NC} Rebuild image(s) without restarting (default: api web)"
|
||||||
echo -e " ${GREEN}status${NC} Show running containers"
|
echo -e " ${GREEN}status${NC} Show running containers"
|
||||||
echo -e " ${GREEN}logs [service]${NC} Tail logs (default: app)"
|
echo -e " ${GREEN}logs [service]${NC} Tail logs (default: api)"
|
||||||
echo -e " ${GREEN}update${NC} Pull latest images + rebuild app"
|
echo -e " ${GREEN}update${NC} Pull latest images + rebuild"
|
||||||
echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env"
|
echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env"
|
||||||
echo -e " ${GREEN}models${NC} Check ollama models in config; pull any missing"
|
echo -e " ${GREEN}models${NC} Check ollama models in config; pull any missing"
|
||||||
echo -e " ${GREEN}test${NC} Run test suite"
|
echo -e " ${GREEN}test${NC} Run test suite"
|
||||||
|
|
@ -41,6 +47,12 @@ usage() {
|
||||||
echo -e " ${GREEN}clean${NC} Remove containers, images, volumes (DESTRUCTIVE)"
|
echo -e " ${GREEN}clean${NC} Remove containers, images, volumes (DESTRUCTIVE)"
|
||||||
echo -e " ${GREEN}open${NC} Open the web UI in your browser"
|
echo -e " ${GREEN}open${NC} Open the web UI in your browser"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo -e " Cloud / demo commands:"
|
||||||
|
echo -e " ${GREEN}cloud-start${NC} Start the cloud stack (peregrine-cloud)"
|
||||||
|
echo -e " ${GREEN}cloud-restart${NC} Rebuild + restart the cloud stack"
|
||||||
|
echo -e " ${GREEN}demo-start${NC} Start the demo stack (peregrine-demo)"
|
||||||
|
echo -e " ${GREEN}demo-restart${NC} Rebuild + restart the demo stack"
|
||||||
|
echo ""
|
||||||
echo " Profiles (set via --profile or PROFILE env var):"
|
echo " Profiles (set via --profile or PROFILE env var):"
|
||||||
echo " remote API-only, no local inference (default)"
|
echo " remote API-only, no local inference (default)"
|
||||||
echo " cpu Local Ollama inference on CPU"
|
echo " cpu Local Ollama inference on CPU"
|
||||||
|
|
@ -70,7 +82,7 @@ while [[ $# -gt 0 ]]; do
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
SERVICE="${1:-app}" # used by `logs` command
|
SERVICE="${1:-api}" # used by `logs` command
|
||||||
|
|
||||||
# ── Dependency guard ──────────────────────────────────────────────────────────
|
# ── Dependency guard ──────────────────────────────────────────────────────────
|
||||||
# Commands that delegate to make; others (status, logs, update, open, setup) run fine without it.
|
# Commands that delegate to make; others (status, logs, update, open, setup) run fine without it.
|
||||||
|
|
@ -101,7 +113,7 @@ case "$CMD" in
|
||||||
start)
|
start)
|
||||||
info "Starting Peregrine (PROFILE=${PROFILE})..."
|
info "Starting Peregrine (PROFILE=${PROFILE})..."
|
||||||
make start PROFILE="$PROFILE"
|
make start PROFILE="$PROFILE"
|
||||||
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
|
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
|
||||||
success "Peregrine is up → http://localhost:${PORT}"
|
success "Peregrine is up → http://localhost:${PORT}"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
|
@ -114,33 +126,30 @@ case "$CMD" in
|
||||||
restart)
|
restart)
|
||||||
info "Restarting (PROFILE=${PROFILE})..."
|
info "Restarting (PROFILE=${PROFILE})..."
|
||||||
make restart PROFILE="$PROFILE"
|
make restart PROFILE="$PROFILE"
|
||||||
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
|
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
|
||||||
success "Peregrine restarted → http://localhost:${PORT}"
|
success "Peregrine restarted → http://localhost:${PORT}"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
status)
|
status)
|
||||||
# Auto-detect compose engine same way Makefile does
|
|
||||||
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
|
|
||||||
&& echo "docker compose" \
|
|
||||||
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
|
|
||||||
$COMPOSE ps
|
$COMPOSE ps
|
||||||
;;
|
;;
|
||||||
|
|
||||||
logs)
|
logs)
|
||||||
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
|
|
||||||
&& echo "docker compose" \
|
|
||||||
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
|
|
||||||
info "Tailing logs for: ${SERVICE}"
|
info "Tailing logs for: ${SERVICE}"
|
||||||
$COMPOSE logs -f "$SERVICE"
|
$COMPOSE logs -f "$SERVICE"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
build)
|
||||||
|
BUILD_SVC="$([[ "${SERVICE}" == "api" ]] && echo "api web" || echo "${SERVICE}")"
|
||||||
|
info "Building ${BUILD_SVC}..."
|
||||||
|
$COMPOSE build $BUILD_SVC
|
||||||
|
success "Build complete. Run './manage.sh restart' to apply."
|
||||||
|
;;
|
||||||
|
|
||||||
update)
|
update)
|
||||||
info "Pulling latest images and rebuilding app..."
|
info "Pulling latest images and rebuilding..."
|
||||||
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
|
|
||||||
&& echo "docker compose" \
|
|
||||||
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
|
|
||||||
$COMPOSE pull searxng ollama 2>/dev/null || true
|
$COMPOSE pull searxng ollama 2>/dev/null || true
|
||||||
$COMPOSE build app web
|
$COMPOSE build api web
|
||||||
success "Update complete. Run './manage.sh restart' to apply."
|
success "Update complete. Run './manage.sh restart' to apply."
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
|
@ -167,7 +176,7 @@ case "$CMD" in
|
||||||
;;
|
;;
|
||||||
|
|
||||||
open)
|
open)
|
||||||
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
|
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
|
||||||
URL="http://localhost:${PORT}"
|
URL="http://localhost:${PORT}"
|
||||||
info "Opening ${URL}"
|
info "Opening ${URL}"
|
||||||
if command -v xdg-open &>/dev/null; then
|
if command -v xdg-open &>/dev/null; then
|
||||||
|
|
@ -197,6 +206,32 @@ case "$CMD" in
|
||||||
-v "${@:3}"
|
-v "${@:3}"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
cloud-start)
|
||||||
|
info "Starting cloud stack (peregrine-cloud)..."
|
||||||
|
$COMPOSE -f compose.cloud.yml --project-name peregrine-cloud up -d
|
||||||
|
success "Cloud stack up → http://localhost:8508"
|
||||||
|
;;
|
||||||
|
|
||||||
|
cloud-restart)
|
||||||
|
info "Rebuilding + restarting cloud stack (peregrine-cloud)..."
|
||||||
|
$COMPOSE -f compose.cloud.yml --project-name peregrine-cloud build api web
|
||||||
|
$COMPOSE -f compose.cloud.yml --project-name peregrine-cloud up -d
|
||||||
|
success "Cloud stack restarted → http://localhost:8508"
|
||||||
|
;;
|
||||||
|
|
||||||
|
demo-start)
|
||||||
|
info "Starting demo stack (peregrine-demo)..."
|
||||||
|
$COMPOSE -f compose.demo.yml --project-name peregrine-demo up -d
|
||||||
|
success "Demo stack up → http://localhost:8504"
|
||||||
|
;;
|
||||||
|
|
||||||
|
demo-restart)
|
||||||
|
info "Rebuilding + restarting demo stack (peregrine-demo)..."
|
||||||
|
$COMPOSE -f compose.demo.yml --project-name peregrine-demo build api web
|
||||||
|
$COMPOSE -f compose.demo.yml --project-name peregrine-demo up -d
|
||||||
|
success "Demo stack restarted → http://localhost:8504"
|
||||||
|
;;
|
||||||
|
|
||||||
help|--help|-h)
|
help|--help|-h)
|
||||||
usage
|
usage
|
||||||
;;
|
;;
|
||||||
|
|
|
||||||
|
|
@ -234,10 +234,11 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
|
||||||
return None
|
return None
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
try:
|
try:
|
||||||
|
status = job.get("status", "pending")
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""INSERT INTO jobs
|
"""INSERT INTO jobs
|
||||||
(title, company, url, source, location, is_remote, salary, description, date_found, date_posted)
|
(title, company, url, source, location, is_remote, salary, description, date_found, date_posted, status)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
job.get("title", ""),
|
job.get("title", ""),
|
||||||
job.get("company", ""),
|
job.get("company", ""),
|
||||||
|
|
@ -249,6 +250,7 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
|
||||||
job.get("description", ""),
|
job.get("description", ""),
|
||||||
job.get("date_found", ""),
|
job.get("date_found", ""),
|
||||||
job.get("date_posted", "") or "",
|
job.get("date_posted", "") or "",
|
||||||
|
status,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
|
||||||
|
|
@ -392,6 +392,7 @@ def _has_todo_keyword(subject: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
_LINKEDIN_ALERT_SENDER = "jobalerts-noreply@linkedin.com"
|
_LINKEDIN_ALERT_SENDER = "jobalerts-noreply@linkedin.com"
|
||||||
|
_INDEED_ALERT_SENDER = "jobalerts@indeed.com"
|
||||||
|
|
||||||
# Social-proof / nav lines to skip when parsing alert blocks
|
# Social-proof / nav lines to skip when parsing alert blocks
|
||||||
_ALERT_SKIP_PHRASES = {
|
_ALERT_SKIP_PHRASES = {
|
||||||
|
|
@ -447,6 +448,75 @@ def parse_linkedin_alert(body: str) -> list[dict]:
|
||||||
return jobs
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
def parse_indeed_alert(body: str) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Parse the HTML body of an Indeed Job Alert email.
|
||||||
|
|
||||||
|
Returns a list of dicts: {title, company, location, salary, url}.
|
||||||
|
URL is canonicalised to https://www.indeed.com/viewjob?jk=<id>
|
||||||
|
(tracking parameters stripped).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from bs4 import BeautifulSoup as _BS
|
||||||
|
except ImportError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
jobs: list[dict] = []
|
||||||
|
soup = _BS(body, "html.parser")
|
||||||
|
|
||||||
|
# Each job card is an <a> wrapping a job title — Indeed uses several layouts
|
||||||
|
# across their email templates. We try two strategies:
|
||||||
|
#
|
||||||
|
# Strategy A (2023+ layout): <td> blocks containing an <a> with /viewjob?jk=
|
||||||
|
# Strategy B (older layout): <tr class="job"> blocks
|
||||||
|
#
|
||||||
|
# Both extract the canonical jk= key from the href.
|
||||||
|
|
||||||
|
seen_jks: set[str] = set()
|
||||||
|
|
||||||
|
for anchor in soup.find_all("a", href=True):
|
||||||
|
href: str = anchor["href"]
|
||||||
|
jk_m = re.search(r"[?&]jk=([a-z0-9]+)", href, re.IGNORECASE)
|
||||||
|
if not jk_m:
|
||||||
|
continue
|
||||||
|
jk = jk_m.group(1)
|
||||||
|
if jk in seen_jks:
|
||||||
|
continue
|
||||||
|
seen_jks.add(jk)
|
||||||
|
|
||||||
|
title = anchor.get_text(separator=" ", strip=True)
|
||||||
|
if not title or len(title) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Walk up to find the container cell/row and extract company + location
|
||||||
|
container = anchor.find_parent(["td", "tr", "div"])
|
||||||
|
company = location = salary = ""
|
||||||
|
if container:
|
||||||
|
text_lines = [
|
||||||
|
t.strip() for t in container.get_text(separator="\n").splitlines()
|
||||||
|
if t.strip() and t.strip().lower() != title.lower()
|
||||||
|
]
|
||||||
|
if text_lines:
|
||||||
|
company = text_lines[0]
|
||||||
|
if len(text_lines) > 1:
|
||||||
|
location = text_lines[1]
|
||||||
|
# salary line often contains "$" or "/yr"
|
||||||
|
for line in text_lines[2:]:
|
||||||
|
if "$" in line or "/yr" in line.lower() or "/hour" in line.lower():
|
||||||
|
salary = line
|
||||||
|
break
|
||||||
|
|
||||||
|
jobs.append({
|
||||||
|
"title": title,
|
||||||
|
"company": company,
|
||||||
|
"location": location,
|
||||||
|
"salary": salary,
|
||||||
|
"url": f"https://www.indeed.com/viewjob?jk={jk}",
|
||||||
|
})
|
||||||
|
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
def _scan_todo_label(conn: imaplib.IMAP4, cfg: dict, db_path: Path,
|
def _scan_todo_label(conn: imaplib.IMAP4, cfg: dict, db_path: Path,
|
||||||
active_jobs: list[dict],
|
active_jobs: list[dict],
|
||||||
known_message_ids: set) -> int:
|
known_message_ids: set) -> int:
|
||||||
|
|
@ -558,20 +628,29 @@ def _scan_unmatched_leads(conn: imaplib.IMAP4, cfg: dict,
|
||||||
if mid in known_message_ids:
|
if mid in known_message_ids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ── LinkedIn Job Alert digest — parse each card individually ──────
|
# ── Job alert digests — parse each card deterministically ───────
|
||||||
if _LINKEDIN_ALERT_SENDER in parsed["from_addr"].lower():
|
from_lower = parsed["from_addr"].lower()
|
||||||
cards = parse_linkedin_alert(parsed["body"])
|
alert_cards: list[dict] = []
|
||||||
for card in cards:
|
alert_source = ""
|
||||||
|
if _LINKEDIN_ALERT_SENDER in from_lower:
|
||||||
|
alert_cards = parse_linkedin_alert(parsed["body"])
|
||||||
|
alert_source = "linkedin"
|
||||||
|
elif _INDEED_ALERT_SENDER in from_lower:
|
||||||
|
alert_cards = parse_indeed_alert(parsed["body"])
|
||||||
|
alert_source = "indeed"
|
||||||
|
|
||||||
|
if alert_cards:
|
||||||
|
for card in alert_cards:
|
||||||
if card["url"] in existing_urls:
|
if card["url"] in existing_urls:
|
||||||
continue
|
continue
|
||||||
job_id = insert_job(db_path, {
|
job_id = insert_job(db_path, {
|
||||||
"title": card["title"],
|
"title": card["title"],
|
||||||
"company": card["company"],
|
"company": card["company"],
|
||||||
"url": card["url"],
|
"url": card["url"],
|
||||||
"source": "linkedin",
|
"source": alert_source,
|
||||||
"location": card["location"],
|
"location": card.get("location", ""),
|
||||||
"is_remote": 0,
|
"is_remote": 0,
|
||||||
"salary": "",
|
"salary": card.get("salary", ""),
|
||||||
"description": "",
|
"description": "",
|
||||||
"date_found": datetime.now().isoformat()[:10],
|
"date_found": datetime.now().isoformat()[:10],
|
||||||
})
|
})
|
||||||
|
|
@ -580,7 +659,7 @@ def _scan_unmatched_leads(conn: imaplib.IMAP4, cfg: dict,
|
||||||
submit_task(db_path, "scrape_url", job_id)
|
submit_task(db_path, "scrape_url", job_id)
|
||||||
existing_urls.add(card["url"])
|
existing_urls.add(card["url"])
|
||||||
new_leads += 1
|
new_leads += 1
|
||||||
print(f"[imap] LinkedIn alert → {card['company']} — {card['title']}")
|
print(f"[imap] {alert_source} alert → {card['company']} — {card['title']}")
|
||||||
known_message_ids.add(mid)
|
known_message_ids.add(mid)
|
||||||
continue # skip normal LLM extraction path
|
continue # skip normal LLM extraction path
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ _TIMEOUT = 12
|
||||||
|
|
||||||
|
|
||||||
def _detect_board(url: str) -> str:
|
def _detect_board(url: str) -> str:
|
||||||
"""Return 'linkedin', 'indeed', 'glassdoor', or 'generic'."""
|
"""Return 'linkedin', 'indeed', 'glassdoor', 'jobgether', 'oracle_hcm', or 'generic'."""
|
||||||
url_lower = url.lower()
|
url_lower = url.lower()
|
||||||
if "linkedin.com" in url_lower:
|
if "linkedin.com" in url_lower:
|
||||||
return "linkedin"
|
return "linkedin"
|
||||||
|
|
@ -67,6 +67,8 @@ def _detect_board(url: str) -> str:
|
||||||
return "glassdoor"
|
return "glassdoor"
|
||||||
if "jobgether.com" in url_lower:
|
if "jobgether.com" in url_lower:
|
||||||
return "jobgether"
|
return "jobgether"
|
||||||
|
if "oraclecloud.com" in url_lower and "hcmui" in url_lower:
|
||||||
|
return "oracle_hcm"
|
||||||
return "generic"
|
return "generic"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -201,6 +203,70 @@ def _scrape_jobgether(url: str) -> dict:
|
||||||
return {"company": company, "source": "jobgether"} if company else {}
|
return {"company": company, "source": "jobgether"} if company else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _scrape_oracle_hcm(url: str) -> dict:
|
||||||
|
"""Scrape an Oracle HCM CandidateExperience job page via Playwright.
|
||||||
|
|
||||||
|
Oracle HCM portals are React SPAs that require JS execution. The prospect
|
||||||
|
token in the URL path grants public access — no auth needed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError:
|
||||||
|
print("[scrape_url] Oracle HCM: Playwright not installed, falling back to generic")
|
||||||
|
return _scrape_generic(url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
try:
|
||||||
|
ctx = browser.new_context(user_agent=_HEADERS["User-Agent"])
|
||||||
|
page = ctx.new_page()
|
||||||
|
page.goto(url, timeout=30_000)
|
||||||
|
page.wait_for_load_state("networkidle", timeout=20_000)
|
||||||
|
|
||||||
|
result = page.evaluate("""() => {
|
||||||
|
const sel = (s) => document.querySelector(s)?.textContent?.trim() || '';
|
||||||
|
const selInner = (s) => document.querySelector(s)?.innerText?.trim() || '';
|
||||||
|
|
||||||
|
// Title: try known HCM selectors then fall back to first h1
|
||||||
|
const title = sel('[class*="requisition-title"]')
|
||||||
|
|| sel('[class*="JobTitle"]')
|
||||||
|
|| sel('.job-title')
|
||||||
|
|| sel('h1');
|
||||||
|
|
||||||
|
// Company: page header logo alt text, meta, or site-name span
|
||||||
|
const companyMeta = document.querySelector('meta[property="og:site_name"]')
|
||||||
|
?.getAttribute('content') || '';
|
||||||
|
const company = sel('[class*="company-name"]')
|
||||||
|
|| sel('[class*="siteName"]')
|
||||||
|
|| sel('[class*="site-name"]')
|
||||||
|
|| companyMeta;
|
||||||
|
|
||||||
|
// Location: job detail list items
|
||||||
|
const location = sel('[class*="job-location"]')
|
||||||
|
|| sel('[data-testid*="location"]')
|
||||||
|
|| sel('[class*="location"]');
|
||||||
|
|
||||||
|
// Description: main content div
|
||||||
|
const description = selInner('[class*="job-description"]')
|
||||||
|
|| selInner('[class*="requisition-description"]')
|
||||||
|
|| selInner('[class*="JobDescription"]')
|
||||||
|
|| selInner('main article')
|
||||||
|
|| selInner('main');
|
||||||
|
|
||||||
|
return { title, company, location, description };
|
||||||
|
}""")
|
||||||
|
finally:
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
result["source"] = "oracle_hcm"
|
||||||
|
return {k: v for k, v in result.items() if v}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[scrape_url] Oracle HCM Playwright error for {url}: {exc}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_ld_or_og(html: str) -> dict:
|
def _parse_json_ld_or_og(html: str) -> dict:
|
||||||
"""Extract job fields from JSON-LD structured data, then og: meta tags."""
|
"""Extract job fields from JSON-LD structured data, then og: meta tags."""
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
|
@ -278,6 +344,8 @@ def scrape_job_url(db_path: Path = DEFAULT_DB, job_id: int = None) -> dict:
|
||||||
fields = _scrape_glassdoor(url)
|
fields = _scrape_glassdoor(url)
|
||||||
elif board == "jobgether":
|
elif board == "jobgether":
|
||||||
fields = _scrape_jobgether(url)
|
fields = _scrape_jobgether(url)
|
||||||
|
elif board == "oracle_hcm":
|
||||||
|
fields = _scrape_oracle_hcm(url)
|
||||||
else:
|
else:
|
||||||
fields = _scrape_generic(url)
|
fields = _scrape_generic(url)
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,73 @@ def test_parse_linkedin_alert_empty_body():
|
||||||
assert parse_linkedin_alert("No jobs here.") == []
|
assert parse_linkedin_alert("No jobs here.") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Indeed alert parser ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_INDEED_ALERT_HTML = """
|
||||||
|
<html><body>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="https://www.indeed.com/viewjob?jk=abc123def456&utm_source=jobseeker_email">
|
||||||
|
Senior Python Engineer
|
||||||
|
</a>
|
||||||
|
<br/>Acme Corp<br/>San Francisco, CA<br/>$130,000 - $160,000 a year
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="https://www.indeed.com/viewjob?jk=999zzzqqq111&trk=email_alert">
|
||||||
|
Staff Backend Engineer
|
||||||
|
</a>
|
||||||
|
<br/>Widgets Inc<br/>Remote
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="https://www.indeed.com/rc/clk?jk=abc123def456&pos=0">Duplicate link</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_parse_indeed_alert_extracts_jobs():
|
||||||
|
from scripts.imap_sync import parse_indeed_alert
|
||||||
|
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
|
||||||
|
assert len(jobs) == 2
|
||||||
|
assert jobs[0]["title"] == "Senior Python Engineer"
|
||||||
|
assert jobs[0]["url"] == "https://www.indeed.com/viewjob?jk=abc123def456"
|
||||||
|
assert jobs[1]["title"] == "Staff Backend Engineer"
|
||||||
|
assert jobs[1]["url"] == "https://www.indeed.com/viewjob?jk=999zzzqqq111"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_indeed_alert_strips_tracking_params():
|
||||||
|
from scripts.imap_sync import parse_indeed_alert
|
||||||
|
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
|
||||||
|
for job in jobs:
|
||||||
|
assert "utm_source" not in job["url"]
|
||||||
|
assert "trk=" not in job["url"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_indeed_alert_deduplicates_jk():
|
||||||
|
from scripts.imap_sync import parse_indeed_alert
|
||||||
|
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
|
||||||
|
urls = [j["url"] for j in jobs]
|
||||||
|
assert len(urls) == len(set(urls))
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_indeed_alert_empty_body():
|
||||||
|
from scripts.imap_sync import parse_indeed_alert
|
||||||
|
assert parse_indeed_alert("") == []
|
||||||
|
assert parse_indeed_alert("<html><body>No jobs here</body></html>") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_indeed_alert_extracts_salary():
|
||||||
|
from scripts.imap_sync import parse_indeed_alert
|
||||||
|
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
|
||||||
|
assert "$130,000" in jobs[0]["salary"]
|
||||||
|
|
||||||
|
|
||||||
# ── _scan_unmatched_leads integration ─────────────────────────────────────────
|
# ── _scan_unmatched_leads integration ─────────────────────────────────────────
|
||||||
|
|
||||||
_ALERT_BODY = """\
|
_ALERT_BODY = """\
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@
|
||||||
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
|
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
|
||||||
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
|
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
|
||||||
|
|
||||||
|
/* Overlay — modal/dialog scrim */
|
||||||
|
--color-overlay: rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
/* Transitions */
|
/* Transitions */
|
||||||
--transition: 200ms ease;
|
--transition: 200ms ease;
|
||||||
--transition-slow: 400ms ease;
|
--transition-slow: 400ms ease;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useApiFetch } from '../composables/useApi'
|
import { useApiFetch } from '../composables/useApi'
|
||||||
import HintChip from '../components/HintChip.vue'
|
import HintChip from '../components/HintChip.vue'
|
||||||
import { useAppConfigStore } from '../stores/appConfig'
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
|
|
@ -26,6 +26,8 @@ const error = ref<string | null>(null)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const direction = ref<'all' | 'inbound' | 'outbound'>('all')
|
const direction = ref<'all' | 'inbound' | 'outbound'>('all')
|
||||||
const searchInput = ref('')
|
const searchInput = ref('')
|
||||||
|
const syncing = ref(false)
|
||||||
|
const syncStatus = ref<{ status: string; last_completed_at: string | null } | null>(null)
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
async function fetchContacts() {
|
async function fetchContacts() {
|
||||||
|
|
@ -76,9 +78,45 @@ const signalLabel: Record<string, string> = {
|
||||||
rejected: '✖ Rejected',
|
rejected: '✖ Rejected',
|
||||||
positive_response: '✅ Positive',
|
positive_response: '✅ Positive',
|
||||||
survey_received: '📋 Survey',
|
survey_received: '📋 Survey',
|
||||||
|
event_rescheduled: '🔄 Rescheduled',
|
||||||
|
neutral: '— Neutral',
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchContacts)
|
async function fetchSyncStatus() {
|
||||||
|
const { data } = await useApiFetch<{ status: string; last_completed_at: string | null }>(
|
||||||
|
'/api/email/sync/status'
|
||||||
|
)
|
||||||
|
if (data) syncStatus.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerSync() {
|
||||||
|
syncing.value = true
|
||||||
|
await useApiFetch('/api/tasks/email-sync', { method: 'POST' })
|
||||||
|
// Poll until the task finishes or we give up after 60 s
|
||||||
|
const deadline = Date.now() + 60_000
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
await fetchSyncStatus()
|
||||||
|
if (syncStatus.value?.status === 'completed' || Date.now() > deadline) {
|
||||||
|
clearInterval(poll)
|
||||||
|
syncing.value = false
|
||||||
|
fetchContacts()
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSyncTime(iso: string | null): string {
|
||||||
|
if (!iso) return 'never'
|
||||||
|
const d = new Date(iso)
|
||||||
|
const diff = Date.now() - d.getTime()
|
||||||
|
if (diff < 60_000) return 'just now'
|
||||||
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
|
||||||
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
|
||||||
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([fetchContacts(), fetchSyncStatus()])
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -91,6 +129,20 @@ onMounted(fetchContacts)
|
||||||
<header class="contacts-header">
|
<header class="contacts-header">
|
||||||
<h1 class="contacts-title">Contacts</h1>
|
<h1 class="contacts-title">Contacts</h1>
|
||||||
<span class="contacts-count" v-if="total > 0">{{ total }} total</span>
|
<span class="contacts-count" v-if="total > 0">{{ total }} total</span>
|
||||||
|
<div class="contacts-sync">
|
||||||
|
<span v-if="syncStatus" class="sync-last">
|
||||||
|
Last sync: {{ formatSyncTime(syncStatus.last_completed_at) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn-sync"
|
||||||
|
:disabled="syncing"
|
||||||
|
@click="triggerSync"
|
||||||
|
:aria-label="syncing ? 'Email sync running' : 'Sync email now'"
|
||||||
|
>
|
||||||
|
<span :class="['sync-icon', { 'sync-icon--spinning': syncing }]">↻</span>
|
||||||
|
{{ syncing ? 'Syncing…' : 'Sync email' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="contacts-toolbar">
|
<div class="contacts-toolbar">
|
||||||
|
|
@ -115,8 +167,16 @@ onMounted(fetchContacts)
|
||||||
|
|
||||||
<div v-if="loading" class="contacts-empty">Loading…</div>
|
<div v-if="loading" class="contacts-empty">Loading…</div>
|
||||||
<div v-else-if="error" class="contacts-empty contacts-empty--error">{{ error }}</div>
|
<div v-else-if="error" class="contacts-empty contacts-empty--error">{{ error }}</div>
|
||||||
|
<div v-else-if="contacts.length === 0 && !search" class="contacts-empty contacts-empty--setup">
|
||||||
|
<p>No contacts yet.</p>
|
||||||
|
<p class="contacts-empty-hint">
|
||||||
|
Connect your inbox in
|
||||||
|
<a href="/settings?tab=connections" class="setup-link">Settings → Connections</a>
|
||||||
|
then hit <strong>Sync email</strong> to import recruiter emails automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div v-else-if="contacts.length === 0" class="contacts-empty">
|
<div v-else-if="contacts.length === 0" class="contacts-empty">
|
||||||
No contacts found{{ search ? ' for that search' : '' }}.
|
No contacts found for that search.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="contacts-table-wrap">
|
<div v-else class="contacts-table-wrap">
|
||||||
|
|
@ -339,4 +399,69 @@ onMounted(fetchContacts)
|
||||||
.text-muted {
|
.text-muted {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contacts-sync {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-last {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sync {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sync:hover:not(:disabled) {
|
||||||
|
border-color: var(--app-primary);
|
||||||
|
color: var(--app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sync:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-icon--spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-empty--setup {
|
||||||
|
padding: var(--space-10) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-empty-hint {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-link {
|
||||||
|
color: var(--app-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,10 @@
|
||||||
rows="4"
|
rows="4"
|
||||||
aria-label="Job URLs to add"
|
aria-label="Job URLs to add"
|
||||||
/>
|
/>
|
||||||
|
<label class="add-jobs__skip-review">
|
||||||
|
<input type="checkbox" v-model="skipReview" />
|
||||||
|
Skip review — add directly to Apply queue
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
class="action-btn action-btn--primary"
|
class="action-btn action-btn--primary"
|
||||||
:disabled="!urlInput.trim()"
|
:disabled="!urlInput.trim()"
|
||||||
|
|
@ -439,13 +443,14 @@ const runEnrich = () => runTask('enrich', '/api/tasks/enrich')
|
||||||
|
|
||||||
const addTab = ref<'url' | 'csv'>('url')
|
const addTab = ref<'url' | 'csv'>('url')
|
||||||
const urlInput = ref('')
|
const urlInput = ref('')
|
||||||
|
const skipReview = ref(true)
|
||||||
|
|
||||||
async function addByUrl() {
|
async function addByUrl() {
|
||||||
const urls = urlInput.value.split('\n').map(u => u.trim()).filter(Boolean)
|
const urls = urlInput.value.split('\n').map(u => u.trim()).filter(Boolean)
|
||||||
await useApiFetch('/api/jobs/add', {
|
await useApiFetch('/api/jobs/add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ urls }),
|
body: JSON.stringify({ urls, skip_review: skipReview.value }),
|
||||||
})
|
})
|
||||||
urlInput.value = ''
|
urlInput.value = ''
|
||||||
store.refresh()
|
store.refresh()
|
||||||
|
|
@ -791,6 +796,16 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
|
.add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.add-jobs__skip-review {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Danger Zone ──────────────────────────────────────── */
|
/* ── Danger Zone ──────────────────────────────────────── */
|
||||||
|
|
||||||
.danger-zone {
|
.danger-zone {
|
||||||
|
|
|
||||||
|
|
@ -682,7 +682,7 @@ function formatRejectionDate(job: PipelineJob): string {
|
||||||
padding: 1px 8px; font-size: 0.75em; font-weight: 700; margin-left: var(--space-1);
|
padding: 1px 8px; font-size: 0.75em; font-weight: 700; margin-left: var(--space-1);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: #e67e22; }
|
.pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: var(--app-accent); }
|
||||||
|
|
||||||
/* Collapsible pre-list body */
|
/* Collapsible pre-list body */
|
||||||
.pre-list-body {
|
.pre-list-body {
|
||||||
|
|
@ -713,15 +713,15 @@ function formatRejectionDate(job: PipelineJob): string {
|
||||||
border-top: 1px solid transparent;
|
border-top: 1px solid transparent;
|
||||||
display: flex; flex-direction: column; gap: 4px;
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
}
|
}
|
||||||
.pre-signal-banner[data-color="amber"] { background: rgba(245,158,11,0.08); border-top-color: rgba(245,158,11,0.4); }
|
.pre-signal-banner[data-color="amber"] { background: color-mix(in srgb, var(--color-warning) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-warning) 40%, transparent); }
|
||||||
.pre-signal-banner[data-color="green"] { background: rgba(39,174,96,0.08); border-top-color: rgba(39,174,96,0.4); }
|
.pre-signal-banner[data-color="green"] { background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-success) 40%, transparent); }
|
||||||
.pre-signal-banner[data-color="red"] { background: rgba(192,57,43,0.08); border-top-color: rgba(192,57,43,0.4); }
|
.pre-signal-banner[data-color="red"] { background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-error) 40%, transparent); }
|
||||||
|
|
||||||
.signal-label { font-size: 0.82em; }
|
.signal-label { font-size: 0.82em; }
|
||||||
.signal-subject { font-size: 0.78em; color: var(--color-text-muted); }
|
.signal-subject { font-size: 0.78em; color: var(--color-text-muted); }
|
||||||
.signal-actions { display: flex; gap: 6px; align-items: center; }
|
.signal-actions { display: flex; gap: 6px; align-items: center; }
|
||||||
.btn-signal-move {
|
.btn-signal-move {
|
||||||
background: var(--color-primary); color: #fff;
|
background: var(--color-primary); color: var(--color-text-inverse);
|
||||||
border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer;
|
border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-signal-dismiss {
|
.btn-signal-dismiss {
|
||||||
|
|
|
||||||
|
|
@ -496,7 +496,7 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.tab-badge {
|
.tab-badge {
|
||||||
background: var(--color-warning);
|
background: var(--color-warning);
|
||||||
color: white;
|
color: var(--app-accent-text);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|
|
||||||
|
|
@ -34,35 +34,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Action bar -->
|
|
||||||
<div class="action-bar" role="toolbar" aria-label="Message actions">
|
|
||||||
<button class="btn btn--ghost" @click="openLogModal('call_note')">Log call</button>
|
|
||||||
<button class="btn btn--ghost" @click="openLogModal('in_person')">Log note</button>
|
|
||||||
<button class="btn btn--ghost" @click="openTemplateModal('apply')">Use template</button>
|
|
||||||
<button
|
|
||||||
class="btn btn--primary"
|
|
||||||
:disabled="store.loading"
|
|
||||||
@click="requestDraft"
|
|
||||||
>
|
|
||||||
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Osprey (Phase 2 stub) — aria-disabled, never hidden -->
|
|
||||||
<button
|
|
||||||
class="btn btn--osprey"
|
|
||||||
aria-disabled="true"
|
|
||||||
:title="ospreyTitle"
|
|
||||||
@mouseenter="handleOspreyHover"
|
|
||||||
@focus="handleOspreyHover"
|
|
||||||
>
|
|
||||||
📞 Call via Osprey
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Draft pending announcement (screen reader) -->
|
<!-- Draft pending announcement (screen reader) -->
|
||||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
<div aria-live="polite" aria-atomic="true" class="sr-only">{{ draftAnnouncement }}</div>
|
||||||
{{ draftAnnouncement }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error banner -->
|
<!-- Error banner -->
|
||||||
<p v-if="store.error" class="thread-error" role="alert">{{ store.error }}</p>
|
<p v-if="store.error" class="thread-error" role="alert">{{ store.error }}</p>
|
||||||
|
|
@ -76,9 +49,15 @@
|
||||||
v-for="item in timeline"
|
v-for="item in timeline"
|
||||||
:key="item._key"
|
:key="item._key"
|
||||||
class="timeline__item"
|
class="timeline__item"
|
||||||
:class="[`timeline__item--${item.type}`, item.approved_at === null && item.type === 'draft' ? 'timeline__item--draft-pending' : '']"
|
:class="[
|
||||||
|
`timeline__item--${item.type}`,
|
||||||
|
item.approved_at === null && item.type === 'draft' ? 'timeline__item--draft-pending' : '',
|
||||||
|
item.type !== 'draft' ? 'timeline__item--expandable' : '',
|
||||||
|
expandedKeys.has(item._key) ? 'timeline__item--open' : '',
|
||||||
|
]"
|
||||||
role="listitem"
|
role="listitem"
|
||||||
:aria-label="`${typeLabel(item.type)}, ${item.direction || ''}, ${item.logged_at}`"
|
:aria-label="`${typeLabel(item.type)}, ${item.direction || ''}, ${item.logged_at}`"
|
||||||
|
@click="item.type !== 'draft' && toggleExpand(item)"
|
||||||
>
|
>
|
||||||
<span class="timeline__icon" aria-hidden="true">{{ typeIcon(item.type) }}</span>
|
<span class="timeline__icon" aria-hidden="true">{{ typeIcon(item.type) }}</span>
|
||||||
<div class="timeline__content">
|
<div class="timeline__content">
|
||||||
|
|
@ -89,19 +68,29 @@
|
||||||
<span
|
<span
|
||||||
v-if="item.type === 'draft' && item.approved_at === null"
|
v-if="item.type === 'draft' && item.approved_at === null"
|
||||||
class="timeline__badge timeline__badge--pending"
|
class="timeline__badge timeline__badge--pending"
|
||||||
>
|
>Pending approval</span>
|
||||||
Pending approval
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
v-if="item.type === 'draft' && item.approved_at !== null"
|
v-if="item.type === 'draft' && item.approved_at !== null"
|
||||||
class="timeline__badge timeline__badge--approved"
|
class="timeline__badge timeline__badge--approved"
|
||||||
>
|
>Approved</span>
|
||||||
Approved
|
<span v-if="item.type !== 'draft'" class="timeline__expand-hint" aria-hidden="true">
|
||||||
|
{{ expandedKeys.has(item._key) ? '▲' : '▼' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="item.subject" class="timeline__subject">{{ item.subject }}</p>
|
<p v-if="item.subject" class="timeline__subject">{{ item.subject }}</p>
|
||||||
|
|
||||||
<!-- Draft body is editable before approval -->
|
<!-- Expandable body for non-draft items -->
|
||||||
|
<template v-if="item.type !== 'draft' && expandedKeys.has(item._key)">
|
||||||
|
<div class="timeline__body-wrap" @click.stop>
|
||||||
|
<div v-if="bodyCache[item.id] === null" class="timeline__body-loading">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
<pre v-else-if="bodyCache[item.id]" class="timeline__body">{{ bodyCache[item.id] }}</pre>
|
||||||
|
<p v-else class="timeline__body-empty">No body content.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Draft: editable textarea + actions -->
|
||||||
<template v-if="item.type === 'draft' && item.approved_at === null">
|
<template v-if="item.type === 'draft' && item.approved_at === null">
|
||||||
<textarea
|
<textarea
|
||||||
:ref="el => setDraftRef(item.id, el)"
|
:ref="el => setDraftRef(item.id, el)"
|
||||||
|
|
@ -119,25 +108,50 @@
|
||||||
v-if="item.to_addr"
|
v-if="item.to_addr"
|
||||||
:href="`mailto:${item.to_addr}?subject=${encodeURIComponent(item.subject ?? '')}&body=${encodeURIComponent(item.body ?? '')}`"
|
:href="`mailto:${item.to_addr}?subject=${encodeURIComponent(item.subject ?? '')}&body=${encodeURIComponent(item.body ?? '')}`"
|
||||||
class="btn btn--ghost btn--sm"
|
class="btn btn--ghost btn--sm"
|
||||||
target="_blank"
|
target="_blank" rel="noopener"
|
||||||
rel="noopener"
|
>Open in email client</a>
|
||||||
>
|
|
||||||
Open in email client
|
|
||||||
</a>
|
|
||||||
<button class="btn btn--ghost btn--sm btn--danger" @click="confirmDelete(item.id)">
|
<button class="btn btn--ghost btn--sm btn--danger" @click="confirmDelete(item.id)">
|
||||||
Discard
|
Discard
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
|
||||||
<p class="timeline__body">{{ item.body }}</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="timeline.length === 0" class="timeline__empty">
|
<li v-if="timeline.length === 0" class="timeline__empty">
|
||||||
No messages logged yet for this job.
|
No messages logged yet for this job.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<!-- Compose bar (sticky footer) -->
|
||||||
|
<div class="compose-bar" role="toolbar" aria-label="Compose actions">
|
||||||
|
<div v-if="composing" class="compose-bar__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openLogModal('call_note'))">Log call</button>
|
||||||
|
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openLogModal('in_person'))">Log note</button>
|
||||||
|
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openTemplateModal('apply'))">Use template</button>
|
||||||
|
<button
|
||||||
|
class="btn btn--primary btn--sm"
|
||||||
|
:disabled="store.loading"
|
||||||
|
@click="triggerAction(requestDraft)"
|
||||||
|
>
|
||||||
|
<span v-if="store.loading" class="btn__spinner" aria-hidden="true"></span>
|
||||||
|
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn--osprey btn--sm"
|
||||||
|
aria-disabled="true"
|
||||||
|
:title="ospreyTitle"
|
||||||
|
@mouseenter="handleOspreyHover"
|
||||||
|
@focus="handleOspreyHover"
|
||||||
|
>📞 Call via Osprey</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn compose-bar__toggle"
|
||||||
|
:class="composing ? 'btn--ghost' : 'btn--primary'"
|
||||||
|
@click="composing = !composing"
|
||||||
|
:aria-expanded="composing"
|
||||||
|
aria-controls="compose-actions"
|
||||||
|
>{{ composing ? '✕ Close' : '+ New' }}</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
@ -230,8 +244,8 @@ const jobContacts = ref<JobContact[]>([])
|
||||||
|
|
||||||
watch(selectedJobId, async (id) => {
|
watch(selectedJobId, async (id) => {
|
||||||
if (id === null) { jobContacts.value = []; return }
|
if (id === null) { jobContacts.value = []; return }
|
||||||
const { data } = await useApiFetch<JobContact[]>(`/api/contacts?job_id=${id}`)
|
const { data } = await useApiFetch<{ total: number; contacts: JobContact[] }>(`/api/contacts?job_id=${id}`)
|
||||||
jobContacts.value = data ?? []
|
jobContacts.value = data?.contacts ?? []
|
||||||
})
|
})
|
||||||
|
|
||||||
const timeline = computed<TimelineItem[]>(() => {
|
const timeline = computed<TimelineItem[]>(() => {
|
||||||
|
|
@ -262,6 +276,31 @@ const timeline = computed<TimelineItem[]>(() => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Body expansion ────────────────────────────────────────────────────────
|
||||||
|
const expandedKeys = ref(new Set<string>())
|
||||||
|
const bodyCache = ref<Record<number, string | null>>({}) // null = still loading
|
||||||
|
|
||||||
|
async function toggleExpand(item: TimelineItem) {
|
||||||
|
const key = item._key
|
||||||
|
const next = new Set(expandedKeys.value)
|
||||||
|
if (next.has(key)) { next.delete(key); expandedKeys.value = next; return }
|
||||||
|
next.add(key)
|
||||||
|
expandedKeys.value = next
|
||||||
|
if (key.startsWith('jc-') && !(item.id in bodyCache.value)) {
|
||||||
|
bodyCache.value = { ...bodyCache.value, [item.id]: null }
|
||||||
|
const { data } = await useApiFetch<{ body: string | null }>(`/api/contacts/${item.id}`)
|
||||||
|
const raw = data?.body ?? ''
|
||||||
|
const text = raw.trimStart().startsWith('<')
|
||||||
|
? (new DOMParser().parseFromString(raw, 'text/html').body.textContent ?? '').trim()
|
||||||
|
: raw.trim()
|
||||||
|
bodyCache.value = { ...bodyCache.value, [item.id]: text }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compose bar ────────────────────────────────────────────────────────────
|
||||||
|
const composing = ref(false)
|
||||||
|
function triggerAction(fn: () => void) { composing.value = false; fn() }
|
||||||
|
|
||||||
// ── Draft body edits (local, before approve) ──────────────────────────────
|
// ── Draft body edits (local, before approve) ──────────────────────────────
|
||||||
|
|
||||||
const draftBodyEdits = ref<Record<number, string>>({})
|
const draftBodyEdits = ref<Record<number, string>>({})
|
||||||
|
|
@ -415,8 +454,15 @@ onUnmounted(() => {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.messaging-layout {
|
.messaging-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100dvh;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.messaging-layout {
|
||||||
|
height: calc(100dvh - 56px - env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Left panel ─────────────────────── */
|
/* ── Left panel ─────────────────────── */
|
||||||
|
|
@ -465,11 +511,6 @@ onUnmounted(() => {
|
||||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.action-bar {
|
|
||||||
display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
.btn--osprey {
|
.btn--osprey {
|
||||||
opacity: 0.5; cursor: not-allowed;
|
opacity: 0.5; cursor: not-allowed;
|
||||||
background: none; border: 1px dashed var(--color-border);
|
background: none; border: 1px dashed var(--color-border);
|
||||||
|
|
@ -477,6 +518,21 @@ onUnmounted(() => {
|
||||||
color: var(--color-text-muted); font-size: var(--text-sm);
|
color: var(--color-text-muted); font-size: var(--text-sm);
|
||||||
padding: var(--space-2) var(--space-3); min-height: 36px;
|
padding: var(--space-2) var(--space-3); min-height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compose bar */
|
||||||
|
.compose-bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex; flex-direction: column; align-items: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
.compose-bar__actions {
|
||||||
|
display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
|
||||||
|
width: 100%; justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.compose-bar__toggle { align-self: flex-end; min-width: 90px; justify-content: center; }
|
||||||
.thread-error {
|
.thread-error {
|
||||||
margin: var(--space-2) var(--space-4);
|
margin: var(--space-2) var(--space-4);
|
||||||
color: var(--app-accent); font-size: var(--text-sm);
|
color: var(--app-accent); font-size: var(--text-sm);
|
||||||
|
|
@ -507,10 +563,27 @@ onUnmounted(() => {
|
||||||
font-size: var(--text-xs); font-weight: 700;
|
font-size: var(--text-xs); font-weight: 700;
|
||||||
padding: 1px 6px; border-radius: var(--radius-full);
|
padding: 1px 6px; border-radius: var(--radius-full);
|
||||||
}
|
}
|
||||||
.timeline__badge--pending { background: #fef3c7; color: #d97706; }
|
.timeline__badge--pending { background: var(--color-accent-light); color: var(--color-accent); }
|
||||||
.timeline__badge--approved { background: #d1fae5; color: #065f46; }
|
.timeline__badge--approved { background: var(--color-primary-light); color: var(--color-primary); }
|
||||||
.timeline__subject { font-size: var(--text-sm); font-weight: 500; margin: 0; }
|
.timeline__subject { font-size: var(--text-sm); font-weight: 500; margin: 0; }
|
||||||
.timeline__body { font-size: var(--text-sm); white-space: pre-wrap; margin: 0; color: var(--color-text); }
|
.timeline__expand-hint {
|
||||||
|
font-size: var(--text-xs); color: var(--color-text-muted); margin-left: auto;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
}
|
||||||
|
.timeline__item--expandable { cursor: pointer; }
|
||||||
|
.timeline__item--expandable:hover { border-color: var(--app-primary); }
|
||||||
|
.timeline__body-wrap {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
}
|
||||||
|
.timeline__body {
|
||||||
|
font-size: var(--text-sm); white-space: pre-wrap; margin: 0;
|
||||||
|
color: var(--color-text); max-height: 280px; overflow-y: auto;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
.timeline__body-loading { font-size: var(--text-xs); color: var(--color-text-muted); }
|
||||||
|
.timeline__body-empty { font-size: var(--text-xs); color: var(--color-text-muted); margin: 0; }
|
||||||
.timeline__draft-body {
|
.timeline__draft-body {
|
||||||
width: 100%; font-size: var(--text-sm); font-family: var(--font-body);
|
width: 100%; font-size: var(--text-sm); font-family: var(--font-body);
|
||||||
padding: var(--space-2); border: 1px solid var(--color-border);
|
padding: var(--space-2); border: 1px solid var(--color-border);
|
||||||
|
|
@ -522,20 +595,45 @@ onUnmounted(() => {
|
||||||
.timeline__empty { color: var(--color-text-muted); font-size: var(--text-sm); padding: var(--space-2); }
|
.timeline__empty { color: var(--color-text-muted); font-size: var(--text-sm); padding: var(--space-2); }
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn { padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; cursor: pointer; min-height: 36px; }
|
.btn {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 36px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease, transform 80ms ease;
|
||||||
|
}
|
||||||
|
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||||
|
.btn:focus-visible { outline: 2px solid var(--app-primary); outline-offset: 2px; }
|
||||||
.btn--sm { padding: var(--space-1) var(--space-3); min-height: 30px; font-size: var(--text-xs); }
|
.btn--sm { padding: var(--space-1) var(--space-3); min-height: 30px; font-size: var(--text-xs); }
|
||||||
.btn--primary { background: var(--app-primary); color: var(--color-surface); border: none; }
|
.btn--primary { background: var(--app-primary); color: var(--color-surface); border: none; }
|
||||||
.btn--primary:hover:not(:disabled) { opacity: 0.9; }
|
.btn--primary:hover:not(:disabled) { opacity: 0.88; }
|
||||||
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.btn--ghost { background: none; border: 1px solid var(--color-border); color: var(--color-text); }
|
.btn--ghost { background: none; border: 1px solid var(--color-border); color: var(--color-text); }
|
||||||
.btn--ghost:hover { background: var(--color-surface-alt); }
|
.btn--ghost:hover:not(:disabled) { background: var(--color-surface-alt); border-color: var(--app-primary); color: var(--app-primary); }
|
||||||
.btn--danger { background: var(--app-accent); color: white; border: none; }
|
.btn--danger { background: var(--app-accent); color: var(--app-accent-text); border: none; }
|
||||||
.btn--danger:hover { opacity: 0.9; }
|
.btn--danger:hover:not(:disabled) { opacity: 0.88; }
|
||||||
|
|
||||||
|
/* Spinner inside buttons */
|
||||||
|
.btn__spinner {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.35);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: btn-spin 0.65s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes btn-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
/* Modals (delete confirm) */
|
/* Modals (delete confirm) */
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
background: rgba(0,0,0,0.5);
|
background: var(--color-overlay);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -442,7 +442,7 @@ onMounted(fetchRefs)
|
||||||
.tag-chip--technical { background: var(--app-primary-light); color: var(--app-primary); }
|
.tag-chip--technical { background: var(--app-primary-light); color: var(--app-primary); }
|
||||||
.tag-chip--managerial { background: rgba(39, 174, 96, 0.12); color: var(--color-success); }
|
.tag-chip--managerial { background: rgba(39, 174, 96, 0.12); color: var(--color-success); }
|
||||||
.tag-chip--character { background: rgba(212, 137, 26, 0.12); color: var(--score-mid); }
|
.tag-chip--character { background: rgba(212, 137, 26, 0.12); color: var(--score-mid); }
|
||||||
.tag-chip--academic { background: rgba(103, 58, 183, 0.12); color: #7c3aed; }
|
.tag-chip--academic { background: color-mix(in srgb, var(--status-synced) 12%, var(--color-surface)); color: var(--status-synced); }
|
||||||
|
|
||||||
.ref-card__actions {
|
.ref-card__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue