Compare commits

..

2 Commits

36 changed files with 305 additions and 1838 deletions

View File

@@ -6,16 +6,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
from app.schemas import DocumentListResponse, DocumentResponse from app.schemas import DocumentListResponse, DocumentResponse
from app.services.documents import create_document, delete_document, get_document, list_documents from app.services.documents import create_document, get_document, list_documents
from app.services.storage import storage_client from app.services.storage import storage_client
from dlib.auth import AuthenticatedUser from dlib.auth import AuthenticatedUser
from dlib.cv import generate_patched_docx
router = APIRouter(prefix="/documents", tags=["documents"]) router = APIRouter(prefix="/documents", tags=["documents"])
@router.get("", response_model=DocumentListResponse) @router.get("/", response_model=DocumentListResponse)
async def list_user_documents( async def list_user_documents(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user), user: AuthenticatedUser = Depends(get_current_user),
@@ -50,8 +49,7 @@ async def download_version_docx(
version = next((v for v in document.versions if v.id == version_id), None) version = next((v for v in document.versions if v.id == version_id), None)
if not version or not version.artifact_docx_key: if not version or not version.artifact_docx_key:
raise HTTPException(status_code=404, detail="Version artifact not found") raise HTTPException(status_code=404, detail="Version artifact not found")
original = storage_client.download_bytes(key=version.artifact_docx_key) data = storage_client.download_bytes(key=version.artifact_docx_key)
data = generate_patched_docx(original, version.structured_blocks or [])
slug = f"{document.title.replace(' ', '-')}-{version.branch_name}.docx" slug = f"{document.title.replace(' ', '-')}-{version.branch_name}.docx"
return Response( return Response(
content=data, content=data,
@@ -60,7 +58,7 @@ async def download_version_docx(
) )
@router.post("", response_model=DocumentResponse) @router.post("/", response_model=DocumentResponse)
async def upload_document( async def upload_document(
title: str = Form(...), title: str = Form(...),
description: str | None = Form(default=None), description: str | None = Form(default=None),
@@ -76,14 +74,3 @@ async def upload_document(
upload=file, upload=file,
) )
return DocumentResponse.model_validate(document) return DocumentResponse.model_validate(document)
@router.delete("/{document_id}", status_code=204)
async def delete_user_document(
document_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
deleted = await delete_document(session, owner_id=user.sub, document_id=document_id)
if not deleted:
raise HTTPException(status_code=404, detail="Document not found")

View File

@@ -1,53 +1,20 @@
from __future__ import annotations from __future__ import annotations
import hashlib from fastapi import APIRouter, Depends, HTTPException
from datetime import datetime, timezone from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
from app.core.config import get_settings from app.core.config import get_settings
from app.models import CvDocument, CvVersion, PublicAsset, PublicAssetView from app.models import PublicAsset
from app.schemas import ( from app.schemas import PublicAssetLookupResponse, PublicAssetResponse, PublishRequest
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
)
from app.services.publication import publish_version from app.services.publication import publish_version
from app.services.storage import storage_client
from dlib.auth import AuthenticatedUser from dlib.auth import AuthenticatedUser
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
router = APIRouter(prefix="/public", tags=["public"]) router = APIRouter(prefix="/public", tags=["public"])
async def _log_view(session: AsyncSession, asset: PublicAsset, request: Request) -> None:
ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "")
ip_hash = hashlib.sha256(ip.split(",")[0].strip().encode()).hexdigest() if ip else None
view = PublicAssetView(
public_asset_id=asset.id,
viewed_at=datetime.now(timezone.utc),
user_agent=request.headers.get("user-agent", "")[:512] or None,
ip_hash=ip_hash,
)
session.add(view)
await session.commit()
async def _get_public_asset(session: AsyncSession, slug: str) -> PublicAsset:
stmt = select(PublicAsset).where(PublicAsset.slug == slug, PublicAsset.is_public.is_(True))
result = await session.execute(stmt)
asset = result.scalars().one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
return asset
@router.post("/publish", response_model=PublicAssetResponse) @router.post("/publish", response_model=PublicAssetResponse)
async def publish( async def publish(
payload: PublishRequest, payload: PublishRequest,
@@ -66,78 +33,21 @@ async def publish(
return _response_from_asset(asset) return _response_from_asset(asset)
@router.get("/{slug}/analytics", response_model=PublicAssetAnalyticsResponse)
async def get_analytics(
slug: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
asset = await _get_public_asset(session, slug)
if asset.version_id:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == asset.version_id, CvDocument.owner_id == user.sub)
)
if not (await session.execute(stmt)).scalars().one_or_none():
raise HTTPException(status_code=403, detail="Not authorized")
else:
raise HTTPException(status_code=403, detail="Not authorized")
view_count = (
await session.execute(
select(func.count()).where(PublicAssetView.public_asset_id == asset.id)
)
).scalar() or 0
last_viewed_at = (
await session.execute(
select(PublicAssetView.viewed_at)
.where(PublicAssetView.public_asset_id == asset.id)
.order_by(PublicAssetView.viewed_at.desc())
.limit(1)
)
).scalar()
return PublicAssetAnalyticsResponse(
slug=slug, view_count=view_count, last_viewed_at=last_viewed_at
)
@router.get("/{slug}/pdf")
async def get_public_pdf(slug: str, request: Request, session: AsyncSession = Depends(get_db)):
asset = await _get_public_asset(session, slug)
await _log_view(session, asset, request)
version: CvVersion | None = None
if asset.version_id:
stmt = select(CvVersion).where(CvVersion.id == asset.version_id)
version = (await session.execute(stmt)).scalars().one_or_none()
docx_bytes = storage_client.download_bytes(key=asset.artifact_key)
blocks = version.structured_blocks or [] if version else []
patched = generate_patched_docx(docx_bytes, blocks)
pdf_bytes = docx_bytes_to_pdf(patched)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{slug}.pdf"'},
)
@router.get("/{slug}", response_model=PublicAssetLookupResponse) @router.get("/{slug}", response_model=PublicAssetLookupResponse)
async def get_public_asset(slug: str, request: Request, session: AsyncSession = Depends(get_db)): async def get_public_asset(slug: str, session: AsyncSession = Depends(get_db)):
asset = await _get_public_asset(session, slug) stmt = select(PublicAsset).where(
await _log_view(session, asset, request) PublicAsset.slug == slug, PublicAsset.is_public.is_(True)
)
result = await session.execute(stmt)
asset = result.scalars().one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
return PublicAssetLookupResponse(asset=_response_from_asset(asset)) return PublicAssetLookupResponse(asset=_response_from_asset(asset))
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse: def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
settings = get_settings() settings = get_settings()
base = settings.public_base_url.rstrip("/") url = f"{settings.public_base_url.rstrip('/')}/cv/{asset.slug}"
url = f"{base}/cv/{asset.slug}"
return PublicAssetResponse( return PublicAssetResponse(
id=asset.id, id=asset.id,
slug=asset.slug, slug=asset.slug,

View File

@@ -9,44 +9,15 @@ from app.schemas import (
SubmissionCreateRequest, SubmissionCreateRequest,
SubmissionResponse, SubmissionResponse,
SuggestionResponse, SuggestionResponse,
SuggestionUpdateRequest,
)
from app.services.submissions import (
create_submission,
get_submission,
list_submissions,
request_ai_suggestions,
update_suggestion,
) )
from app.services.submissions import create_submission, request_ai_suggestions
from dlib.auth import AuthenticatedUser from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/submissions", tags=["submissions"]) router = APIRouter(prefix="/submissions", tags=["submissions"])
@router.get("", response_model=list[SubmissionResponse]) @router.post("/", response_model=SubmissionResponse)
async def list_submissions_endpoint(
version_id: str | None = None,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
items = await list_submissions(session, owner_id=user.sub, version_id=version_id)
return [SubmissionResponse.model_validate(s) for s in items]
@router.get("/{submission_id}", response_model=SubmissionResponse)
async def get_submission_endpoint(
submission_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
submission = await get_submission(session, owner_id=user.sub, submission_id=submission_id)
if not submission:
raise HTTPException(status_code=404, detail="Submission not found")
return SubmissionResponse.model_validate(submission)
@router.post("", response_model=SubmissionResponse)
async def create_submission_endpoint( async def create_submission_endpoint(
payload: SubmissionCreateRequest, payload: SubmissionCreateRequest,
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
@@ -83,23 +54,3 @@ async def request_submissions_ai(
if suggestions is None: if suggestions is None:
raise HTTPException(status_code=404, detail="Submission not found") raise HTTPException(status_code=404, detail="Submission not found")
return [SuggestionResponse.model_validate(item) for item in suggestions] return [SuggestionResponse.model_validate(item) for item in suggestions]
@router.patch("/{submission_id}/suggestions/{suggestion_id}", response_model=SuggestionResponse)
async def update_suggestion_endpoint(
submission_id: str,
suggestion_id: str,
payload: SuggestionUpdateRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
suggestion = await update_suggestion(
session,
owner_id=user.sub,
submission_id=submission_id,
suggestion_id=suggestion_id,
accepted=payload.accepted,
)
if not suggestion:
raise HTTPException(status_code=404, detail="Suggestion not found")
return SuggestionResponse.model_validate(suggestion)

View File

@@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
from app.schemas import BranchCreateRequest, VersionResponse from app.schemas import BranchCreateRequest, VersionResponse
from app.services.versions import create_branch, delete_version from app.services.versions import create_branch
from dlib.auth import AuthenticatedUser from dlib.auth import AuthenticatedUser
@@ -29,18 +29,3 @@ async def create_version_branch(
if not version: if not version:
raise HTTPException(status_code=404, detail="Parent version not found") raise HTTPException(status_code=404, detail="Parent version not found")
return VersionResponse.model_validate(version) return VersionResponse.model_validate(version)
@router.delete("/{version_id}", status_code=204)
async def delete_version_branch(
version_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
result = await delete_version(session, owner_id=user.sub, version_id=version_id)
if result is False:
raise HTTPException(status_code=404, detail="Version not found")
if result == "root":
raise HTTPException(status_code=400, detail="Cannot delete root version")
if result == "has_children":
raise HTTPException(status_code=409, detail="Delete child branches first")

View File

@@ -4,7 +4,6 @@ from .cv import (
CvPatch, CvPatch,
CvVersion, CvVersion,
PublicAsset, PublicAsset,
PublicAssetView,
Specialization, Specialization,
Submission, Submission,
SubmissionStatus, SubmissionStatus,
@@ -18,6 +17,5 @@ __all__ = [
"Submission", "Submission",
"SubmissionStatus", "SubmissionStatus",
"PublicAsset", "PublicAsset",
"PublicAssetView",
"AiSuggestion", "AiSuggestion",
] ]

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import enum import enum
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
@@ -22,11 +21,7 @@ class CvDocument(Base, IdentifierMixin, TimestampMixin):
) )
versions: Mapped[list["CvVersion"]] = relationship( versions: Mapped[list["CvVersion"]] = relationship(
"CvVersion", "CvVersion", back_populates="document"
back_populates="document",
foreign_keys="[CvVersion.document_id]",
cascade="all, delete-orphan",
passive_deletes=True,
) )
@@ -46,9 +41,7 @@ class CvVersion(Base, IdentifierMixin, TimestampMixin):
structured_blocks: Mapped[list[dict] | None] = mapped_column(JSONB, default=list) structured_blocks: Mapped[list[dict] | None] = mapped_column(JSONB, default=list)
metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict) metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
document: Mapped[CvDocument] = relationship( document: Mapped[CvDocument] = relationship("CvDocument", back_populates="versions")
"CvDocument", back_populates="versions", foreign_keys="[CvVersion.document_id]"
)
parent: Mapped["CvVersion | None"] = relationship( parent: Mapped["CvVersion | None"] = relationship(
"CvVersion", remote_side="[CvVersion.id]" "CvVersion", remote_side="[CvVersion.id]"
) )
@@ -139,24 +132,6 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
"Submission", back_populates="public_asset" "Submission", back_populates="public_asset"
) )
version: Mapped[CvVersion | None] = relationship("CvVersion") version: Mapped[CvVersion | None] = relationship("CvVersion")
views: Mapped[list["PublicAssetView"]] = relationship(
"PublicAssetView", back_populates="public_asset", cascade="all, delete-orphan", passive_deletes=True,
)
class PublicAssetView(Base, IdentifierMixin):
__tablename__ = "public_asset_views"
public_asset_id: Mapped[str] = mapped_column(
ForeignKey("public_assets.id", ondelete="CASCADE"), index=True
)
viewed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
)
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
public_asset: Mapped[PublicAsset] = relationship("PublicAsset", back_populates="views")
class AiSuggestion(Base, IdentifierMixin, TimestampMixin): class AiSuggestion(Base, IdentifierMixin, TimestampMixin):

View File

@@ -4,14 +4,12 @@ from .cv import (
DocumentCreateResult, DocumentCreateResult,
DocumentListResponse, DocumentListResponse,
DocumentResponse, DocumentResponse,
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse, PublicAssetLookupResponse,
PublicAssetResponse, PublicAssetResponse,
PublishRequest, PublishRequest,
SubmissionCreateRequest, SubmissionCreateRequest,
SubmissionResponse, SubmissionResponse,
SuggestionResponse, SuggestionResponse,
SuggestionUpdateRequest,
VersionResponse, VersionResponse,
) )
@@ -25,9 +23,7 @@ __all__ = [
"SubmissionResponse", "SubmissionResponse",
"AiSuggestionRequest", "AiSuggestionRequest",
"SuggestionResponse", "SuggestionResponse",
"SuggestionUpdateRequest",
"PublishRequest", "PublishRequest",
"PublicAssetResponse", "PublicAssetResponse",
"PublicAssetLookupResponse", "PublicAssetLookupResponse",
"PublicAssetAnalyticsResponse",
] ]

View File

@@ -123,13 +123,3 @@ class PublicAssetResponse(BaseModel):
class PublicAssetLookupResponse(BaseModel): class PublicAssetLookupResponse(BaseModel):
asset: PublicAssetResponse asset: PublicAssetResponse
class PublicAssetAnalyticsResponse(BaseModel):
slug: str
view_count: int
last_viewed_at: datetime | None = None
class SuggestionUpdateRequest(BaseModel):
accepted: bool

View File

@@ -36,15 +36,8 @@ async def create_document(
session.add(doc) session.add(doc)
await session.commit() await session.commit()
await session.refresh(doc) await session.refresh(doc, attribute_names=["versions"])
return doc
stmt = (
select(CvDocument)
.where(CvDocument.id == doc.id)
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
)
result = await session.execute(stmt)
return result.scalars().unique().one()
async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocument]: async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocument]:
@@ -68,14 +61,3 @@ async def get_document(
) )
result = await session.execute(stmt) result = await session.execute(stmt)
return result.scalars().unique().one_or_none() return result.scalars().unique().one_or_none()
async def delete_document(
session: AsyncSession, owner_id: str, document_id: str
) -> bool:
doc = await get_document(session, owner_id, document_id)
if not doc:
return False
await session.delete(doc)
await session.commit()
return True

View File

@@ -24,8 +24,8 @@ async def publish_version(
if submission_id: if submission_id:
stmt = ( stmt = (
select(Submission) select(Submission)
.join(Submission.version) .join(CvVersion)
.join(CvVersion.document) .join(CvDocument)
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id) .where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
) )
result = await session.execute(stmt) result = await session.execute(stmt)
@@ -34,7 +34,7 @@ async def publish_version(
elif version_id: elif version_id:
stmt = ( stmt = (
select(CvVersion) select(CvVersion)
.join(CvVersion.document) .join(CvDocument)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id) .where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
) )
result = await session.execute(stmt) result = await session.execute(stmt)

View File

@@ -84,61 +84,12 @@ async def request_ai_suggestions(
return created return created
async def list_submissions(
session: AsyncSession,
*,
owner_id: str,
version_id: str | None = None,
) -> list[Submission]:
stmt = (
select(Submission)
.join(Submission.version)
.join(CvVersion.document)
.where(CvDocument.owner_id == owner_id)
.options(selectinload(Submission.suggestions))
)
if version_id:
stmt = stmt.where(Submission.version_id == version_id)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_submission(
session: AsyncSession, *, owner_id: str, submission_id: str
) -> Submission | None:
return await _get_submission_for_owner(session, owner_id, submission_id)
async def update_suggestion(
session: AsyncSession,
*,
owner_id: str,
submission_id: str,
suggestion_id: str,
accepted: bool,
) -> AiSuggestion | None:
submission = await _get_submission_for_owner(session, owner_id, submission_id)
if not submission:
return None
stmt = select(AiSuggestion).where(
AiSuggestion.id == suggestion_id, AiSuggestion.submission_id == submission_id
)
result = await session.execute(stmt)
suggestion = result.scalars().one_or_none()
if not suggestion:
return None
suggestion.accepted = accepted
await session.commit()
await session.refresh(suggestion)
return suggestion
async def _get_version_for_owner( async def _get_version_for_owner(
session: AsyncSession, owner_id: str, version_id: str session: AsyncSession, owner_id: str, version_id: str
) -> CvVersion | None: ) -> CvVersion | None:
stmt = ( stmt = (
select(CvVersion) select(CvVersion)
.join(CvVersion.document) .join(CvDocument)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id) .where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
) )
result = await session.execute(stmt) result = await session.execute(stmt)
@@ -152,8 +103,8 @@ async def _get_submission_for_owner(
) -> Submission | None: ) -> Submission | None:
stmt = ( stmt = (
select(Submission) select(Submission)
.join(Submission.version) .join(CvVersion)
.join(CvVersion.document) .join(CvDocument)
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id) .where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
.options(selectinload(Submission.version)) .options(selectinload(Submission.version))
) )

View File

@@ -26,7 +26,7 @@ async def create_branch(
) -> CvVersion | None: ) -> CvVersion | None:
stmt = ( stmt = (
select(CvVersion) select(CvVersion)
.join(CvVersion.document) .join(CvDocument)
.where(CvVersion.id == parent_version_id, CvDocument.owner_id == owner_id) .where(CvVersion.id == parent_version_id, CvDocument.owner_id == owner_id)
.options(selectinload(CvVersion.patches)) .options(selectinload(CvVersion.patches))
) )
@@ -74,36 +74,5 @@ async def create_branch(
) )
await session.commit() await session.commit()
await session.refresh(new_version)
stmt_refresh = ( return new_version
select(CvVersion)
.where(CvVersion.id == new_version.id)
.options(selectinload(CvVersion.patches))
)
result = await session.execute(stmt_refresh)
return result.scalars().one()
async def delete_version(
session: AsyncSession, owner_id: str, version_id: str
) -> bool | str:
"""Delete a non-root branch. Returns False if not found, 'root' if root, True on success."""
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)
version = result.scalars().one_or_none()
if not version:
return False
if not version.parent_version_id:
return "root"
# Refuse if child branches exist
child_stmt = select(CvVersion.id).where(CvVersion.parent_version_id == version_id).limit(1)
child_result = await session.execute(child_stmt)
if child_result.scalar_one_or_none():
return "has_children"
await session.delete(version)
await session.commit()
return True

