Compare commits

..

2 Commits

39 changed files with 319 additions and 1871 deletions

1
.gitignore vendored
View File

@@ -9,4 +9,3 @@ logs/
FLAT.xml
**/package-lock.json
.nx/
**/*.db

View File

@@ -6,16 +6,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
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 dlib.auth import AuthenticatedUser
from dlib.cv import generate_patched_docx
router = APIRouter(prefix="/documents", tags=["documents"])
@router.get("", response_model=DocumentListResponse)
@router.get("/", response_model=DocumentListResponse)
async def list_user_documents(
session: AsyncSession = Depends(get_db),
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)
if not version or not version.artifact_docx_key:
raise HTTPException(status_code=404, detail="Version artifact not found")
original = storage_client.download_bytes(key=version.artifact_docx_key)
data = generate_patched_docx(original, version.structured_blocks or [])
data = storage_client.download_bytes(key=version.artifact_docx_key)
slug = f"{document.title.replace(' ', '-')}-{version.branch_name}.docx"
return Response(
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(
title: str = Form(...),
description: str | None = Form(default=None),
@@ -76,14 +74,3 @@ async def upload_document(
upload=file,
)
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
import hashlib
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
from sqlalchemy import func, select
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.models import CvDocument, CvVersion, PublicAsset, PublicAssetView
from app.schemas import (
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
)
from app.models import PublicAsset
from app.schemas import PublicAssetLookupResponse, PublicAssetResponse, PublishRequest
from app.services.publication import publish_version
from app.services.storage import storage_client
from dlib.auth import AuthenticatedUser
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
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)
async def publish(
payload: PublishRequest,
@@ -66,78 +33,21 @@ async def publish(
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)
async def get_public_asset(slug: str, request: Request, session: AsyncSession = Depends(get_db)):
asset = await _get_public_asset(session, slug)
await _log_view(session, asset, request)
async def get_public_asset(slug: str, session: AsyncSession = Depends(get_db)):
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 PublicAssetLookupResponse(asset=_response_from_asset(asset))
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
settings = get_settings()
base = settings.public_base_url.rstrip("/")
url = f"{base}/cv/{asset.slug}"
url = f"{settings.public_base_url.rstrip('/')}/cv/{asset.slug}"
return PublicAssetResponse(
id=asset.id,
slug=asset.slug,

View File

@@ -9,44 +9,15 @@ from app.schemas import (
SubmissionCreateRequest,
SubmissionResponse,
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
router = APIRouter(prefix="/submissions", tags=["submissions"])
@router.get("", response_model=list[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)
@router.post("/", response_model=SubmissionResponse)
async def create_submission_endpoint(
payload: SubmissionCreateRequest,
session: AsyncSession = Depends(get_db),
@@ -83,23 +54,3 @@ async def request_submissions_ai(
if suggestions is None:
raise HTTPException(status_code=404, detail="Submission not found")
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,9 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
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.cv.ats_guard import PatchValidationError
router = APIRouter(prefix="/versions", tags=["versions"])
@@ -19,32 +18,14 @@ async def create_version_branch(
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
try:
version = await create_branch(
session,
owner_id=user.sub,
parent_version_id=payload.parent_version_id,
branch_name=payload.branch_name,
version_label=payload.version_label,
patches=payload.patches,
)
except PatchValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
version = await create_branch(
session,
owner_id=user.sub,
parent_version_id=payload.parent_version_id,
branch_name=payload.branch_name,
version_label=payload.version_label,
patches=payload.patches,
)
if not version:
raise HTTPException(status_code=404, detail="Parent version not found")
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

@@ -57,13 +57,6 @@ class Settings(BaseSettings):
return [origin.strip() for origin in value.split(",") if origin.strip()]
return value
@field_validator("storage_endpoint_url", mode="before")
@classmethod
def _empty_endpoint_to_none(cls, value):
if isinstance(value, str) and not value.strip():
return None
return value
@lru_cache(maxsize=1)
def get_settings() -> Settings:

View File

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

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import enum
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
@@ -22,11 +21,7 @@ class CvDocument(Base, IdentifierMixin, TimestampMixin):
)
versions: Mapped[list["CvVersion"]] = relationship(
"CvVersion",
back_populates="document",
foreign_keys="[CvVersion.document_id]",
cascade="all, delete-orphan",
passive_deletes=True,
"CvVersion", back_populates="document"
)
@@ -46,9 +41,7 @@ class CvVersion(Base, IdentifierMixin, TimestampMixin):
structured_blocks: Mapped[list[dict] | None] = mapped_column(JSONB, default=list)
metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
document: Mapped[CvDocument] = relationship(
"CvDocument", back_populates="versions", foreign_keys="[CvVersion.document_id]"
)
document: Mapped[CvDocument] = relationship("CvDocument", back_populates="versions")
parent: Mapped["CvVersion | None"] = relationship(
"CvVersion", remote_side="[CvVersion.id]"
)
@@ -139,24 +132,6 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
"Submission", back_populates="public_asset"
)
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):

View File

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

View File

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

View File

@@ -23,30 +23,21 @@ async def create_document(
structured = parse_docx_bytes(file_bytes, version_label="root")
doc = CvDocument(owner_id=owner_id, title=title, description=description)
session.add(doc)
await session.flush() # persist doc so version FK is satisfied
version = CvVersion(
document_id=doc.id,
document=doc,
branch_name="root",
version_label="root",
artifact_docx_key=artifact_key,
structured_blocks=[block.model_dump() for block in structured.blocks],
metadata_json={"ingested": True},
)
session.add(version)
await session.flush() # persist version so root_version_id FK is satisfied
doc.versions.append(version)
doc.root_version_id = version.id
await session.commit()
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()
session.add(doc)
await session.commit()
await session.refresh(doc, attribute_names=["versions"])
return doc
async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocument]:
@@ -70,14 +61,3 @@ async def get_document(
)
result = await session.execute(stmt)
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:
stmt = (
select(Submission)
.join(Submission.version)
.join(CvVersion.document)
.join(CvVersion)
.join(CvDocument)
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)
@@ -34,7 +34,7 @@ async def publish_version(
elif version_id:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.join(CvDocument)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)

View File

@@ -84,61 +84,12 @@ async def request_ai_suggestions(
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(
session: AsyncSession, owner_id: str, version_id: str
) -> CvVersion | None:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.join(CvDocument)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)
@@ -152,8 +103,8 @@ async def _get_submission_for_owner(
) -> Submission | None:
stmt = (
select(Submission)
.join(Submission.version)
.join(CvVersion.document)
.join(CvVersion)
.join(CvDocument)
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
.options(selectinload(Submission.version))
)

View File

@@ -26,7 +26,7 @@ async def create_branch(
) -> CvVersion | None:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.join(CvDocument)
.where(CvVersion.id == parent_version_id, CvDocument.owner_id == owner_id)
.options(selectinload(CvVersion.patches))
)
@@ -74,36 +74,5 @@ async def create_branch(
)
await session.commit()
stmt_refresh = (
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
await session.refresh(new_version)
return new_version

View File

@@ -4,7 +4,7 @@ import path from "node:path";
const nextConfig: NextConfig = {
outputFileTracingRoot: path.join(process.cwd(), "../.."),
async rewrites() {
const backend = process.env.API_BASE_URL ?? "http://localhost:9812";
const backend = process.env.API_BASE_URL ?? "https://api.cv.alves.world";
return [{ source: "/api/:path*", destination: `${backend}/api/:path*` }];
},
};

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
}) {
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 Link from 'next/link';
import {
createBranch, createSubmission, deleteDocument, deleteVersion,
Document, downloadVersionUrl,
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
publishVersion, PublicAsset, PublicAssetAnalytics,
requestAiSuggestions,
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
createBranch, createSubmission, Document, downloadVersionUrl,
fetchDocuments, publishVersion, uploadDocument, Version,
} from '@/libs/api';
// ── helpers ───────────────────────────────────────────────────────────────────
// ─── tiny helpers ────────────────────────────────────────────────────────────
function fmt(iso: string) {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function statusBadge(status: string) {
const cls = ({
draft: 'badge-draft', tailoring: 'badge-submitted', pending_review: 'badge-interviewing',
published: 'badge-public', archived: 'badge-closed',
} as Record<string, string>)[status] ?? 'badge-draft';
return <span className={`badge ${cls}`}>{status.replace('_', ' ')}</span>;
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="label" style={{ padding: '0 0 8px' }}>{title}</div>
{children}
</div>
);
}
// ── modals ───────────────────────────────────────────────────────────────────
// ── modals ───────────────────────────────────────────────────────────────────
function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: Document) => void }) {
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-title">Upload CV</div>
<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)} />
<div onClick={() => ref.current?.click()} style={{
border: '1px dashed var(--border-strong)', borderRadius: 5, padding: '16px 0',
textAlign: 'center', cursor: 'pointer', fontSize: 13,
color: file ? 'var(--text)' : 'var(--text-muted)',
}}>
{file ? file.name : 'Click to select .docx'}
<div
onClick={() => ref.current?.click()}
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)' }}
>
{file ? file.name : 'Click to select .docx file'}
</div>
<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>}
<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-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Uploading…' : 'Upload'}
@@ -72,51 +68,31 @@ function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: D
);
}
function BranchModal({
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;
}) {
function BranchModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (v: Version) => void }) {
const [name, setName] = useState('');
const [label, setLabel] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const patches = initialPatches ?? [];
const submit = async () => {
if (!name.trim()) { setError('Branch name required.'); return; }
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); }
};
return (
<div className="overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 460 }}>
<div className="modal-title">
New branch from <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 400 }}>{version.branch_name}</span>
</div>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">New branch from <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 400 }}>{version.branch_name}</span></div>
<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)} />
{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>}
<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-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Creating…' : 'Create branch'}
{loading ? 'Creating…' : 'Create'}
</button>
</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 [role, setRole] = useState('');
const [url, setUrl] = useState('');
const [jd, setJd] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const submit = async () => {
if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; }
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); }
};
return (
<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 style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', gap: 8 }}>
<input placeholder="Company" value={company} onChange={e => setCompany(e.target.value)} autoFocus />
<input placeholder="Role title" value={role} onChange={e => setRole(e.target.value)} />
</div>
<input placeholder="Company name" value={company} onChange={e => setCompany(e.target.value)} />
<input placeholder="Role title" value={role} onChange={e => setRole(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>}
<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-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{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 [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -177,7 +145,7 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
setLoading(true); setError('');
try {
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); }
};
@@ -186,12 +154,12 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">Publish version</div>
<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>
<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>}
<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-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Publishing…' : 'Publish'}
@@ -203,275 +171,9 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
);
}
// ── content tab with inline editing ──────────────────────────────────────────
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 ────────────────────────────────────────────────────────────
// ─── main dashboard ───────────────────────────────────────────────────────────
type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null;
type Tab = 'content' | 'patches' | 'submissions';
export default function Dashboard() {
const [docs, setDocs] = useState<Document[]>([]);
@@ -480,50 +182,25 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modal, setModal] = useState<Modal>(null);
const [publishedAsset, setPublishedAsset] = useState<PublicAsset | 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);
const [publishedUrl, setPublishedUrl] = useState<string | null>(null);
useEffect(() => {
fetchDocuments()
.then(d => {
setDocs(d);
if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); }
})
.catch(() => setError('Failed to load documents. Make sure the backend is running.'))
.then(d => { setDocs(d); if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); } })
.catch(e => setError(e.message))
.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 selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null;
const refreshDocs = async () => {
const fresh = await fetchDocuments().catch(() => docs);
setDocs(fresh);
return fresh;
};
const refreshSubs = () => {
if (!selectedVersionId) return;
fetchSubmissions(selectedVersionId).then(setSubmissions).catch(() => { });
try {
const fresh = await fetchDocuments();
setDocs(fresh);
const doc = fresh.find(d => d.id === selectedDocId) ?? fresh[0] ?? null;
if (doc) { setSelectedDocId(doc.id); }
} catch { /* silent */ }
};
const onUploadDone = (doc: Document) => {
@@ -531,117 +208,28 @@ export default function Dashboard() {
setSelectedDocId(doc.id);
setSelectedVersionId(doc.root_version_id ?? null);
setModal(null);
setSidebarOpen(false);
};
const onBranchDone = async (v: Version) => {
const fresh = await refreshDocs();
const doc = fresh.find(d => d.id === selectedDocId);
if (doc?.versions.find(x => x.id === v.id)) setSelectedVersionId(v.id);
setPendingEdits(new Map());
const onBranchDone = (v: Version) => {
refreshDocs().then(() => setSelectedVersionId(v.id));
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 (
<div className="dashboard-root">
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden', background: 'var(--bg)' }}>
{/* top bar */}
<div className="topbar">
<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' }}>
cvfs
</Link>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: 12 }} onClick={() => setModal('upload')}>
+ Upload CV
</button>
<button className="btn btn-ghost" style={{ padding: '4px 10px', fontSize: 12 }} onClick={logout}>
Sign out
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px', height: 44, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}>
Resume Branches
</Link>
<button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: 12 }} onClick={() => setModal('upload')}>
+ Upload CV
</button>
</div>
<div className="dashboard-body">
{/* sidebar overlay on mobile */}
{sidebarOpen && (
<div
className="sidebar-overlay"
onClick={() => setSidebarOpen(false)}
/>
)}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* 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>}
{error && <div style={{ padding: 16, fontSize: 13, color: '#dc2626' }}>{error}</div>}
@@ -656,60 +244,30 @@ export default function Dashboard() {
{docs.length > 0 && (
<>
{/* document selector */}
<div style={{ padding: '10px 12px 6px' }}>
<div className="label" style={{ marginBottom: 6 }}>Documents</div>
{docs.map(d => (
<div
key={d.id}
onMouseEnter={() => setDocHovered(d.id)}
onMouseLeave={() => setDocHovered(null)}
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,
}}
onClick={() => { setSelectedDocId(d.id); setSelectedVersionId(d.root_version_id ?? 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' }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<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>
)}
{d.title}
</div>
))}
</div>
<hr className="divider" style={{ margin: '4px 0' }} />
<hr className="divider" style={{ margin: '6px 0' }} />
{/* version tree */}
{selectedDoc && (
<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
versions={selectedDoc.versions}
selectedVersionId={selectedVersionId}
onSelect={selectVersion}
onDeleteVersion={handleDeleteVersion}
onSelect={setSelectedVersionId}
/>
</div>
)}
@@ -717,151 +275,85 @@ export default function Dashboard() {
)}
</div>
{/* main panel */}
<div className="main-panel">
{/* main content */}
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>
{!selectedVersion && !loading && (
<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>
)}
{selectedVersion && (
<>
<div style={{ maxWidth: 680 }}>
{/* version header */}
<div style={{ padding: '16px 20px 0', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
<div style={{ minWidth: 0 }}>
<h2 style={{ fontSize: 17, fontWeight: 600, marginBottom: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedVersion.version_label || selectedVersion.branch_name}
</h2>
<div style={{ display: 'flex', gap: 14, fontSize: 12, color: 'var(--text-muted)', flexWrap: 'wrap' }}>
{selectedVersion.parent_version_id ? (
<span>
branched from{' '}
<span style={{ fontFamily: 'var(--font-mono)' }}>
{selectedDoc?.versions.find(v => v.id === selectedVersion.parent_version_id)?.branch_name ?? '…'}
</span>
</span>
) : (
<span className="badge badge-draft" style={{ fontFamily: 'var(--font-mono)' }}>root</span>
)}
<span>{fmt(selectedVersion.created_at)}</span>
{selectedVersion.patches.length > 0 && (
<span>{selectedVersion.patches.length} patch{selectedVersion.patches.length !== 1 ? 'es' : ''}</span>
)}
</div>
</div>
{/* action buttons */}
<div className="action-buttons">
<button className="btn btn-ghost" onClick={() => setModal('branch')}>Branch</button>
<button className="btn btn-ghost" onClick={() => { setModal('submission'); }}>Submit</button>
<button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>
{selectedVersion.artifact_docx_key && selectedDoc && (
<a href={downloadVersionUrl(selectedDoc.id, selectedVersion.id)} download className="btn btn-ghost">
DOCX
</a>
)}
</div>
</div>
{publishedAsset && (
<div style={{
padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', flexDirection: 'column', gap: 6,
}}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ color: '#166534', fontWeight: 500 }}>Published</span>
{publishedAnalytics !== null && (
<span style={{ color: '#166534', fontSize: 11, background: '#dcfce7', padding: '1px 7px', borderRadius: 10 }}>
{publishedAnalytics.view_count} view{publishedAnalytics.view_count !== 1 ? 's' : ''}
</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 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' : ''}
<div style={{ marginBottom: 20 }}>
<h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 4 }}>
{selectedVersion.version_label || selectedVersion.branch_name}
</h2>
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-muted)' }}>
{selectedVersion.parent_version_id && (
<span>
branched from{' '}
<span style={{ fontFamily: 'var(--font-mono)' }}>
{selectedDoc?.versions.find(v => v.id === selectedVersion.parent_version_id)?.branch_name ?? ''}
</span>
</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>
))}
)}
{!selectedVersion.parent_version_id && <span style={{ fontFamily: 'var(--font-mono)' }}>root</span>}
<span>{fmt(selectedVersion.created_at)}</span>
{selectedVersion.patches.length > 0 && (
<span>{selectedVersion.patches.length} patch{selectedVersion.patches.length !== 1 ? 'es' : ''}</span>
)}
</div>
</div>
{/* tab content */}
<div style={{ padding: '16px 20px', flex: 1, overflow: 'auto' }}>
{activeTab === 'content' && (
<ContentTab
blocks={selectedVersion.structured_blocks ?? []}
pendingEdits={pendingEdits}
onEdit={stageEdit}
/>
)}
{activeTab === 'patches' && (
<DiffViewer patches={selectedVersion.patches} />
)}
{activeTab === 'submissions' && (
<SubmissionsTab
submissions={submissions}
loading={subsLoading}
versionId={selectedVersionId!}
onNewSubmission={() => setModal('submission')}
onRefresh={refreshSubs}
/>
{/* action bar */}
<div style={{ display: 'flex', gap: 6, marginBottom: 24, flexWrap: 'wrap' }}>
<button className="btn btn-ghost" onClick={() => setModal('branch')}>New branch</button>
<button className="btn btn-ghost" onClick={() => setModal('submission')}>New submission</button>
<button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>
{selectedVersion.artifact_docx_key && selectedDoc && (
<a
href={downloadVersionUrl(selectedDoc.id, selectedVersion.id)}
download
className="btn btn-ghost"
>
DOCX
</a>
)}
</div>
</>
{publishedUrl && (
<div style={{ padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 5, marginBottom: 20, fontSize: 13 }}>
Published:{' '}
<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>
)}
<hr className="divider" style={{ marginBottom: 24 }} />
{/* 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>
</div>
))}
</div>
</Section>
)}
{/* patches */}
<Section title={`Patches (${selectedVersion.patches.length} changes from parent)`}>
<DiffViewer patches={selectedVersion.patches} />
</Section>
</div>
)}
</div>
</div>
@@ -871,21 +363,16 @@ export default function Dashboard() {
<UploadModal onClose={() => setModal(null)} onDone={onUploadDone} />
)}
{modal === 'branch' && selectedVersion && (
<BranchModal
version={selectedVersion}
initialPatches={stagedPatches}
onClose={() => setModal(null)}
onDone={onBranchDone}
/>
<BranchModal version={selectedVersion} onClose={() => setModal(null)} onDone={onBranchDone} />
)}
{modal === 'submission' && selectedVersion && (
<SubmissionModal version={selectedVersion} onClose={() => setModal(null)} onDone={onSubmissionDone} />
<SubmissionModal version={selectedVersion} onClose={() => setModal(null)} onDone={() => { setModal(null); }} />
)}
{modal === 'publish' && selectedVersion && (
<PublishModal
version={selectedVersion}
onClose={() => setModal(null)}
onDone={asset => { setPublishedAsset(asset); setPublishedAnalytics(null); setModal(null); }}
onDone={url => { setPublishedUrl(url); setModal(null); }}
/>
)}
</div>

View File

@@ -150,111 +150,3 @@ input:focus, textarea:focus, select:focus {
font-weight: 600;
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";
export const metadata: Metadata = {
title: "cvfs",
description: "CV File System — manage your resume like code: branch, version, and tailor for different roles while preserving ATS formatting",
title: "Resume Branches",
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 }) {

View File

@@ -1,3 +1,46 @@
// Auth is handled via /api/auth/* route handlers.
// This file retained for compatibility; Supabase sign-in is no longer used.
export {};
'use server'
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 { 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}`;
}
import { login, signup } from './actions'
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 (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center',
justifyContent: 'center', background: 'var(--bg)',
}}>
<div style={{ width: '100%', maxWidth: 360, padding: '0 20px' }}>
{/* brand */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<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>
{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>
);
return (
<form>
<label htmlFor="email">Email:</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Password:</label>
<input id="password" name="password" type="password" required />
<button formAction={login}>Log in</button>
<button formAction={signup}>Sign up</button>
</form>
)
}

View File

@@ -10,10 +10,10 @@ export default function Home() {
<section style={{ padding: "80px 24px 64px", textAlign: "center", borderBottom: "1px solid var(--border)" }}>
<div style={{ maxWidth: 560, margin: "0 auto" }}>
<p style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-faint)", marginBottom: 16 }}>
cvfs
Resume Branches
</p>
<h1 style={{ fontSize: 40, fontWeight: 700, lineHeight: 1.1, marginBottom: 16, letterSpacing: "-0.02em" }}>
CV File System
Git for CVs
</h1>
<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

View File

@@ -4,9 +4,9 @@ export default function Footer() {
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="grid md:grid-cols-4 gap-8">
<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">
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.
</p>
</div>
@@ -40,7 +40,7 @@ export default function Footer() {
</div>
<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">
<a href="#" className="text-gray-400 hover:text-white transition-colors">
<span className="sr-only">Twitter</span>

View File

@@ -4,7 +4,7 @@ export default function Header() {
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 }}>
<Link href="/" style={{ fontSize: 14, fontWeight: 600, color: "var(--text)", textDecoration: "none" }}>
cvfs
Resume Branches
</Link>
<nav style={{ display: "flex", alignItems: "center", gap: 24 }}>
{[["Dashboard", "/dashboard"], ["Docs", "/docs"]].map(([label, href]) => (

View File

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

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 = "";
export type StructuredBlock = {
@@ -40,16 +42,6 @@ export type Document = {
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 = {
id: string;
version_id: string;
@@ -58,7 +50,6 @@ export type Submission = {
job_url?: string | null;
job_description?: string | null;
status: string;
suggestions: Suggestion[];
created_at: string;
};
@@ -73,30 +64,12 @@ export type PublicAsset = {
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> {
const res = await fetch(`${API}${path}`, {
...init,
headers: { accept: 'application/json', ...getAuthHeader(), ...init?.headers },
headers: { accept: "application/json", ...init?.headers },
});
if (!res.ok) {
if (res.status === 401 && typeof window !== 'undefined') {
document.cookie = 'oidc_token_pub=; max-age=0; path=/';
document.cookie = 'oidc_token=; max-age=0; path=/';
window.location.href = '/login';
}
const detail = await res.text().catch(() => res.statusText);
throw new Error(detail || `HTTP ${res.status}`);
}
@@ -104,17 +77,17 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
}
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> =>
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> {
const form = new FormData();
form.append('title', title);
if (description) form.append('description', description);
form.append('file', file);
return req<Document>('/api/v1/documents', { method: 'POST', body: form });
form.append("title", title);
if (description) form.append("description", description);
form.append("file", file);
return req<Document>("/api/v1/documents", { method: "POST", body: form });
}
export const downloadVersionUrl = (documentId: string, versionId: string): string =>
@@ -126,9 +99,9 @@ export async function createBranch(
versionLabel?: string | null,
patches: Record<string, unknown>[] = [],
): Promise<Version> {
return req<Version>('/api/v1/versions/branches', {
method: 'POST',
headers: { 'content-type': 'application/json' },
return req<Version>("/api/v1/versions/branches", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parent_version_id: parentVersionId, branch_name: branchName, version_label: versionLabel ?? null, patches }),
});
}
@@ -140,79 +113,22 @@ export async function createSubmission(
jobUrl?: string | null,
jobDescription?: string | null,
): Promise<Submission> {
return req<Submission>('/api/v1/submissions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
return req<Submission>("/api/v1/submissions", {
method: "POST",
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 }),
});
}
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(
versionId?: string | null,
submissionId?: string | null,
slug?: string | null,
): Promise<PublicAsset> {
return req<PublicAsset>('/api/v1/public/publish', {
method: 'POST',
headers: { 'content-type': 'application/json' },
return req<PublicAsset>("/api/v1/public/publish", {
method: "POST",
headers: { "content-type": "application/json" },
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
import time
from functools import cached_property
from typing import Any
import httpx
@@ -20,16 +21,6 @@ class TokenValidationError(Exception):
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:
def __init__(
self,
@@ -39,16 +30,12 @@ class OidcTokenValidator:
jwks_url: str | None = None,
disable: bool = False,
) -> None:
raw_issuer, discovery_issuer = _normalize_issuer(issuer)
self.issuer = raw_issuer
self.issuer = issuer
self.audience = audience
self.jwks_url = jwks_url
self.discovery_url = (
f"{(discovery_issuer or raw_issuer).rstrip('/')}/.well-known/openid-configuration"
if (discovery_issuer or raw_issuer)
else None
self.jwks_url = jwks_url or (
f"{issuer.rstrip('/')}/.well-known/jwks.json" if 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_expiry: float = 0
@@ -58,22 +45,17 @@ class OidcTokenValidator:
sub="dev-user", email="dev@example.com", name="Developer"
)
header = jwt.get_unverified_header(token)
alg = header.get("alg") or "RS256"
jwks = await self._get_jwks()
if not jwks:
raise TokenValidationError("Unable to resolve signing keys")
key = await self._get_key(header.get("kid"))
if not key:
raise TokenValidationError("Unable to resolve signing key")
try:
claims = jwt.decode(
token,
jwks,
algorithms=[alg],
options={"verify_aud": False, "verify_iss": False},
key,
algorithms=[key.get("alg", "RS256")],
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:
raise TokenValidationError(str(exc)) from exc
roles = claims.get("roles") or claims.get("app_metadata", {}).get("roles") or []
@@ -87,19 +69,7 @@ class OidcTokenValidator:
roles=roles,
)
async def _ensure_jwks_url(self) -> 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()
async def _get_key(self, kid: str | None) -> dict[str, Any] | None:
if not self.jwks_url:
return None
if not self._jwks or time.time() > self._jwks_expiry:
@@ -108,7 +78,12 @@ class OidcTokenValidator:
response.raise_for_status()
self._jwks = response.json()
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(

View File

@@ -8,8 +8,6 @@ from .schema import (
from .parser import parse_docx_bytes, summarize_keywords
from .patcher import apply_patchset
from .ats_guard import validate_patchset
from .docx_export import generate_patched_docx
from .pdf_export import docx_bytes_to_pdf
__all__ = [
"StructuredBlock",
@@ -21,6 +19,4 @@ __all__ = [
"summarize_keywords",
"apply_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:
context: ./
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:
- 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:
- dokploy-network
- cvfs-network
@@ -42,20 +32,18 @@ services:
environment:
- BACKEND_PORT=8080
- 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_REGION=us-east-1
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-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
- CV_PUBLIC_DOMAIN=cv.alves.world
- REDIS_URL=redis://cvfs-redis:6379/0
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
- 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:
- postgres
- minio
@@ -80,7 +68,7 @@ services:
- REDIS_URL=redis://cvfs-redis:6379/0
- CELERY_BROKER_URL=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_REGION=us-east-1
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
@@ -120,14 +108,13 @@ services:
image: minio/minio:latest
container_name: "cvfs-minio"
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio_data:/data
command: server /data --console-address ":9001"
networks:
- cvfs-network
- dokploy-network
restart: unless-stopped
create-bucket:

View File

@@ -8,12 +8,6 @@ COPY apps/webapp/package.json apps/webapp/bun.lock ./
RUN bun install --frozen-lockfile
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 ./
RUN bun run build

View File

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