View File

@@ -1,56 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
function authentikBase(url?: string | null) {
if (!url) return null;
try {
const parsed = new URL(url);
return parsed.origin.replace(/\/$/, '');
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
const { searchParams, origin } = new URL(req.url);
const code = searchParams.get('code');
if (!code) return NextResponse.redirect(`${origin}/login?error=no_code`);
const issuerRaw = process.env.AUTHENTIK_ISSUER;
const clientId = process.env.AUTHENTIK_CLIENT_ID;
const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET;
const publicBase = process.env.NEXT_PUBLIC_BASE_URL ?? origin;
const redirectUri = `${publicBase}/api/auth/callback`;
const authentikHost = authentikBase(issuerRaw);
if (!authentikHost || !clientId || !clientSecret) {
return NextResponse.redirect(`${publicBase}/login?error=oidc_not_configured`);
}
const tokenRes = await fetch(`${authentikHost}/application/o/token/`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', code,
redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret,
}),
}).catch(() => null);
if (!tokenRes?.ok) return NextResponse.redirect(`${origin}/login?error=token_exchange`);
const tokens = await tokenRes.json();
const res = NextResponse.redirect(`${publicBase}/dashboard`);
res.cookies.set('oidc_token', tokens.access_token, {
httpOnly: true, sameSite: 'lax', path: '/',
maxAge: tokens.expires_in ?? 3600,
secure: process.env.NODE_ENV === 'production',
});
// non-httpOnly copy for client-side API bearer usage
res.cookies.set('oidc_token_pub', tokens.access_token, {
httpOnly: false, sameSite: 'lax', path: '/',
maxAge: tokens.expires_in ?? 3600,
secure: process.env.NODE_ENV === 'production',
});
return res;
}

View File

@@ -1,27 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { createHmac } from 'crypto';
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
const LOGIN_USER = process.env.LOGIN_USER ?? 'admin';
const LOGIN_PASS = process.env.LOGIN_PASS ?? 'admin';
function sign(value: string) {
return createHmac('sha256', SECRET).update(value).digest('hex');
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const { username, password } = body as Record<string, string>;
if (!username || !password || username !== LOGIN_USER || password !== LOGIN_PASS) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const payload = `${username}:${Date.now()}`;
const token = `${payload}.${sign(payload)}`;
const res = NextResponse.json({ ok: true });
res.cookies.set('session', token, {
httpOnly: true, sameSite: 'lax', path: '/',
maxAge: 60 * 60 * 24 * 7,
secure: process.env.NODE_ENV === 'production',
});
return res;
}

View File

@@ -1,8 +0,0 @@
import { NextResponse } from 'next/server';
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.delete('session');
res.cookies.delete('oidc_token');
return res;
}

View File

@@ -1,6 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
const token = req.cookies.get('oidc_token')?.value ?? null;
return NextResponse.json({ token });
}

View File

@@ -1,38 +0,0 @@
import { NextResponse } from 'next/server';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const backend = process.env.API_BASE_URL ?? "http://localhost:9812";
try {
const res = await fetch(`${backend}/api/v1/public/${slug}`, {
cache: 'no-store'
});
if (!res.ok) {
return new NextResponse('CV not found', { status: 404 });
}
const data = await res.json();
if (!data.asset || !data.asset.artifact_key) {
return new NextResponse('CV not found', { status: 404 });
}
// Construct MinIO public URL
const storageHost = process.env.MINIO_ENDPOINT || (process.env.NODE_ENV === 'production'
? 'https://storage.cv.alves.world'
: 'http://localhost:9900');
const bucket = process.env.MINIO_BUCKET || 'resume-branches';
const downloadUrl = `${storageHost}/${bucket}/${data.asset.artifact_key}`;
return NextResponse.redirect(downloadUrl);
} catch (error) {
console.error('Error fetching public CV:', error);
return new NextResponse('Internal Server Error', { status: 500 });
}
}

View File

@@ -3,5 +3,12 @@ export default function DashboardLayout({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
return <>{children}</>; return (
<div>
<nav>
<h1>Dashboard</h1>
</nav>
<main>{children}</main>
</div>
)
} }

View File

@@ -5,29 +5,26 @@ import CVTree from '@/components/cv/CVTree';
import DiffViewer from '@/components/cv/DiffViewer'; import DiffViewer from '@/components/cv/DiffViewer';
import Link from 'next/link'; import Link from 'next/link';
import { import {
createBranch, createSubmission, deleteDocument, deleteVersion, createBranch, createSubmission, Document, downloadVersionUrl,
Document, downloadVersionUrl, fetchDocuments, publishVersion, uploadDocument, Version,
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
publishVersion, PublicAsset, PublicAssetAnalytics,
requestAiSuggestions,
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
} from '@/libs/api'; } from '@/libs/api';
// ── helpers ─────────────────────────────────────────────────────────────────── // ─── tiny helpers ────────────────────────────────────────────────────────────
function fmt(iso: string) { function fmt(iso: string) {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} }
function statusBadge(status: string) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
const cls = ({ return (
draft: 'badge-draft', tailoring: 'badge-submitted', pending_review: 'badge-interviewing', <div>
published: 'badge-public', archived: 'badge-closed', <div className="label" style={{ padding: '0 0 8px' }}>{title}</div>
} as Record<string, string>)[status] ?? 'badge-draft'; {children}
return <span className={`badge ${cls}`}>{status.replace('_', ' ')}</span>; </div>
);
} }
// ── modals ─────────────────────────────────────────────────────────────────── // ── modals ───────────────────────────────────────────────────────────────────
function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: Document) => void }) { function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: Document) => void }) {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@@ -49,18 +46,17 @@ function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: D
<div className="modal" onClick={e => e.stopPropagation()}> <div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">Upload CV</div> <div className="modal-title">Upload CV</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} autoFocus /> <input placeholder="Title (e.g. My Resume)" value={title} onChange={e => setTitle(e.target.value)} />
<input placeholder="Description (optional)" value={desc} onChange={e => setDesc(e.target.value)} /> <input placeholder="Description (optional)" value={desc} onChange={e => setDesc(e.target.value)} />
<div onClick={() => ref.current?.click()} style={{ <div
border: '1px dashed var(--border-strong)', borderRadius: 5, padding: '16px 0', onClick={() => ref.current?.click()}
textAlign: 'center', cursor: 'pointer', fontSize: 13, style={{ border: '1px dashed var(--border-strong)', borderRadius: 5, padding: '18px 0', textAlign: 'center', cursor: 'pointer', fontSize: 13, color: file ? 'var(--text)' : 'var(--text-muted)' }}
color: file ? 'var(--text)' : 'var(--text-muted)', >
}}> {file ? file.name : 'Click to select .docx file'}
{file ? file.name : 'Click to select .docx'}
</div> </div>
<input ref={ref} type="file" accept=".docx" style={{ display: 'none' }} onChange={e => setFile(e.target.files?.[0] ?? null)} /> <input ref={ref} type="file" accept=".docx" style={{ display: 'none' }} onChange={e => setFile(e.target.files?.[0] ?? null)} />
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>} {error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button> <button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}> <button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Uploading…' : 'Upload'} {loading ? 'Uploading…' : 'Upload'}
@@ -72,51 +68,31 @@ function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: D
); );
} }
function BranchModal({ function BranchModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (v: Version) => void }) {
version, initialPatches, onClose, onDone,
}: {
version: Version;
initialPatches?: Array<{ target_path: string; operation: string; old_value: string; new_value: string }>;
onClose: () => void;
onDone: (v: Version) => void;
}) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const patches = initialPatches ?? [];
const submit = async () => { const submit = async () => {
if (!name.trim()) { setError('Branch name required.'); return; } if (!name.trim()) { setError('Branch name required.'); return; }
setLoading(true); setError(''); setLoading(true); setError('');
try { onDone(await createBranch(version.id, name.trim(), label.trim() || null, patches as Record<string, unknown>[])); } try { onDone(await createBranch(version.id, name.trim(), label.trim() || null)); }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
}; };
return ( return (
<div className="overlay" onClick={onClose}> <div className="overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 460 }}> <div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title"> <div className="modal-title">New branch from <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 400 }}>{version.branch_name}</span></div>
New branch from <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 400 }}>{version.branch_name}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input placeholder="Branch name (e.g. ml-engineer)" value={name} onChange={e => setName(e.target.value)} autoFocus /> <input placeholder="Branch name (e.g. ml-engineer)" value={name} onChange={e => setName(e.target.value)} />
<input placeholder="Label (optional)" value={label} onChange={e => setLabel(e.target.value)} /> <input placeholder="Label (optional)" value={label} onChange={e => setLabel(e.target.value)} />
{patches.length > 0 && (
<div style={{ padding: '8px 10px', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }}>
<div className="label" style={{ marginBottom: 6 }}>Staged edits ({patches.length})</div>
{patches.map((p, i) => (
<div key={i} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>
± {p.target_path}
</div>
))}
</div>
)}
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>} {error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button> <button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}> <button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Creating…' : 'Create branch'} {loading ? 'Creating…' : 'Create'}
</button> </button>
</div> </div>
</div> </div>
@@ -125,38 +101,30 @@ function BranchModal({
); );
} }
function SubmissionModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (s: Submission) => void }) { function SubmissionModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: () => void }) {
const [company, setCompany] = useState(''); const [company, setCompany] = useState('');
const [role, setRole] = useState(''); const [role, setRole] = useState('');
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [jd, setJd] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const submit = async () => { const submit = async () => {
if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; } if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; }
setLoading(true); setError(''); setLoading(true); setError('');
try { onDone(await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null, jd.trim() || null)); } try { await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null); onDone(); }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
}; };
return ( return (
<div className="overlay" onClick={onClose}> <div className="overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 480 }}> <div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">New submission from <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 400 }}>{version.branch_name}</span></div> <div className="modal-title">New submission from <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 400 }}>{version.branch_name}</span></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', gap: 8 }}> <input placeholder="Company name" value={company} onChange={e => setCompany(e.target.value)} />
<input placeholder="Company" value={company} onChange={e => setCompany(e.target.value)} autoFocus />
<input placeholder="Role title" value={role} onChange={e => setRole(e.target.value)} /> <input placeholder="Role title" value={role} onChange={e => setRole(e.target.value)} />
</div>
<input placeholder="Job URL (optional)" value={url} onChange={e => setUrl(e.target.value)} /> <input placeholder="Job URL (optional)" value={url} onChange={e => setUrl(e.target.value)} />
<textarea
placeholder="Paste job description (used for AI tailoring)"
value={jd} onChange={e => setJd(e.target.value)}
style={{ height: 100, resize: 'vertical' }}
/>
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>} {error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button> <button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}> <button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Saving…' : 'Create'} {loading ? 'Saving…' : 'Create'}
@@ -168,7 +136,7 @@ function SubmissionModal({ version, onClose, onDone }: { version: Version; onClo
); );
} }
function PublishModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (asset: PublicAsset) => void }) { function PublishModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (url: string) => void }) {
const [slug, setSlug] = useState(''); const [slug, setSlug] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -177,7 +145,7 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
setLoading(true); setError(''); setLoading(true); setError('');
try { try {
const asset = await publishVersion(version.id, null, slug.trim() || null); const asset = await publishVersion(version.id, null, slug.trim() || null);
onDone(asset); onDone(asset.url ?? asset.slug);
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); } } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
}; };
@@ -186,12 +154,12 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
<div className="modal" onClick={e => e.stopPropagation()}> <div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">Publish version</div> <div className="modal-title">Publish version</div>
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}> <p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}>
Freezes an immutable public artifact. Existing shares remain stable. Creates an immutable public artifact. The link stays stable even if you edit further.
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input placeholder="Custom slug (optional)" value={slug} onChange={e => setSlug(e.target.value)} autoFocus /> <input placeholder="Custom slug (optional)" value={slug} onChange={e => setSlug(e.target.value)} />
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>} {error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button> <button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}> <button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Publishing…' : 'Publish'} {loading ? 'Publishing…' : 'Publish'}
@@ -203,275 +171,9 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
); );
} }
// ── content tab with inline editing ────────────────────────────────────────── // ─── main dashboard ───────────────────────────────────────────────────────────
type PendingEdit = { old_value: string; new_value: string };
function ContentTab({
blocks,
pendingEdits,
onEdit,
}: {
blocks: StructuredBlock[];
pendingEdits: Map<string, PendingEdit>;
onEdit: (path: string, oldVal: string, newVal: string) => void;
}) {
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState('');
const startEdit = (b: StructuredBlock) => {
setEditing(b.path);
setDraft(pendingEdits.get(b.path)?.new_value ?? b.text);
};
const saveEdit = (b: StructuredBlock) => {
if (draft.trim() && draft !== b.text) {
onEdit(b.path, b.text, draft.trim());
}
setEditing(null);
};
const cancelEdit = () => setEditing(null);
if (!blocks.length) return (
<div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>No content blocks parsed.</div>
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{blocks.map((b) => {
const pending = pendingEdits.get(b.path);
const isEditing = editing === b.path;
return (
<div key={b.path} style={{
borderBottom: '1px solid var(--border)',
padding: '6px 0',
background: pending ? '#fffbeb' : 'transparent',
}}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-faint)',
flexShrink: 0, width: 100, paddingTop: 3,
}}>
{b.path}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
{isEditing ? (
<>
<textarea
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && e.metaKey) saveEdit(b); if (e.key === 'Escape') cancelEdit(); }}
style={{ width: '100%', minHeight: 60, fontSize: 13, resize: 'vertical', marginBottom: 6 }}
autoFocus
/>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-primary" style={{ fontSize: 11, padding: '3px 8px' }} onClick={() => saveEdit(b)}>
Stage edit
</button>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '3px 8px' }} onClick={cancelEdit}>
Cancel
</button>
</div>
</>
) : (
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<span style={{
fontSize: 13, color: pending ? '#92400e' : 'var(--text)',
lineHeight: 1.5, flex: 1,
}}>
{pending ? pending.new_value : b.text}
</span>
<button
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 7px', flexShrink: 0 }}
onClick={() => startEdit(b)}
>
Edit
</button>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
);
}
// ── submissions tab ───────────────────────────────────────────────────────────
function SubmissionsTab({
submissions, loading, versionId,
onNewSubmission, onRefresh,
}: {
submissions: Submission[];
loading: boolean;
versionId: string;
onNewSubmission: () => void;
onRefresh: () => void;
}) {
const [expanded, setExpanded] = useState<string | null>(null);
const [aiLoading, setAiLoading] = useState<string | null>(null);
const [aiJd, setAiJd] = useState<Record<string, string>>({});
const [suggestions, setSuggestions] = useState<Record<string, Suggestion[]>>({});
const loadAi = async (s: Submission) => {
const jd = aiJd[s.id] ?? s.job_description ?? '';
if (!jd.trim()) return;
setAiLoading(s.id);
try {
const res = await requestAiSuggestions(s.id, jd);
setSuggestions(prev => ({ ...prev, [s.id]: res }));
onRefresh();
} catch { /* ignore */ }
finally { setAiLoading(null); }
};
const toggleSuggestion = async (sub: Submission, sug: Suggestion, accepted: boolean) => {
try {
await updateSuggestion(sub.id, sug.id, accepted);
setSuggestions(prev => ({
...prev,
[sub.id]: (prev[sub.id] ?? sub.suggestions).map(s => s.id === sug.id ? { ...s, accepted } : s),
}));
} catch { /* ignore */ }
};
if (loading) return <div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>Loading</div>;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{submissions.length} submission{submissions.length !== 1 ? 's' : ''}</span>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={onNewSubmission}>+ New submission</button>
</div>
{submissions.length === 0 && (
<div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>
No submissions yet. Create one to track a job application and get AI tailoring suggestions.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{submissions.map(s => {
const isOpen = expanded === s.id;
const sugs = suggestions[s.id] ?? s.suggestions;
return (
<div key={s.id} style={{ border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
{/* header row */}
<div
onClick={() => setExpanded(isOpen ? null : s.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px',
cursor: 'pointer', background: isOpen ? 'var(--hover)' : 'transparent',
}}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" style={{ color: 'var(--text-faint)', transform: isOpen ? 'rotate(90deg)' : 'none', transition: 'transform 0.12s', flexShrink: 0 }}>
<path d="M2 1l4 3-4 3V1z" />
</svg>
<span style={{ fontSize: 13, fontWeight: 500, flex: 1 }}>
{s.company_name} {s.role_title}
</span>
{statusBadge(s.status)}
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{fmt(s.created_at)}</span>
</div>
{/* expanded body */}
{isOpen && (
<div style={{ padding: '12px 14px', borderTop: '1px solid var(--border)', background: 'var(--surface)' }}>
{s.job_url && (
<a href={s.job_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: 'var(--text-muted)', display: 'block', marginBottom: 10 }}>
{s.job_url}
</a>
)}
{/* AI tailoring */}
<div style={{ marginBottom: 12 }}>
<div className="label" style={{ marginBottom: 6 }}>AI tailoring</div>
<textarea
placeholder="Paste or edit job description for AI suggestions…"
value={aiJd[s.id] ?? s.job_description ?? ''}
onChange={e => setAiJd(prev => ({ ...prev, [s.id]: e.target.value }))}
style={{ height: 80, resize: 'vertical', fontSize: 12, marginBottom: 6 }}
/>
<button
className="btn btn-ghost"
style={{ fontSize: 12 }}
disabled={aiLoading === s.id || !(aiJd[s.id] ?? s.job_description)}
onClick={() => loadAi(s)}
>
{aiLoading === s.id ? 'Generating…' : sugs.length > 0 ? 'Regenerate suggestions' : 'Get AI suggestions'}
</button>
</div>
{/* suggestions list */}
{sugs.length > 0 && (
<div>
<div className="label" style={{ marginBottom: 8 }}>Suggestions ({sugs.length})</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{sugs.map(sug => (
<div key={sug.id} style={{
borderLeft: `3px solid ${sug.accepted === true ? '#22c55e' : sug.accepted === false ? '#ef4444' : 'var(--border-strong)'}`,
paddingLeft: 10,
}}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 3 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-muted)' }}>
± {sug.target_path}
</span>
{sug.metadata_json?.confidence !== undefined && (
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>
{Math.round((sug.metadata_json.confidence as number) * 100)}% conf
</span>
)}
</div>
{sug.proposed_text && (
<div style={{ fontSize: 12, color: 'var(--text)', marginBottom: 4, lineHeight: 1.4 }}>
{sug.proposed_text}
</div>
)}
{sug.rationale && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 6, fontStyle: 'italic' }}>
{sug.rationale}
</div>
)}
<div style={{ display: 'flex', gap: 6 }}>
<button
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 8px', color: sug.accepted === true ? '#166534' : 'var(--text-muted)', borderColor: sug.accepted === true ? '#86efac' : 'var(--border)' }}
onClick={() => toggleSuggestion(s, sug, true)}
>
Accept
</button>
<button
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 8px', color: sug.accepted === false ? '#991b1b' : 'var(--text-muted)', borderColor: sug.accepted === false ? '#fca5a5' : 'var(--border)' }}
onClick={() => toggleSuggestion(s, sug, false)}
>
Reject
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
// ── main dashboard ────────────────────────────────────────────────────────────
type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null; type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null;
type Tab = 'content' | 'patches' | 'submissions';
export default function Dashboard() { export default function Dashboard() {
const [docs, setDocs] = useState<Document[]>([]); const [docs, setDocs] = useState<Document[]>([]);
@@ -480,50 +182,25 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [modal, setModal] = useState<Modal>(null); const [modal, setModal] = useState<Modal>(null);
const [publishedAsset, setPublishedAsset] = useState<PublicAsset | null>(null); const [publishedUrl, setPublishedUrl] = useState<string | null>(null);
const [publishedAnalytics, setPublishedAnalytics] = useState<PublicAssetAnalytics | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('content');
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [subsLoading, setSubsLoading] = useState(false);
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
const [sidebarOpen, setSidebarOpen] = useState(false);
const [docHovered, setDocHovered] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetchDocuments() fetchDocuments()
.then(d => { .then(d => { setDocs(d); if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); } })
setDocs(d);
if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); }
})
.catch(e => setError(e.message)) .catch(e => setError(e.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => {
setPendingEdits(new Map());
}, [selectedVersionId]);
useEffect(() => {
if (activeTab !== 'submissions' || !selectedVersionId) return;
setSubsLoading(true);
fetchSubmissions(selectedVersionId)
.then(setSubmissions)
.catch(() => { })
.finally(() => setSubsLoading(false));
}, [activeTab, selectedVersionId]);
const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null; const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null;
const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null; const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null;
const refreshDocs = async () => { const refreshDocs = async () => {
const fresh = await fetchDocuments().catch(() => docs); try {
const fresh = await fetchDocuments();
setDocs(fresh); setDocs(fresh);
return fresh; const doc = fresh.find(d => d.id === selectedDocId) ?? fresh[0] ?? null;
}; if (doc) { setSelectedDocId(doc.id); }
} catch { /* silent */ }
const refreshSubs = () => {
if (!selectedVersionId) return;
fetchSubmissions(selectedVersionId).then(setSubmissions).catch(() => { });
}; };
const onUploadDone = (doc: Document) => { const onUploadDone = (doc: Document) => {
@@ -531,117 +208,28 @@ export default function Dashboard() {
setSelectedDocId(doc.id); setSelectedDocId(doc.id);
setSelectedVersionId(doc.root_version_id ?? null); setSelectedVersionId(doc.root_version_id ?? null);
setModal(null); setModal(null);
setSidebarOpen(false);
}; };
const onBranchDone = async (v: Version) => { const onBranchDone = (v: Version) => {
const fresh = await refreshDocs(); refreshDocs().then(() => setSelectedVersionId(v.id));
const doc = fresh.find(d => d.id === selectedDocId);
if (doc?.versions.find(x => x.id === v.id)) setSelectedVersionId(v.id);
setPendingEdits(new Map());
setModal(null); setModal(null);
}; };
const onSubmissionDone = (s: Submission) => {
setSubmissions(prev => [s, ...prev]);
setModal(null);
setActiveTab('submissions');
};
const stageEdit = (path: string, old_value: string, new_value: string) => {
setPendingEdits(prev => new Map(prev).set(path, { old_value, new_value }));
};
const discardEdits = () => setPendingEdits(new Map());
const handleDeleteDoc = async (docId: string) => {
if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return;
try {
await deleteDocument(docId);
const updated = docs.filter(d => d.id !== docId);
setDocs(updated);
if (selectedDocId === docId) {
setSelectedDocId(updated[0]?.id ?? null);
setSelectedVersionId(updated[0]?.root_version_id ?? null);
}
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Delete failed');
}
};
const handleDeleteVersion = async (versionId: string) => {
const version = selectedDoc?.versions.find(v => v.id === versionId);
const hasChildren = selectedDoc?.versions.some(v => v.parent_version_id === versionId);
const msg = hasChildren
? 'Delete this branch and all its sub-branches? This cannot be undone.'
: 'Delete this branch? This cannot be undone.';
if (!confirm(msg)) return;
try {
await deleteVersion(versionId);
const fresh = await refreshDocs();
if (selectedVersionId === versionId) {
const doc = fresh.find(d => d.id === selectedDocId);
setSelectedVersionId(doc?.root_version_id ?? null);
}
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Delete failed');
}
};
const selectVersion = (id: string) => {
setSelectedVersionId(id);
setActiveTab('content');
setSidebarOpen(false);
};
const pendingCount = pendingEdits.size;
const stagedPatches = [...pendingEdits.entries()].map(([path, { old_value, new_value }]) => ({
target_path: path, operation: 'replace_text', old_value, new_value,
}));
const logout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/login';
};
return ( return (
<div className="dashboard-root"> <div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden', background: 'var(--bg)' }}>
{/* top bar */} {/* top bar */}
<div className="topbar"> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px', height: 44, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
className="btn btn-ghost sidebar-toggle"
style={{ padding: '4px 8px', fontSize: 16 }}
onClick={() => setSidebarOpen(o => !o)}
aria-label="Toggle menu"
>
</button>
<Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}> <Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}>
cvfs Resume Branches
</Link> </Link>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: 12 }} onClick={() => setModal('upload')}> <button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: 12 }} onClick={() => setModal('upload')}>
+ Upload CV + Upload CV
</button> </button>
<button className="btn btn-ghost" style={{ padding: '4px 10px', fontSize: 12 }} onClick={logout}>
Sign out
</button>
</div>
</div> </div>
<div className="dashboard-body"> <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* sidebar overlay on mobile */}
{sidebarOpen && (
<div
className="sidebar-overlay"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* left panel */} {/* left panel */}
<div className={`sidebar${sidebarOpen ? ' sidebar-open' : ''}`}> <div style={{ width: 240, flexShrink: 0, borderRight: '1px solid var(--border)', background: 'var(--surface)', overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
{loading && <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>Loading</div>} {loading && <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>Loading</div>}
{error && <div style={{ padding: 16, fontSize: 13, color: '#dc2626' }}>{error}</div>} {error && <div style={{ padding: 16, fontSize: 13, color: '#dc2626' }}>{error}</div>}
@@ -656,60 +244,30 @@ export default function Dashboard() {
{docs.length > 0 && ( {docs.length > 0 && (
<> <>
{/* document selector */}
<div style={{ padding: '10px 12px 6px' }}> <div style={{ padding: '10px 12px 6px' }}>
<div className="label" style={{ marginBottom: 6 }}>Documents</div> <div className="label" style={{ marginBottom: 6 }}>Documents</div>
{docs.map(d => ( {docs.map(d => (
<div <div
key={d.id} key={d.id}
onMouseEnter={() => setDocHovered(d.id)} onClick={() => { setSelectedDocId(d.id); setSelectedVersionId(d.root_version_id ?? null); }}
onMouseLeave={() => setDocHovered(null)} style={{ padding: '5px 8px', borderRadius: 4, cursor: 'pointer', fontSize: 13, fontWeight: d.id === selectedDocId ? 600 : 400, background: d.id === selectedDocId ? 'var(--selected-bg)' : 'transparent' }}
onClick={() => {
setSelectedDocId(d.id);
setSelectedVersionId(d.root_version_id ?? null);
setActiveTab('content');
setSidebarOpen(false);
}}
style={{
padding: '5px 8px', borderRadius: 4, cursor: 'pointer',
fontSize: 13, fontWeight: d.id === selectedDocId ? 600 : 400,
background: d.id === selectedDocId ? 'var(--selected-bg)' : 'transparent',
display: 'flex', alignItems: 'flex-start', gap: 4,
}}
> >
<div style={{ flex: 1, minWidth: 0 }}> {d.title}
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
{d.versions.length} version{d.versions.length !== 1 ? 's' : ''}
</div>
</div>
{docHovered === d.id && (
<button
onClick={e => { e.stopPropagation(); handleDeleteDoc(d.id); }}
title="Delete CV"
aria-label="Delete CV"
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#dc2626', fontSize: 14, lineHeight: 1, padding: '2px 2px',
flexShrink: 0,
}}
>
×
</button>
)}
</div> </div>
))} ))}
</div> </div>
<hr className="divider" style={{ margin: '4px 0' }} /> <hr className="divider" style={{ margin: '6px 0' }} />
{/* version tree */}
{selectedDoc && ( {selectedDoc && (
<div style={{ padding: '6px 0' }}> <div style={{ padding: '6px 0' }}>
<div className="label" style={{ padding: '0 12px 6px' }}>Branches</div> <div className="label" style={{ padding: '0 12px 6px' }}>Versions</div>
<CVTree <CVTree
versions={selectedDoc.versions} versions={selectedDoc.versions}
selectedVersionId={selectedVersionId} selectedVersionId={selectedVersionId}
onSelect={selectVersion} onSelect={setSelectedVersionId}
onDeleteVersion={handleDeleteVersion}
/> />
</div> </div>
)} )}
@@ -717,34 +275,31 @@ export default function Dashboard() {
)} )}
</div> </div>
{/* main panel */} {/* main content */}
<div className="main-panel"> <div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>
{!selectedVersion && !loading && ( {!selectedVersion && !loading && (
<div style={{ paddingTop: 60, textAlign: 'center', color: 'var(--text-faint)', fontSize: 13 }}> <div style={{ paddingTop: 60, textAlign: 'center', color: 'var(--text-faint)', fontSize: 13 }}>
Select a branch to view details. Select a version to view details.
</div> </div>
)} )}
{selectedVersion && ( {selectedVersion && (
<> <div style={{ maxWidth: 680 }}>
{/* version header */} {/* version header */}
<div style={{ padding: '16px 20px 0', flexShrink: 0 }}> <div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}> <h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 4 }}>
<div style={{ minWidth: 0 }}>
<h2 style={{ fontSize: 17, fontWeight: 600, marginBottom: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedVersion.version_label || selectedVersion.branch_name} {selectedVersion.version_label || selectedVersion.branch_name}
</h2> </h2>
<div style={{ display: 'flex', gap: 14, fontSize: 12, color: 'var(--text-muted)', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-muted)' }}>
{selectedVersion.parent_version_id ? ( {selectedVersion.parent_version_id && (
<span> <span>
branched from{' '} branched from{' '}
<span style={{ fontFamily: 'var(--font-mono)' }}> <span style={{ fontFamily: 'var(--font-mono)' }}>
{selectedDoc?.versions.find(v => v.id === selectedVersion.parent_version_id)?.branch_name ?? '…'} {selectedDoc?.versions.find(v => v.id === selectedVersion.parent_version_id)?.branch_name ?? '…'}
</span> </span>
</span> </span>
) : (
<span className="badge badge-draft" style={{ fontFamily: 'var(--font-mono)' }}>root</span>
)} )}
{!selectedVersion.parent_version_id && <span style={{ fontFamily: 'var(--font-mono)' }}>root</span>}
<span>{fmt(selectedVersion.created_at)}</span> <span>{fmt(selectedVersion.created_at)}</span>
{selectedVersion.patches.length > 0 && ( {selectedVersion.patches.length > 0 && (
<span>{selectedVersion.patches.length} patch{selectedVersion.patches.length !== 1 ? 'es' : ''}</span> <span>{selectedVersion.patches.length} patch{selectedVersion.patches.length !== 1 ? 'es' : ''}</span>
@@ -752,116 +307,53 @@ export default function Dashboard() {
</div> </div>
</div> </div>
{/* action buttons */} {/* action bar */}
<div className="action-buttons"> <div style={{ display: 'flex', gap: 6, marginBottom: 24, flexWrap: 'wrap' }}>
<button className="btn btn-ghost" onClick={() => setModal('branch')}>Branch</button> <button className="btn btn-ghost" onClick={() => setModal('branch')}>New branch</button>
<button className="btn btn-ghost" onClick={() => { setModal('submission'); }}>Submit</button> <button className="btn btn-ghost" onClick={() => setModal('submission')}>New submission</button>
<button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button> <button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>
{selectedVersion.artifact_docx_key && selectedDoc && ( {selectedVersion.artifact_docx_key && selectedDoc && (
<a href={downloadVersionUrl(selectedDoc.id, selectedVersion.id)} download className="btn btn-ghost"> <a
href={downloadVersionUrl(selectedDoc.id, selectedVersion.id)}
download
className="btn btn-ghost"
>
DOCX DOCX
</a> </a>
)} )}
</div> </div>
</div>
{publishedAsset && ( {publishedUrl && (
<div style={{ <div style={{ padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 5, marginBottom: 20, fontSize: 13 }}>
padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0', Published:{' '}
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', flexDirection: 'column', gap: 6, <a href={publishedUrl} target="_blank" rel="noreferrer" style={{ color: '#166534', wordBreak: 'break-all' }}>{publishedUrl}</a>
}}> <button onClick={() => setPublishedUrl(null)} style={{ float: 'right', background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 14 }}>×</button>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> </div>
<span style={{ color: '#166534', fontWeight: 500 }}>Published</span> )}
{publishedAnalytics !== null && (
<span style={{ color: '#166534', fontSize: 11, background: '#dcfce7', padding: '1px 7px', borderRadius: 10 }}> <hr className="divider" style={{ marginBottom: 24 }} />
{publishedAnalytics.view_count} view{publishedAnalytics.view_count !== 1 ? 's' : ''}
{/* structured blocks */}
{(selectedVersion.structured_blocks?.length ?? 0) > 0 && (
<Section title={`Content (${selectedVersion.structured_blocks!.length} blocks)`}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginBottom: 24 }}>
{selectedVersion.structured_blocks!.map((b, i) => (
<div key={i} style={{ display: 'flex', gap: 12, padding: '4px 0', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-faint)', flexShrink: 0, width: 110, paddingTop: 1 }}>{b.path}</span>
<span style={{ fontSize: 13, color: 'var(--text)', lineHeight: 1.5, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{b.text}
</span> </span>
)}
<button
onClick={() => fetchPublicAssetAnalytics(publishedAsset.slug).then(setPublishedAnalytics).catch(() => null)}
style={{ background: 'none', border: '1px solid #bbf7d0', cursor: 'pointer', color: '#15803d', fontSize: 11, padding: '1px 6px', borderRadius: 4 }}
>
stats
</button>
<button onClick={() => { setPublishedAsset(null); setPublishedAnalytics(null); }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 16, lineHeight: 1, marginLeft: 'auto' }}>×</button>
</div> </div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<a href={publishedAsset.url ?? '#'} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
Share link
</a>
<span style={{ color: '#bbf7d0' }}>|</span>
<a href={getPublicPdfUrl(publishedAsset.slug)} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
View PDF
</a>
</div>
</div>
)}
{/* staged edits bar */}
{pendingCount > 0 && (
<div style={{
padding: '8px 12px', background: '#fffbeb', border: '1px solid #fde68a',
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap',
}}>
<span style={{ color: '#92400e', flex: 1 }}>
{pendingCount} staged edit{pendingCount !== 1 ? 's' : ''}
</span>
<button
className="btn btn-primary"
style={{ fontSize: 12, padding: '3px 10px', background: '#92400e', borderColor: '#92400e' }}
onClick={() => setModal('branch')}
>
Save as branch
</button>
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={discardEdits}>
Discard
</button>
</div>
)}
{/* tabs */}
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', overflowX: 'auto' }}>
{(['content', 'patches', 'submissions'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setActiveTab(t)}
style={{
padding: '6px 14px', fontSize: 13, background: 'none', border: 'none',
cursor: 'pointer', color: activeTab === t ? 'var(--text)' : 'var(--text-muted)',
borderBottom: activeTab === t ? '2px solid var(--text)' : '2px solid transparent',
fontWeight: activeTab === t ? 500 : 400,
marginBottom: -1, transition: 'color 0.1s', whiteSpace: 'nowrap',
}}
>
{t === 'patches' ? `Patches (${selectedVersion.patches.length})` : t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))} ))}
</div> </div>
</div> </Section>
)}
{/* tab content */} {/* patches */}
<div style={{ padding: '16px 20px', flex: 1, overflow: 'auto' }}> <Section title={`Patches (${selectedVersion.patches.length} changes from parent)`}>
{activeTab === 'content' && (
<ContentTab
blocks={selectedVersion.structured_blocks ?? []}
pendingEdits={pendingEdits}
onEdit={stageEdit}
/>
)}
{activeTab === 'patches' && (
<DiffViewer patches={selectedVersion.patches} /> <DiffViewer patches={selectedVersion.patches} />
)} </Section>
{activeTab === 'submissions' && (
<SubmissionsTab
submissions={submissions}
loading={subsLoading}
versionId={selectedVersionId!}
onNewSubmission={() => setModal('submission')}
onRefresh={refreshSubs}
/>
)}
</div> </div>
</>
)} )}
</div> </div>
</div> </div>
@@ -871,21 +363,16 @@ export default function Dashboard() {
<UploadModal onClose={() => setModal(null)} onDone={onUploadDone} /> <UploadModal onClose={() => setModal(null)} onDone={onUploadDone} />
)} )}
{modal === 'branch' && selectedVersion && ( {modal === 'branch' && selectedVersion && (
<BranchModal <BranchModal version={selectedVersion} onClose={() => setModal(null)} onDone={onBranchDone} />
version={selectedVersion}
initialPatches={stagedPatches}
onClose={() => setModal(null)}
onDone={onBranchDone}
/>
)} )}
{modal === 'submission' && selectedVersion && ( {modal === 'submission' && selectedVersion && (
<SubmissionModal version={selectedVersion} onClose={() => setModal(null)} onDone={onSubmissionDone} /> <SubmissionModal version={selectedVersion} onClose={() => setModal(null)} onDone={() => { setModal(null); }} />
)} )}
{modal === 'publish' && selectedVersion && ( {modal === 'publish' && selectedVersion && (
<PublishModal <PublishModal
version={selectedVersion} version={selectedVersion}
onClose={() => setModal(null)} onClose={() => setModal(null)}
onDone={asset => { setPublishedAsset(asset); setPublishedAnalytics(null); setModal(null); }} onDone={url => { setPublishedUrl(url); setModal(null); }}
/> />
)} )}
</div> </div>

View File

@@ -150,111 +150,3 @@ input:focus, textarea:focus, select:focus {
font-weight: 600; font-weight: 600;
margin-bottom: 16px; margin-bottom: 16px;
} }
/* ── dashboard layout ────────────────────────────────────────────────────────── */
.dashboard-root {
display: flex;
flex-direction: column;
height: 100dvh;
overflow: hidden;
background: var(--bg);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 44px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-toggle { display: none; }
.dashboard-body {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
.sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: var(--surface);
overflow: auto;
display: flex;
flex-direction: column;
}
.sidebar-overlay { display: none; }
.main-panel {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
}
.action-buttons {
display: flex;
gap: 6px;
flex-wrap: wrap;
flex-shrink: 0;
}
/* ── mobile breakpoint ───────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.sidebar-toggle { display: inline-flex; }
.sidebar {
position: fixed;
top: 44px;
left: 0;
bottom: 0;
z-index: 40;
transform: translateX(-100%);
transition: transform 0.2s ease;
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
}
.sidebar.sidebar-open {
transform: translateX(0);
}
.sidebar-overlay {
display: block;
position: fixed;
inset: 44px 0 0 0;
background: rgba(0, 0, 0, 0.25);
z-index: 39;
}
.modal {
max-width: 100% !important;
border-radius: 12px 12px 0 0;
position: fixed;
bottom: 0;
left: 0;
right: 0;
margin: 0;
border-bottom: none;
}
.overlay {
align-items: flex-end;
}
.action-buttons {
gap: 4px;
}
.action-buttons .btn {
padding: 4px 8px;
font-size: 11px;
}
}

View File

@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "cvfs", title: "Resume Branches",
description: "CV File System — manage your resume like code: branch, version, and tailor for different roles while preserving ATS formatting", description: "Manage your CV like code: branch, version, and tailor for different roles while preserving ATS formatting",
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {

View File

@@ -1,3 +1,46 @@
// Auth is handled via /api/auth/* route handlers. 'use server'
// This file retained for compatibility; Supabase sign-in is no longer used.
export {}; import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export async function login(formData: FormData) {
const supabase = await createClient()
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signInWithPassword(data)
if (error) {
redirect('/error')
}
revalidatePath('/', 'layout')
redirect('/')
}
export async function signup(formData: FormData) {
const supabase = await createClient()
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signUp(data)
if (error) {
redirect('/error')
}
revalidatePath('/', 'layout')
redirect('/')
}

View File

@@ -1,144 +1,14 @@
'use client'; import { login, signup } from './actions'
import { useState } from 'react';
import { useRouter } from 'next/navigation';
function authentikBase(url?: string | null) {
if (!url) return null;
try {
const parsed = new URL(url);
return parsed.origin.replace(/\/$/, '');
} catch {
return null;
}
}
function authentikUrl() {
const baseHost = authentikBase(process.env.NEXT_PUBLIC_AUTHENTIK_ISSUER);
const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID;
const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : '');
if (!baseHost || !clientId) return null;
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: `${base}/api/auth/callback`,
scope: 'openid email profile',
});
return `${baseHost}/application/o/authorize/?${params}`;
}
export default function LoginPage() { export default function LoginPage() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const oidcUrl = typeof window !== 'undefined' ? authentikUrl() : null;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) return;
setLoading(true); setError('');
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (res.ok) {
router.push('/dashboard');
} else {
const j = await res.json().catch(() => ({}));
setError(j.error ?? 'Login failed');
setLoading(false);
}
};
return ( return (
<div style={{ <form>
minHeight: '100vh', display: 'flex', alignItems: 'center', <label htmlFor="email">Email:</label>
justifyContent: 'center', background: 'var(--bg)', <input id="email" name="email" type="email" required />
}}> <label htmlFor="password">Password:</label>
<div style={{ width: '100%', maxWidth: 360, padding: '0 20px' }}> <input id="password" name="password" type="password" required />
{/* brand */} <button formAction={login}>Log in</button>
<div style={{ textAlign: 'center', marginBottom: 32 }}> <button formAction={signup}>Sign up</button>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>
cvfs
</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Sign in to your account
</div>
</div>
{/* form card */}
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, padding: '24px 24px 20px',
}}>
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 5, color: 'var(--text-muted)' }}>
Username
</label>
<input
type="text" autoComplete="username" autoFocus
value={username} onChange={e => setUsername(e.target.value)}
placeholder="admin"
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 5, color: 'var(--text-muted)' }}>
Password
</label>
<input
type="password" autoComplete="current-password"
value={password} onChange={e => setPassword(e.target.value)}
placeholder="••••••••"
/>
</div>
{error && (
<div style={{ fontSize: 12, color: '#dc2626', padding: '6px 10px', background: '#fef2f2', borderRadius: 4 }}>
{error}
</div>
)}
<button
type="submit" className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center', marginTop: 4 }}
disabled={loading || !username || !password}
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form> </form>
)
{oidcUrl && (
<>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
margin: '16px 0', color: 'var(--text-faint)', fontSize: 12,
}}>
<hr className="divider" style={{ flex: 1 }} />
<span>or</span>
<hr className="divider" style={{ flex: 1 }} />
</div>
<a href={oidcUrl} style={{ textDecoration: 'none', display: 'block' }}>
<button className="btn btn-ghost" style={{ width: '100%', justifyContent: 'center' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ flexShrink: 0 }}>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
Sign in with Authentik
</button>
</a>
</>
)}
</div>
<p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-faint)', marginTop: 20 }}>
cvfs CV File System
</p>
</div>
</div>
);
} }

View File

@@ -10,10 +10,10 @@ export default function Home() {
<section style={{ padding: "80px 24px 64px", textAlign: "center", borderBottom: "1px solid var(--border)" }}> <section style={{ padding: "80px 24px 64px", textAlign: "center", borderBottom: "1px solid var(--border)" }}>
<div style={{ maxWidth: 560, margin: "0 auto" }}> <div style={{ maxWidth: 560, margin: "0 auto" }}>
<p style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-faint)", marginBottom: 16 }}> <p style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-faint)", marginBottom: 16 }}>
cvfs Resume Branches
</p> </p>
<h1 style={{ fontSize: 40, fontWeight: 700, lineHeight: 1.1, marginBottom: 16, letterSpacing: "-0.02em" }}> <h1 style={{ fontSize: 40, fontWeight: 700, lineHeight: 1.1, marginBottom: 16, letterSpacing: "-0.02em" }}>
CV File System Git for CVs
</h1> </h1>
<p style={{ fontSize: 16, color: "var(--text-muted)", lineHeight: 1.6, marginBottom: 32 }}> <p style={{ fontSize: 16, color: "var(--text-muted)", lineHeight: 1.6, marginBottom: 32 }}>
Upload your ATS-safe DOCX. Branch it by role. Tailor per company without Upload your ATS-safe DOCX. Branch it by role. Tailor per company without

View File

@@ -4,9 +4,9 @@ export default function Footer() {
<div className="max-w-7xl mx-auto px-6 py-12"> <div className="max-w-7xl mx-auto px-6 py-12">
<div className="grid md:grid-cols-4 gap-8"> <div className="grid md:grid-cols-4 gap-8">
<div className="col-span-1"> <div className="col-span-1">
<h3 className="text-xl font-bold text-white mb-4">cvfs</h3> <h3 className="text-xl font-bold text-white mb-4">Resume Branches</h3>
<p className="text-sm mb-4"> <p className="text-sm mb-4">
CV File System. Manage your resume like code with version control, Git for CVs. Manage your resume like code with version control,
branching, and smart AI-assisted tailoring. branching, and smart AI-assisted tailoring.
</p> </p>
</div> </div>
@@ -40,7 +40,7 @@ export default function Footer() {
</div> </div>
<div className="border-t border-gray-800 mt-8 pt-8 flex flex-col md:flex-row items-center justify-between"> <div className="border-t border-gray-800 mt-8 pt-8 flex flex-col md:flex-row items-center justify-between">
<p className="text-sm">© 2024 cvfs. All rights reserved.</p> <p className="text-sm">© 2024 Resume Branches. All rights reserved.</p>
<div className="flex items-center space-x-6 mt-4 md:mt-0"> <div className="flex items-center space-x-6 mt-4 md:mt-0">
<a href="#" className="text-gray-400 hover:text-white transition-colors"> <a href="#" className="text-gray-400 hover:text-white transition-colors">
<span className="sr-only">Twitter</span> <span className="sr-only">Twitter</span>

View File

@@ -4,7 +4,7 @@ export default function Header() {
return ( return (
<header style={{ borderBottom: "1px solid var(--border)", padding: "0 24px", height: 52, display: "flex", alignItems: "center", justifyContent: "space-between", background: "#fff", position: "sticky", top: 0, zIndex: 40 }}> <header style={{ borderBottom: "1px solid var(--border)", padding: "0 24px", height: 52, display: "flex", alignItems: "center", justifyContent: "space-between", background: "#fff", position: "sticky", top: 0, zIndex: 40 }}>
<Link href="/" style={{ fontSize: 14, fontWeight: 600, color: "var(--text)", textDecoration: "none" }}> <Link href="/" style={{ fontSize: 14, fontWeight: 600, color: "var(--text)", textDecoration: "none" }}>
cvfs Resume Branches
</Link> </Link>
<nav style={{ display: "flex", alignItems: "center", gap: 24 }}> <nav style={{ display: "flex", alignItems: "center", gap: 24 }}>
{[["Dashboard", "/dashboard"], ["Docs", "/docs"]].map(([label, href]) => ( {[["Dashboard", "/dashboard"], ["Docs", "/docs"]].map(([label, href]) => (

View File

@@ -15,136 +15,57 @@ function buildTree(versions: Version[]): TreeNode | null {
return root; return root;
} }
const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2']; function Node({ node, depth, selectedId, onSelect }: {
node: TreeNode; depth: number; selectedId: string | null; onSelect: (id: string) => void;
function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
node: TreeNode; depth: number; selectedId: string | null;
onSelect: (id: string) => void;
onDelete?: (id: string) => void;
colorIndex?: number;
}) { }) {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [hovered, setHovered] = useState(false);
const v = node.version; const v = node.version;
const isRoot = !v.parent_version_id;
const isSelected = v.id === selectedId; const isSelected = v.id === selectedId;
const isLeaf = node.children.length === 0; const hasChildren = node.children.length > 0;
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
return ( return (
<div style={{ position: 'relative' }}> <div>
{depth > 0 && (
<div style={{
position: 'absolute', left: -1, top: 15,
width: 14, height: 1, background: 'var(--border-strong)', zIndex: 1,
}} />
)}
<div <div
onClick={() => onSelect(v.id)} onClick={() => onSelect(v.id)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 4,
paddingLeft: depth > 0 ? 18 : 8, paddingRight: 4, paddingLeft: 12 + depth * 16, paddingRight: 8,
height: 30, cursor: 'pointer', borderRadius: 4, userSelect: 'none', height: 30, cursor: 'pointer',
background: isSelected ? 'var(--selected-bg)' : hovered ? 'var(--hover)' : 'transparent', background: isSelected ? 'var(--selected-bg)' : 'transparent',
borderLeft: isSelected && depth === 0 ? '2px solid var(--selected-border)' : '2px solid transparent', borderLeft: isSelected ? '2px solid var(--selected-border)' : '2px solid transparent',
transition: 'background 0.1s',
}} }}
onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'var(--hover)'; }}
onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
> >
<button <button
onClick={e => { e.stopPropagation(); setOpen(o => !o); }} onClick={e => { e.stopPropagation(); setOpen(o => !o); }}
style={{ style={{ width: 14, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: hasChildren ? 1 : 0, cursor: 'pointer', background: 'none', border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0 }}
width: 12, height: 12, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0,
opacity: node.children.length > 0 ? 1 : 0, pointerEvents: node.children.length > 0 ? 'auto' : 'none',
}}
> >
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor" <svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" style={{ transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}>
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.12s' }}>
<path d="M2 1l4 3-4 3V1z" /> <path d="M2 1l4 3-4 3V1z" />
</svg> </svg>
</button> </button>
<span style={{ <span style={{ flex: 1, fontSize: 13, fontWeight: !v.parent_version_id ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text)' }}>
width: isRoot ? 8 : 7, height: isRoot ? 8 : 7,
borderRadius: '50%', flexShrink: 0,
background: isRoot || isSelected ? dotColor : 'transparent',
border: `2px solid ${dotColor}`,
transition: 'background 0.1s',
}} />
<span style={{
flex: 1, fontSize: 13,
fontWeight: isRoot ? 600 : 400,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
color: isSelected ? 'var(--text)' : isRoot ? 'var(--text)' : 'var(--text-muted)',
}}>
{v.version_label || v.branch_name} {v.version_label || v.branch_name}
</span> </span>
{v.patches.length > 0 && (
<span style={{
fontSize: 10, color: 'var(--text-faint)',
background: 'var(--hover)', padding: '1px 4px',
borderRadius: 3, flexShrink: 0,
}}>
{v.patches.length}
</span>
)}
{!isRoot && onDelete && hovered && (
<button
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
title="Delete branch"
aria-label="Delete branch"
style={{
width: 18, height: 18, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: '#dc2626', flexShrink: 0,
borderRadius: 3, fontSize: 14, lineHeight: 1,
}}
>
×
</button>
)}
</div> </div>
{open && node.children.map(child => (
{open && node.children.length > 0 && ( <Node key={child.version.id} node={child} depth={depth + 1} selectedId={selectedId} onSelect={onSelect} />
<div style={{
marginLeft: depth > 0 ? 22 : 14,
borderLeft: `1px solid var(--border)`,
paddingLeft: 0,
}}>
{node.children.map((child, i) => (
<Node
key={child.version.id}
node={child}
depth={depth + 1}
selectedId={selectedId}
onSelect={onSelect}
onDelete={onDelete}
colorIndex={depth === 0 ? i + 1 : colorIndex}
/>
))} ))}
</div> </div>
)}
</div>
); );
} }
export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion }: { export default function CVTree({ versions, selectedVersionId, onSelect }: {
versions: Version[]; selectedVersionId: string | null; versions: Version[]; selectedVersionId: string | null; onSelect: (id: string) => void;
onSelect: (id: string) => void;
onDeleteVersion?: (id: string) => void;
}) { }) {
const tree = buildTree(versions); const tree = buildTree(versions);
if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>; if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>;
return ( return (
<div style={{ paddingBottom: 8 }}> <div style={{ paddingBottom: 8 }}>
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} /> <Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} />
</div> </div>
); );
} }

View File

@@ -1,3 +1,5 @@
// Empty base: all API calls go to /api/* which Next.js rewrites to the backend.
// The actual backend URL is set via API_BASE_URL env var in next.config.ts (server-side, runtime).
const API = ""; const API = "";
export type StructuredBlock = { export type StructuredBlock = {
@@ -40,16 +42,6 @@ export type Document = {
updated_at: string; updated_at: string;
}; };
export type Suggestion = {
id: string;
target_path: string;
operation: string;
proposed_text?: string | null;
rationale?: string | null;
accepted?: boolean | null;
metadata_json?: { keywords?: string[]; confidence?: number } | null;
};
export type Submission = { export type Submission = {
id: string; id: string;
version_id: string; version_id: string;
@@ -58,7 +50,6 @@ export type Submission = {
job_url?: string | null; job_url?: string | null;
job_description?: string | null; job_description?: string | null;
status: string; status: string;
suggestions: Suggestion[];
created_at: string; created_at: string;
}; };
@@ -73,23 +64,10 @@ export type PublicAsset = {
created_at: string; created_at: string;
}; };
export type PublicAssetAnalytics = {
slug: string;
view_count: number;
last_viewed_at?: string | null;
};
// reads OIDC bearer token from client-readable cookie (set by /api/auth/callback)
function getAuthHeader(): Record<string, string> {
if (typeof document === 'undefined') return {};
const token = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('oidc_token_pub='))?.split('=')[1];
return token ? { authorization: `Bearer ${decodeURIComponent(token)}` } : {};
}
async function req<T>(path: string, init?: RequestInit): Promise<T> { async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, { const res = await fetch(`${API}${path}`, {
...init, ...init,
headers: { accept: 'application/json', ...getAuthHeader(), ...init?.headers }, headers: { accept: "application/json", ...init?.headers },
}); });
if (!res.ok) { if (!res.ok) {
const detail = await res.text().catch(() => res.statusText); const detail = await res.text().catch(() => res.statusText);
@@ -99,17 +77,17 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
} }
export const fetchDocuments = (): Promise<Document[]> => export const fetchDocuments = (): Promise<Document[]> =>
req<{ items: Document[] }>('/api/v1/documents', { cache: 'no-store' }).then(r => r.items); req<{ items: Document[] }>("/api/v1/documents", { cache: "no-store" }).then(r => r.items);
export const fetchDocument = (id: string): Promise<Document> => export const fetchDocument = (id: string): Promise<Document> =>
req<Document>(`/api/v1/documents/${id}`, { cache: 'no-store' }); req<Document>(`/api/v1/documents/${id}`, { cache: "no-store" });
export async function uploadDocument(title: string, description: string | null, file: File): Promise<Document> { export async function uploadDocument(title: string, description: string | null, file: File): Promise<Document> {
const form = new FormData(); const form = new FormData();
form.append('title', title); form.append("title", title);
if (description) form.append('description', description); if (description) form.append("description", description);
form.append('file', file); form.append("file", file);
return req<Document>('/api/v1/documents', { method: 'POST', body: form }); return req<Document>("/api/v1/documents", { method: "POST", body: form });
} }
export const downloadVersionUrl = (documentId: string, versionId: string): string => export const downloadVersionUrl = (documentId: string, versionId: string): string =>
@@ -121,9 +99,9 @@ export async function createBranch(
versionLabel?: string | null, versionLabel?: string | null,
patches: Record<string, unknown>[] = [], patches: Record<string, unknown>[] = [],
): Promise<Version> { ): Promise<Version> {
return req<Version>('/api/v1/versions/branches', { return req<Version>("/api/v1/versions/branches", {
method: 'POST', method: "POST",
headers: { 'content-type': 'application/json' }, headers: { "content-type": "application/json" },
body: JSON.stringify({ parent_version_id: parentVersionId, branch_name: branchName, version_label: versionLabel ?? null, patches }), body: JSON.stringify({ parent_version_id: parentVersionId, branch_name: branchName, version_label: versionLabel ?? null, patches }),
}); });
} }
@@ -135,79 +113,22 @@ export async function createSubmission(
jobUrl?: string | null, jobUrl?: string | null,
jobDescription?: string | null, jobDescription?: string | null,
): Promise<Submission> { ): Promise<Submission> {
return req<Submission>('/api/v1/submissions', { return req<Submission>("/api/v1/submissions", {
method: 'POST', method: "POST",
headers: { 'content-type': 'application/json' }, headers: { "content-type": "application/json" },
body: JSON.stringify({ version_id: versionId, company_name: companyName, role_title: roleTitle, job_url: jobUrl ?? null, job_description: jobDescription ?? null }), body: JSON.stringify({ version_id: versionId, company_name: companyName, role_title: roleTitle, job_url: jobUrl ?? null, job_description: jobDescription ?? null }),
}); });
} }
export const fetchSubmissions = (versionId: string): Promise<Submission[]> =>
req<Submission[]>(`/api/v1/submissions?version_id=${versionId}`);
export const fetchSubmission = (id: string): Promise<Submission> =>
req<Submission>(`/api/v1/submissions/${id}`);
export async function requestAiSuggestions(
submissionId: string,
jobDescription: string,
focusKeywords: string[] = [],
): Promise<Suggestion[]> {
return req<Suggestion[]>(`/api/v1/submissions/${submissionId}/ai`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ job_description: jobDescription, focus_keywords: focusKeywords }),
});
}
export async function updateSuggestion(
submissionId: string,
suggestionId: string,
accepted: boolean,
): Promise<Suggestion> {
return req<Suggestion>(`/api/v1/submissions/${submissionId}/suggestions/${suggestionId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ accepted }),
});
}
export async function publishVersion( export async function publishVersion(
versionId?: string | null, versionId?: string | null,
submissionId?: string | null, submissionId?: string | null,
slug?: string | null, slug?: string | null,
): Promise<PublicAsset> { ): Promise<PublicAsset> {
return req<PublicAsset>('/api/v1/public/publish', { return req<PublicAsset>("/api/v1/public/publish", {
method: 'POST', method: "POST",
headers: { 'content-type': 'application/json' }, headers: { "content-type": "application/json" },
body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }), body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }),
}); });
} }
export const getPublicPdfUrl = (slug: string): string =>
`${API}/api/v1/public/${encodeURIComponent(slug)}/pdf`;
export const fetchPublicAssetAnalytics = (slug: string): Promise<PublicAssetAnalytics> =>
req<PublicAssetAnalytics>(`/api/v1/public/${encodeURIComponent(slug)}/analytics`);
export async function deleteDocument(documentId: string): Promise<void> {
const res = await fetch(`${API}/api/v1/documents/${documentId}`, {
method: 'DELETE',
headers: { accept: 'application/json', ...getAuthHeader() },
});
if (!res.ok) {
const detail = await res.text().catch(() => res.statusText);
throw new Error(detail || `HTTP ${res.status}`);
}
}
export async function deleteVersion(versionId: string): Promise<void> {
const res = await fetch(`${API}/api/v1/versions/${versionId}`, {
method: 'DELETE',
headers: { accept: 'application/json', ...getAuthHeader() },
});
if (!res.ok) {
const detail = await res.text().catch(() => res.statusText);
throw new Error(detail || `HTTP ${res.status}`);
}
}

View File

@@ -1,29 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
async function verifySession(token: string): Promise<boolean> {
const lastDot = token.lastIndexOf('.');
if (lastDot === -1) return false;
const payload = token.slice(0, lastDot);
const sigHex = token.slice(lastDot + 1);
try {
const key = await globalThis.crypto.subtle.importKey(
'raw', new TextEncoder().encode(SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
);
const sigBytes = new Uint8Array((sigHex.match(/.{1,2}/g) ?? []).map(b => parseInt(b, 16)));
return await globalThis.crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload));
} catch { return false; }
}
export async function middleware(req: NextRequest) {
if (!req.nextUrl.pathname.startsWith('/dashboard')) return NextResponse.next();
const session = req.cookies.get('session')?.value;
const oidc = req.cookies.get('oidc_token')?.value;
if (oidc) return NextResponse.next();
if (session && await verifySession(session)) return NextResponse.next();
return NextResponse.redirect(new URL('/login', req.url));
}
export const config = { matcher: ['/dashboard/:path*'] };

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import time import time
from functools import cached_property
from typing import Any from typing import Any
import httpx import httpx
@@ -20,16 +21,6 @@ class TokenValidationError(Exception):
pass pass
def _normalize_issuer(value: str | None) -> tuple[str | None, str | None]:
if not value:
return None, None
raw = value.strip().rstrip("/")
normalized = raw.replace("/application/o/authorize/", "/application/o/")
normalized = normalized.replace("/application/o/authorize", "/application/o")
normalized = normalized.rstrip("/")
return raw, normalized if normalized != raw else raw
class OidcTokenValidator: class OidcTokenValidator:
def __init__( def __init__(
self, self,
@@ -39,16 +30,12 @@ class OidcTokenValidator:
jwks_url: str | None = None, jwks_url: str | None = None,
disable: bool = False, disable: bool = False,
) -> None: ) -> None:
raw_issuer, discovery_issuer = _normalize_issuer(issuer) self.issuer = issuer
self.issuer = raw_issuer
self.audience = audience self.audience = audience
self.jwks_url = jwks_url self.jwks_url = jwks_url or (
self.discovery_url = ( f"{issuer.rstrip('/')}/.well-known/jwks.json" if issuer else None
f"{(discovery_issuer or raw_issuer).rstrip('/')}/.well-known/openid-configuration"
if (discovery_issuer or raw_issuer)
else None
) )
self.disable = disable or not raw_issuer self.disable = disable or not issuer
self._jwks: dict[str, Any] | None = None self._jwks: dict[str, Any] | None = None
self._jwks_expiry: float = 0 self._jwks_expiry: float = 0
@@ -58,22 +45,17 @@ class OidcTokenValidator:
sub="dev-user", email="dev@example.com", name="Developer" sub="dev-user", email="dev@example.com", name="Developer"
) )
header = jwt.get_unverified_header(token) header = jwt.get_unverified_header(token)
alg = header.get("alg") or "RS256" key = await self._get_key(header.get("kid"))
jwks = await self._get_jwks() if not key:
if not jwks: raise TokenValidationError("Unable to resolve signing key")
raise TokenValidationError("Unable to resolve signing keys")
try: try:
claims = jwt.decode( claims = jwt.decode(
token, token,
jwks, key,
algorithms=[alg], algorithms=[key.get("alg", "RS256")],
options={"verify_aud": False, "verify_iss": False}, audience=self.audience,
issuer=self.issuer,
) )
iss = claims.get("iss")
if self.issuer and iss not in (self.issuer, self.issuer + "/"):
# fallback: check if it matches discovery host
if not (iss and iss.startswith(self.issuer.split("/application/")[0])):
raise TokenValidationError(f"Invalid issuer: {iss}")
except JWTError as exc: except JWTError as exc:
raise TokenValidationError(str(exc)) from exc raise TokenValidationError(str(exc)) from exc
roles = claims.get("roles") or claims.get("app_metadata", {}).get("roles") or [] roles = claims.get("roles") or claims.get("app_metadata", {}).get("roles") or []
@@ -87,19 +69,7 @@ class OidcTokenValidator:
roles=roles, roles=roles,
) )
async def _ensure_jwks_url(self) -> None: async def _get_key(self, kid: str | None) -> dict[str, Any] | None:
if self.jwks_url or not self.discovery_url:
return
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(self.discovery_url)
response.raise_for_status()
data = response.json()
jwks_uri = data.get("jwks_uri")
if isinstance(jwks_uri, str):
self.jwks_url = jwks_uri
async def _get_jwks(self) -> dict[str, Any] | None:
await self._ensure_jwks_url()
if not self.jwks_url: if not self.jwks_url:
return None return None
if not self._jwks or time.time() > self._jwks_expiry: if not self._jwks or time.time() > self._jwks_expiry:
@@ -108,7 +78,12 @@ class OidcTokenValidator:
response.raise_for_status() response.raise_for_status()
self._jwks = response.json() self._jwks = response.json()
self._jwks_expiry = time.time() + 3600 self._jwks_expiry = time.time() + 3600
return self._jwks keys = self._jwks.get("keys", []) if isinstance(self._jwks, dict) else []
if kid:
for key in keys:
if key.get("kid") == kid:
return key
return keys[0] if keys else None
def build_validator( def build_validator(

View File

@@ -8,8 +8,6 @@ from .schema import (
from .parser import parse_docx_bytes, summarize_keywords from .parser import parse_docx_bytes, summarize_keywords
from .patcher import apply_patchset from .patcher import apply_patchset
from .ats_guard import validate_patchset from .ats_guard import validate_patchset
from .docx_export import generate_patched_docx
from .pdf_export import docx_bytes_to_pdf
__all__ = [ __all__ = [
"StructuredBlock", "StructuredBlock",
@@ -21,6 +19,4 @@ __all__ = [
"summarize_keywords", "summarize_keywords",
"apply_patchset", "apply_patchset",
"validate_patchset", "validate_patchset",
"generate_patched_docx",
"docx_bytes_to_pdf",
] ]

View File

@@ -1,76 +0,0 @@
from __future__ import annotations
from collections import defaultdict
from io import BytesIO
from docx import Document
from .parser import _detect_block_type
def _path_to_para_map(doc: Document) -> dict[str, int]:
counters: defaultdict[str, int] = defaultdict(int)
result: dict[str, int] = {}
for idx, para in enumerate(doc.paragraphs):
if not para.text.strip():
continue
block_type = _detect_block_type(getattr(para.style, "name", None), para)
counters[block_type] += 1
result[f"{block_type}[{counters[block_type]}]"] = idx
return result
def _replace_para_text(para, new_text: str) -> None:
"""Replace paragraph text preserving the first run's character formatting."""
if not para.runs:
para.add_run(new_text)
return
first = para.runs[0]
for run in para.runs[1:]:
run.text = ""
first.text = new_text
def _remove_paragraph(paragraph) -> None:
p = paragraph._element
p.getparent().remove(p)
def generate_patched_docx(
original_bytes: bytes, structured_blocks: list[dict]
) -> bytes:
"""Return DOCX bytes with text patches from structured_blocks applied.
Compares each block's text against the original paragraph and replaces it
when different. Blocks absent from structured_blocks are removed.
"""
if not structured_blocks:
return original_bytes
doc = Document(BytesIO(original_bytes))
path_map = _path_to_para_map(doc)
original_paths = set(path_map.keys())
patched = {b["path"]: b["text"] for b in structured_blocks}
patched_paths = set(patched.keys())
# Apply text replacements first (indices stay stable)
for path, new_text in patched.items():
idx = path_map.get(path)
if idx is None:
continue
para = doc.paragraphs[idx]
if para.text.strip() != new_text:
_replace_para_text(para, new_text)
# Remove blocks no longer present; process in reverse index order
removed = sorted(
[path_map[p] for p in (original_paths - patched_paths) if p in path_map],
reverse=True,
)
for idx in removed:
_remove_paragraph(doc.paragraphs[idx])
out = BytesIO()
doc.save(out)
return out.getvalue()

View File

@@ -1,79 +0,0 @@
from __future__ import annotations
from io import BytesIO
from docx import Document
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate, Spacer
from .parser import _detect_block_type
_STYLES = getSampleStyleSheet()
_STYLE_MAP: dict[str, ParagraphStyle] = {
"heading": ParagraphStyle(
"CVHeading", parent=_STYLES["Normal"],
fontSize=15, leading=20, spaceBefore=10, spaceAfter=4,
textColor=colors.HexColor("#111111"), fontName="Helvetica-Bold",
),
"meta": ParagraphStyle(
"CVMeta", parent=_STYLES["Normal"],
fontSize=9, leading=13, spaceAfter=2, textColor=colors.HexColor("#555555"),
),
"summary": ParagraphStyle(
"CVSummary", parent=_STYLES["Normal"],
fontSize=10, leading=14, spaceAfter=6, textColor=colors.HexColor("#333333"),
),
"bullet": ParagraphStyle(
"CVBullet", parent=_STYLES["Normal"],
fontSize=10, leading=14, spaceAfter=3, leftIndent=14,
bulletIndent=0, textColor=colors.HexColor("#222222"),
),
"skills": ParagraphStyle(
"CVSkills", parent=_STYLES["Normal"],
fontSize=10, leading=14, spaceAfter=3, textColor=colors.HexColor("#222222"),
),
"text": ParagraphStyle(
"CVText", parent=_STYLES["Normal"],
fontSize=10, leading=14, spaceAfter=4, textColor=colors.HexColor("#222222"),
),
}
def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes:
doc = Document(BytesIO(docx_bytes))
buf = BytesIO()
pdf = SimpleDocTemplate(
buf, pagesize=A4,
leftMargin=2.2 * cm, rightMargin=2.2 * cm,
topMargin=2 * cm, bottomMargin=2 * cm,
)
story: list = []
prev_type: str | None = None
for para in doc.paragraphs:
text = para.text.strip()
if not text:
if prev_type and prev_type != "empty":
story.append(Spacer(1, 4))
prev_type = "empty"
continue
block_type = _detect_block_type(getattr(para.style, "name", None), para)
style = _STYLE_MAP.get(block_type, _STYLE_MAP["text"])
# draw separator line before new heading sections (except first)
if block_type == "heading" and prev_type not in (None, "heading"):
story.append(Spacer(1, 6))
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#cccccc")))
prefix = "\u2022\u00a0" if block_type == "bullet" else ""
story.append(Paragraph(f"{prefix}{text}", style))
prev_type = block_type
pdf.build(story)
return buf.getvalue()

View File

@@ -11,18 +11,8 @@ services:
build: build:
context: ./ context: ./
dockerfile: ./docker/webapp.Dockerfile dockerfile: ./docker/webapp.Dockerfile
args:
NEXT_PUBLIC_AUTHENTIK_ISSUER: ${NEXT_PUBLIC_AUTHENTIK_ISSUER}
NEXT_PUBLIC_AUTHENTIK_CLIENT_ID: ${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-https://cv.alves.world}
environment: environment:
- API_BASE_URL=http://cvfs-backend:8080 - API_BASE_URL=http://cvfs-backend:8080
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER}
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID}
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET}
- NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER}
- NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://cv.alves.world}
networks: networks:
- dokploy-network - dokploy-network
- cvfs-network - cvfs-network
@@ -42,20 +32,18 @@ services:
environment: environment:
- BACKEND_PORT=8080 - BACKEND_PORT=8080
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches - DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches
- MINIO_ENDPOINT=https://storage.cv.alves.world - MINIO_ENDPOINT=http://cvfs-minio:9000
- MINIO_BUCKET=resume-branches - MINIO_BUCKET=resume-branches
- MINIO_REGION=us-east-1 - MINIO_REGION=us-east-1
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
- CORS_ORIGINS=https://cv.alves.world,https://api.cv.alves.world
- PUBLIC_BASE_URL=https://cv.alves.world - PUBLIC_BASE_URL=https://cv.alves.world
- CV_PUBLIC_DOMAIN=cv.alves.world - CV_PUBLIC_DOMAIN=cv.alves.world
- REDIS_URL=redis://cvfs-redis:6379/0 - REDIS_URL=redis://cvfs-redis:6379/0
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0 - CELERY_BROKER_URL=redis://cvfs-redis:6379/0
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0 - CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER}
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE}
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
depends_on: depends_on:
- postgres - postgres
- minio - minio
@@ -80,7 +68,7 @@ services:
- REDIS_URL=redis://cvfs-redis:6379/0 - REDIS_URL=redis://cvfs-redis:6379/0
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0 - CELERY_BROKER_URL=redis://cvfs-redis:6379/0
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0 - CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
- MINIO_ENDPOINT=https://storage.cv.alves.world - MINIO_ENDPOINT=http://cvfs-minio:9000
- MINIO_BUCKET=resume-branches - MINIO_BUCKET=resume-branches
- MINIO_REGION=us-east-1 - MINIO_REGION=us-east-1
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
@@ -120,14 +108,13 @@ services:
image: minio/minio:latest image: minio/minio:latest
container_name: "cvfs-minio" container_name: "cvfs-minio"
environment: environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
volumes: volumes:
- minio_data:/data - minio_data:/data
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
networks: networks:
- cvfs-network - cvfs-network
- dokploy-network
restart: unless-stopped restart: unless-stopped
create-bucket: create-bucket:

View File

@@ -8,12 +8,6 @@ COPY apps/webapp/package.json apps/webapp/bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
FROM deps AS builder FROM deps AS builder
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_AUTHENTIK_ISSUER
ARG NEXT_PUBLIC_AUTHENTIK_CLIENT_ID
ENV NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
ENV NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER}
ENV NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
COPY apps/webapp ./ COPY apps/webapp ./
RUN bun run build RUN bun run build

View File

@@ -25,7 +25,6 @@ dependencies = [
"python-multipart", "python-multipart",
"pyyaml", "pyyaml",
"python-jose[cryptography]", "python-jose[cryptography]",
"reportlab",
"sqlalchemy[asyncio]", "sqlalchemy[asyncio]",
"asyncpg", "asyncpg",
"redis", "redis",