Compare commits

..

32 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
0b38f9f703 fix: correct default API_BASE_URL and improve sidebar error message
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/1a9aa3e0-c4e1-48fb-b646-49f31c316325

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-04 06:35:18 +00:00
copilot-swe-agent[bot]
8bc501fa85 Initial plan 2026-04-04 06:19:11 +00:00
Daniel Alves Rösel
b18ad7712c Merge pull request #4 from velocitatem/copilot/add-pdf-rendering-and-analytics
feat: CV share analytics + PDF rendering for public links
2026-04-04 10:05:35 +04:00
copilot-swe-agent[bot]
ad91369371 fix: address code review - use timezone-aware datetime, full ip hash
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/fb35fb9a-a89e-4df0-9584-109f7151509c

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-04 06:02:24 +00:00
copilot-swe-agent[bot]
7435a0f1bf feat: add CV view analytics and PDF rendering for public share links
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/fb35fb9a-a89e-4df0-9584-109f7151509c

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-04 05:59:05 +00:00
copilot-swe-agent[bot]
b63417b8b3 Initial plan 2026-04-04 05:50:23 +00:00
66bf016747 verify tokens using full jwks and relaxed options 2026-04-03 19:51:48 +02:00
dfc3764bcc normalize discovery issuer path 2026-04-03 19:45:20 +02:00
b5053c5536 strip authorize suffix from issuer 2026-04-03 19:37:49 +02:00
d2ad0c3fdd use raw issuer for discovery 2026-04-03 19:36:22 +02:00
effb9161f8 use kid-specific jwk for verification 2026-04-03 19:33:56 +02:00
fa215009cd allow jwt alg from token header 2026-04-03 19:31:23 +02:00
e7bac3b178 discover jwks uri from oidc configuration 2026-04-03 19:28:03 +02:00
ba0612efb8 parse authentik issuer path correctly 2026-04-03 19:24:54 +02:00
7e5f2bb06a fix authentik issuer normalization 2026-04-03 19:22:51 +02:00
5a8e8f1572 force authentik issuer base 2026-04-03 19:21:13 +02:00
9f90b000e2 normalize oidc issuer for authentik 2026-04-03 19:18:27 +02:00
dce592c086 redirect using public base url 2026-04-03 19:15:37 +02:00
531c27b669 use authentik host for authorize urls 2026-04-03 19:13:28 +02:00
81165ca9db normalize authentik issuer paths 2026-04-03 19:04:43 +02:00
3f6b9a4f81 propagate authentik env into web build 2026-04-03 19:01:17 +02:00
dcfe207389 wire authentik env vars in compose 2026-04-03 18:47:42 +02:00
5ccae82cfd load env file for compose services 2026-04-03 18:45:32 +02:00
af7a9cb63f fix cascade delete on cv versions 2026-04-03 18:28:54 +02:00
Daniel Alves Rösel
d6e5e563f1 Merge pull request #3 from velocitatem/copilot/add-delete-buttons-and-fix-header
Rename to cvfs, remove dashboard heading, add branch delete buttons
2026-04-03 19:17:40 +04:00
copilot-swe-agent[bot]
77d454cf09 rename to cvfs, remove dashboard heading, add branch delete buttons
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/2bd56e04-d1e0-4e38-93b6-a99afc1d2b3c

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-03 15:08:05 +00:00
copilot-swe-agent[bot]
7543402c83 Initial plan 2026-04-03 15:03:54 +00:00
Daniel Alves Rösel
83b609f815 Merge pull request #2 from velocitatem/copilot/add-mobile-support-and-deleting-functions
Add mobile support, CV/branch deletion, and fix DOCX export to include patches
2026-04-03 18:54:47 +04:00
copilot-swe-agent[bot]
5d815cd24d feat: add mobile support, delete CV/branch, and fix DOCX export with patches
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/4d754ed6-7f63-44e0-8689-123d7a70595f

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-03 14:45:54 +00:00
copilot-swe-agent[bot]
300a577fbe Initial plan 2026-04-03 14:31:39 +00:00
Daniel Alves Rösel
2002a78509 Merge pull request #1 from velocitatem/claude/cv-branching-dashboard-XGi60
fix(webapp): use Web Crypto API in middleware and drop node: prefix in auth route
2026-04-03 18:09:34 +04:00
Daniel Alves Rösel
684936ab72 Add AI-powered CV tailoring, submissions tracking, and auth overhaul 2026-04-03 17:57:28 +04:00
28 changed files with 788 additions and 120 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ from .cv import (
DocumentCreateResult, DocumentCreateResult,
DocumentListResponse, DocumentListResponse,
DocumentResponse, DocumentResponse,
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse, PublicAssetLookupResponse,
PublicAssetResponse, PublicAssetResponse,
PublishRequest, PublishRequest,
@@ -28,4 +29,5 @@ __all__ = [
"PublishRequest", "PublishRequest",
"PublicAssetResponse", "PublicAssetResponse",
"PublicAssetLookupResponse", "PublicAssetLookupResponse",
"PublicAssetAnalyticsResponse",
] ]

View File

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

View File

@@ -68,3 +68,14 @@ async def get_document(
) )
result = await session.execute(stmt) result = await session.execute(stmt)
return result.scalars().unique().one_or_none() return result.scalars().unique().one_or_none()
async def delete_document(
session: AsyncSession, owner_id: str, document_id: str
) -> bool:
doc = await get_document(session, owner_id, document_id)
if not doc:
return False
await session.delete(doc)
await session.commit()
return True

View File

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

View File

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

View File

@@ -1,21 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'; 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) { export async function GET(req: NextRequest) {
const { searchParams, origin } = new URL(req.url); const { searchParams, origin } = new URL(req.url);
const code = searchParams.get('code'); const code = searchParams.get('code');
if (!code) return NextResponse.redirect(`${origin}/login?error=no_code`); if (!code) return NextResponse.redirect(`${origin}/login?error=no_code`);
const issuer = process.env.AUTHENTIK_ISSUER; const issuerRaw = process.env.AUTHENTIK_ISSUER;
const clientId = process.env.AUTHENTIK_CLIENT_ID; const clientId = process.env.AUTHENTIK_CLIENT_ID;
const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET; const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET;
const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL ?? origin}/api/auth/callback`; const publicBase = process.env.NEXT_PUBLIC_BASE_URL ?? origin;
const redirectUri = `${publicBase}/api/auth/callback`;
if (!issuer || !clientId || !clientSecret) { const authentikHost = authentikBase(issuerRaw);
return NextResponse.redirect(`${origin}/login?error=oidc_not_configured`);
if (!authentikHost || !clientId || !clientSecret) {
return NextResponse.redirect(`${publicBase}/login?error=oidc_not_configured`);
} }
const tokenRes = await fetch(`${issuer}/application/o/token/`, { const tokenRes = await fetch(`${authentikHost}/application/o/token/`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' }, headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ body: new URLSearchParams({
@@ -27,7 +40,7 @@ export async function GET(req: NextRequest) {
if (!tokenRes?.ok) return NextResponse.redirect(`${origin}/login?error=token_exchange`); if (!tokenRes?.ok) return NextResponse.redirect(`${origin}/login?error=token_exchange`);
const tokens = await tokenRes.json(); const tokens = await tokenRes.json();
const res = NextResponse.redirect(`${origin}/dashboard`); const res = NextResponse.redirect(`${publicBase}/dashboard`);
res.cookies.set('oidc_token', tokens.access_token, { res.cookies.set('oidc_token', tokens.access_token, {
httpOnly: true, sameSite: 'lax', path: '/', httpOnly: true, sameSite: 'lax', path: '/',
maxAge: tokens.expires_in ?? 3600, maxAge: tokens.expires_in ?? 3600,

View File

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

View File

@@ -5,8 +5,11 @@ import CVTree from '@/components/cv/CVTree';
import DiffViewer from '@/components/cv/DiffViewer'; import DiffViewer from '@/components/cv/DiffViewer';
import Link from 'next/link'; import Link from 'next/link';
import { import {
createBranch, createSubmission, Document, downloadVersionUrl, createBranch, createSubmission, deleteDocument, deleteVersion,
fetchDocuments, fetchSubmissions, publishVersion, requestAiSuggestions, Document, downloadVersionUrl,
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
publishVersion, PublicAsset, PublicAssetAnalytics,
requestAiSuggestions,
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version, Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
} from '@/libs/api'; } from '@/libs/api';
@@ -165,7 +168,7 @@ function SubmissionModal({ version, onClose, onDone }: { version: Version; onClo
); );
} }
function PublishModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (url: string) => void }) { function PublishModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (asset: PublicAsset) => void }) {
const [slug, setSlug] = useState(''); const [slug, setSlug] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -174,7 +177,7 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
setLoading(true); setError(''); setLoading(true); setError('');
try { try {
const asset = await publishVersion(version.id, null, slug.trim() || null); const asset = await publishVersion(version.id, null, slug.trim() || null);
onDone(asset.url ?? asset.slug); onDone(asset);
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); } } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
}; };
@@ -477,11 +480,14 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [modal, setModal] = useState<Modal>(null); const [modal, setModal] = useState<Modal>(null);
const [publishedUrl, setPublishedUrl] = useState<string | null>(null); const [publishedAsset, setPublishedAsset] = useState<PublicAsset | null>(null);
const [publishedAnalytics, setPublishedAnalytics] = useState<PublicAssetAnalytics | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('content'); const [activeTab, setActiveTab] = useState<Tab>('content');
const [submissions, setSubmissions] = useState<Submission[]>([]); const [submissions, setSubmissions] = useState<Submission[]>([]);
const [subsLoading, setSubsLoading] = useState(false); const [subsLoading, setSubsLoading] = useState(false);
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map()); const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
const [sidebarOpen, setSidebarOpen] = useState(false);
const [docHovered, setDocHovered] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetchDocuments() fetchDocuments()
@@ -489,7 +495,7 @@ export default function Dashboard() {
setDocs(d); setDocs(d);
if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); } if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); }
}) })
.catch(e => setError(e.message)) .catch(() => setError('Failed to load documents. Make sure the backend is running.'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@@ -525,6 +531,7 @@ export default function Dashboard() {
setSelectedDocId(doc.id); setSelectedDocId(doc.id);
setSelectedVersionId(doc.root_version_id ?? null); setSelectedVersionId(doc.root_version_id ?? null);
setModal(null); setModal(null);
setSidebarOpen(false);
}; };
const onBranchDone = async (v: Version) => { const onBranchDone = async (v: Version) => {
@@ -547,6 +554,46 @@ export default function Dashboard() {
const discardEdits = () => setPendingEdits(new Map()); 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 pendingCount = pendingEdits.size;
const stagedPatches = [...pendingEdits.entries()].map(([path, { old_value, new_value }]) => ({ const stagedPatches = [...pendingEdits.entries()].map(([path, { old_value, new_value }]) => ({
target_path: path, operation: 'replace_text', old_value, new_value, target_path: path, operation: 'replace_text', old_value, new_value,
@@ -558,15 +605,22 @@ export default function Dashboard() {
}; };
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden', background: 'var(--bg)' }}> <div className="dashboard-root">
{/* top bar */} {/* top bar */}
<div style={{ <div className="topbar">
display: 'flex', alignItems: 'center', justifyContent: 'space-between', <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
padding: '0 16px', height: 44, borderBottom: '1px solid var(--border)', flexShrink: 0, <button
}}> className="btn btn-ghost sidebar-toggle"
style={{ padding: '4px 8px', fontSize: 16 }}
onClick={() => setSidebarOpen(o => !o)}
aria-label="Toggle menu"
>
</button>
<Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}> <Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}>
Resume Branches cvfs
</Link> </Link>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: 12 }} onClick={() => setModal('upload')}> <button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: 12 }} onClick={() => setModal('upload')}>
+ Upload CV + Upload CV
@@ -577,12 +631,17 @@ export default function Dashboard() {
</div> </div>
</div> </div>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}> <div className="dashboard-body">
{/* sidebar overlay on mobile */}
{sidebarOpen && (
<div
className="sidebar-overlay"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* left panel */} {/* left panel */}
<div style={{ <div className={`sidebar${sidebarOpen ? ' sidebar-open' : ''}`}>
width: 240, flexShrink: 0, borderRight: '1px solid var(--border)',
background: 'var(--surface)', overflow: 'auto', display: 'flex', flexDirection: 'column',
}}>
{loading && <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>Loading</div>} {loading && <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>Loading</div>}
{error && <div style={{ padding: 16, fontSize: 13, color: '#dc2626' }}>{error}</div>} {error && <div style={{ padding: 16, fontSize: 13, color: '#dc2626' }}>{error}</div>}
@@ -602,22 +661,42 @@ export default function Dashboard() {
{docs.map(d => ( {docs.map(d => (
<div <div
key={d.id} key={d.id}
onMouseEnter={() => setDocHovered(d.id)}
onMouseLeave={() => setDocHovered(null)}
onClick={() => { onClick={() => {
setSelectedDocId(d.id); setSelectedDocId(d.id);
setSelectedVersionId(d.root_version_id ?? null); setSelectedVersionId(d.root_version_id ?? null);
setActiveTab('content'); setActiveTab('content');
setSidebarOpen(false);
}} }}
style={{ style={{
padding: '5px 8px', borderRadius: 4, cursor: 'pointer', padding: '5px 8px', borderRadius: 4, cursor: 'pointer',
fontSize: 13, fontWeight: d.id === selectedDocId ? 600 : 400, fontSize: 13, fontWeight: d.id === selectedDocId ? 600 : 400,
background: d.id === selectedDocId ? 'var(--selected-bg)' : 'transparent', background: d.id === selectedDocId ? 'var(--selected-bg)' : 'transparent',
display: 'flex', alignItems: 'flex-start', gap: 4,
}} }}
> >
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.title}</div> <div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}> <div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
{d.versions.length} version{d.versions.length !== 1 ? 's' : ''} {d.versions.length} version{d.versions.length !== 1 ? 's' : ''}
</div> </div>
</div> </div>
{docHovered === d.id && (
<button
onClick={e => { e.stopPropagation(); handleDeleteDoc(d.id); }}
title="Delete CV"
aria-label="Delete CV"
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#dc2626', fontSize: 14, lineHeight: 1, padding: '2px 2px',
flexShrink: 0,
}}
>
×
</button>
)}
</div>
))} ))}
</div> </div>
@@ -629,7 +708,8 @@ export default function Dashboard() {
<CVTree <CVTree
versions={selectedDoc.versions} versions={selectedDoc.versions}
selectedVersionId={selectedVersionId} selectedVersionId={selectedVersionId}
onSelect={id => { setSelectedVersionId(id); setActiveTab('content'); }} onSelect={selectVersion}
onDeleteVersion={handleDeleteVersion}
/> />
</div> </div>
)} )}
@@ -638,7 +718,7 @@ export default function Dashboard() {
</div> </div>
{/* main panel */} {/* main panel */}
<div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }}> <div className="main-panel">
{!selectedVersion && !loading && ( {!selectedVersion && !loading && (
<div style={{ paddingTop: 60, textAlign: 'center', color: 'var(--text-faint)', fontSize: 13 }}> <div style={{ paddingTop: 60, textAlign: 'center', color: 'var(--text-faint)', fontSize: 13 }}>
Select a branch to view details. Select a branch to view details.
@@ -648,10 +728,10 @@ export default function Dashboard() {
{selectedVersion && ( {selectedVersion && (
<> <>
{/* version header */} {/* version header */}
<div style={{ padding: '16px 24px 0', flexShrink: 0 }}> <div style={{ padding: '16px 20px 0', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
<div> <div style={{ minWidth: 0 }}>
<h2 style={{ fontSize: 17, fontWeight: 600, marginBottom: 3 }}> <h2 style={{ fontSize: 17, fontWeight: 600, marginBottom: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedVersion.version_label || selectedVersion.branch_name} {selectedVersion.version_label || selectedVersion.branch_name}
</h2> </h2>
<div style={{ display: 'flex', gap: 14, fontSize: 12, color: 'var(--text-muted)', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 14, fontSize: 12, color: 'var(--text-muted)', flexWrap: 'wrap' }}>
@@ -673,7 +753,7 @@ export default function Dashboard() {
</div> </div>
{/* action buttons */} {/* action buttons */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div className="action-buttons">
<button className="btn btn-ghost" onClick={() => setModal('branch')}>Branch</button> <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('submission'); }}>Submit</button>
<button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button> <button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>
@@ -685,14 +765,35 @@ export default function Dashboard() {
</div> </div>
</div> </div>
{publishedUrl && ( {publishedAsset && (
<div style={{ <div style={{
padding: '8px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0', padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', gap: 8, alignItems: 'center', borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', flexDirection: 'column', gap: 6,
}}> }}>
<span style={{ color: '#166534' }}>Published:</span> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<a href={publishedUrl} target="_blank" rel="noreferrer" style={{ color: '#166534', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{publishedUrl}</a> <span style={{ color: '#166534', fontWeight: 500 }}>Published</span>
<button onClick={() => setPublishedUrl(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 16, lineHeight: 1 }}>×</button> {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> </div>
)} )}
@@ -700,7 +801,7 @@ export default function Dashboard() {
{pendingCount > 0 && ( {pendingCount > 0 && (
<div style={{ <div style={{
padding: '8px 12px', background: '#fffbeb', border: '1px solid #fde68a', padding: '8px 12px', background: '#fffbeb', border: '1px solid #fde68a',
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', gap: 10, alignItems: 'center', borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap',
}}> }}>
<span style={{ color: '#92400e', flex: 1 }}> <span style={{ color: '#92400e', flex: 1 }}>
{pendingCount} staged edit{pendingCount !== 1 ? 's' : ''} {pendingCount} staged edit{pendingCount !== 1 ? 's' : ''}
@@ -719,7 +820,7 @@ export default function Dashboard() {
)} )}
{/* tabs */} {/* tabs */}
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)' }}> <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', overflowX: 'auto' }}>
{(['content', 'patches', 'submissions'] as Tab[]).map(t => ( {(['content', 'patches', 'submissions'] as Tab[]).map(t => (
<button <button
key={t} key={t}
@@ -729,7 +830,7 @@ export default function Dashboard() {
cursor: 'pointer', color: activeTab === t ? 'var(--text)' : 'var(--text-muted)', cursor: 'pointer', color: activeTab === t ? 'var(--text)' : 'var(--text-muted)',
borderBottom: activeTab === t ? '2px solid var(--text)' : '2px solid transparent', borderBottom: activeTab === t ? '2px solid var(--text)' : '2px solid transparent',
fontWeight: activeTab === t ? 500 : 400, fontWeight: activeTab === t ? 500 : 400,
marginBottom: -1, transition: 'color 0.1s', marginBottom: -1, transition: 'color 0.1s', whiteSpace: 'nowrap',
}} }}
> >
{t === 'patches' ? `Patches (${selectedVersion.patches.length})` : t.charAt(0).toUpperCase() + t.slice(1)} {t === 'patches' ? `Patches (${selectedVersion.patches.length})` : t.charAt(0).toUpperCase() + t.slice(1)}
@@ -739,7 +840,7 @@ export default function Dashboard() {
</div> </div>
{/* tab content */} {/* tab content */}
<div style={{ padding: '16px 24px', flex: 1, overflow: 'auto' }}> <div style={{ padding: '16px 20px', flex: 1, overflow: 'auto' }}>
{activeTab === 'content' && ( {activeTab === 'content' && (
<ContentTab <ContentTab
blocks={selectedVersion.structured_blocks ?? []} blocks={selectedVersion.structured_blocks ?? []}
@@ -784,7 +885,7 @@ export default function Dashboard() {
<PublishModal <PublishModal
version={selectedVersion} version={selectedVersion}
onClose={() => setModal(null)} onClose={() => setModal(null)}
onDone={url => { setPublishedUrl(url); setModal(null); }} onDone={asset => { setPublishedAsset(asset); setPublishedAnalytics(null); setModal(null); }}
/> />
)} )}
</div> </div>

View File

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

View File

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

View File

@@ -3,18 +3,28 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; 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() { function authentikUrl() {
const issuer = process.env.NEXT_PUBLIC_AUTHENTIK_ISSUER; const baseHost = authentikBase(process.env.NEXT_PUBLIC_AUTHENTIK_ISSUER);
const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID; const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID;
const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : ''); const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : '');
if (!issuer || !clientId) return null; if (!baseHost || !clientId) return null;
const params = new URLSearchParams({ const params = new URLSearchParams({
response_type: 'code', response_type: 'code',
client_id: clientId, client_id: clientId,
redirect_uri: `${base}/api/auth/callback`, redirect_uri: `${base}/api/auth/callback`,
scope: 'openid email profile', scope: 'openid email profile',
}); });
return `${issuer}/application/o/authorize/?${params}`; return `${baseHost}/application/o/authorize/?${params}`;
} }
export default function LoginPage() { export default function LoginPage() {
@@ -52,7 +62,7 @@ export default function LoginPage() {
{/* brand */} {/* brand */}
<div style={{ textAlign: 'center', marginBottom: 32 }}> <div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}> <div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>
Resume Branches cvfs
</div> </div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}> <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Sign in to your account Sign in to your account
@@ -126,7 +136,7 @@ export default function LoginPage() {
</div> </div>
<p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-faint)', marginTop: 20 }}> <p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-faint)', marginTop: 20 }}>
Resume Branches private CV control plane cvfs CV File System
</p> </p>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -17,19 +17,22 @@ function buildTree(versions: Version[]): TreeNode | null {
const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2']; const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2'];
function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: { function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
node: TreeNode; depth: number; selectedId: string | null; node: TreeNode; depth: number; selectedId: string | null;
onSelect: (id: string) => void; colorIndex?: number; onSelect: (id: string) => void;
onDelete?: (id: string) => void;
colorIndex?: number;
}) { }) {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [hovered, setHovered] = useState(false);
const v = node.version; const v = node.version;
const isRoot = !v.parent_version_id; const isRoot = !v.parent_version_id;
const isSelected = v.id === selectedId; const isSelected = v.id === selectedId;
const isLeaf = node.children.length === 0;
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length]; const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
{/* horizontal connector from parent's vertical line */}
{depth > 0 && ( {depth > 0 && (
<div style={{ <div style={{
position: 'absolute', left: -1, top: 15, position: 'absolute', left: -1, top: 15,
@@ -39,17 +42,16 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
<div <div
onClick={() => onSelect(v.id)} onClick={() => onSelect(v.id)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
paddingLeft: depth > 0 ? 18 : 8, paddingRight: 8, paddingLeft: depth > 0 ? 18 : 8, paddingRight: 4,
height: 30, cursor: 'pointer', borderRadius: 4, userSelect: 'none', height: 30, cursor: 'pointer', borderRadius: 4, userSelect: 'none',
background: isSelected ? 'var(--selected-bg)' : 'transparent', background: isSelected ? 'var(--selected-bg)' : hovered ? 'var(--hover)' : 'transparent',
borderLeft: isSelected && depth === 0 ? '2px solid var(--selected-border)' : '2px solid transparent', borderLeft: isSelected && depth === 0 ? '2px solid var(--selected-border)' : '2px solid transparent',
}} }}
onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'var(--hover)'; }}
onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
> >
{/* expand toggle */}
<button <button
onClick={e => { e.stopPropagation(); setOpen(o => !o); }} onClick={e => { e.stopPropagation(); setOpen(o => !o); }}
style={{ style={{
@@ -65,7 +67,6 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
</svg> </svg>
</button> </button>
{/* dot indicator */}
<span style={{ <span style={{
width: isRoot ? 8 : 7, height: isRoot ? 8 : 7, width: isRoot ? 8 : 7, height: isRoot ? 8 : 7,
borderRadius: '50%', flexShrink: 0, borderRadius: '50%', flexShrink: 0,
@@ -74,7 +75,6 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
transition: 'background 0.1s', transition: 'background 0.1s',
}} /> }} />
{/* label */}
<span style={{ <span style={{
flex: 1, fontSize: 13, flex: 1, fontSize: 13,
fontWeight: isRoot ? 600 : 400, fontWeight: isRoot ? 600 : 400,
@@ -84,7 +84,6 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
{v.version_label || v.branch_name} {v.version_label || v.branch_name}
</span> </span>
{/* patch count */}
{v.patches.length > 0 && ( {v.patches.length > 0 && (
<span style={{ <span style={{
fontSize: 10, color: 'var(--text-faint)', fontSize: 10, color: 'var(--text-faint)',
@@ -94,9 +93,24 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
{v.patches.length} {v.patches.length}
</span> </span>
)} )}
{!isRoot && onDelete && hovered && (
<button
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
title="Delete branch"
aria-label="Delete branch"
style={{
width: 18, height: 18, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: '#dc2626', flexShrink: 0,
borderRadius: 3, fontSize: 14, lineHeight: 1,
}}
>
×
</button>
)}
</div> </div>
{/* children with vertical line */}
{open && node.children.length > 0 && ( {open && node.children.length > 0 && (
<div style={{ <div style={{
marginLeft: depth > 0 ? 22 : 14, marginLeft: depth > 0 ? 22 : 14,
@@ -110,6 +124,7 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
depth={depth + 1} depth={depth + 1}
selectedId={selectedId} selectedId={selectedId}
onSelect={onSelect} onSelect={onSelect}
onDelete={onDelete}
colorIndex={depth === 0 ? i + 1 : colorIndex} colorIndex={depth === 0 ? i + 1 : colorIndex}
/> />
))} ))}
@@ -119,14 +134,17 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
); );
} }
export default function CVTree({ versions, selectedVersionId, onSelect }: { export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion }: {
versions: Version[]; selectedVersionId: string | null; onSelect: (id: string) => void; versions: Version[]; selectedVersionId: string | null;
onSelect: (id: string) => void;
onDeleteVersion?: (id: string) => void;
}) { }) {
const tree = buildTree(versions); const tree = buildTree(versions);
if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>; if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>;
return ( return (
<div style={{ paddingBottom: 8 }}> <div style={{ paddingBottom: 8 }}>
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} /> <Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} />
</div> </div>
); );
} }

View File

@@ -73,6 +73,12 @@ export type PublicAsset = {
created_at: string; created_at: string;
}; };
export type PublicAssetAnalytics = {
slug: string;
view_count: number;
last_viewed_at?: string | null;
};
// reads OIDC bearer token from client-readable cookie (set by /api/auth/callback) // reads OIDC bearer token from client-readable cookie (set by /api/auth/callback)
function getAuthHeader(): Record<string, string> { function getAuthHeader(): Record<string, string> {
if (typeof document === 'undefined') return {}; if (typeof document === 'undefined') return {};
@@ -177,3 +183,31 @@ export async function publishVersion(
body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }), body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }),
}); });
} }
export const getPublicPdfUrl = (slug: string): string =>
`${API}/api/v1/public/${encodeURIComponent(slug)}/pdf`;
export const fetchPublicAssetAnalytics = (slug: string): Promise<PublicAssetAnalytics> =>
req<PublicAssetAnalytics>(`/api/v1/public/${encodeURIComponent(slug)}/analytics`);
export async function deleteDocument(documentId: string): Promise<void> {
const res = await fetch(`${API}/api/v1/documents/${documentId}`, {
method: 'DELETE',
headers: { accept: 'application/json', ...getAuthHeader() },
});
if (!res.ok) {
const detail = await res.text().catch(() => res.statusText);
throw new Error(detail || `HTTP ${res.status}`);
}
}
export async function deleteVersion(versionId: string): Promise<void> {
const res = await fetch(`${API}/api/v1/versions/${versionId}`, {
method: 'DELETE',
headers: { accept: 'application/json', ...getAuthHeader() },
});
if (!res.ok) {
const detail = await res.text().catch(() => res.statusText);
throw new Error(detail || `HTTP ${res.status}`);
}
}

View File

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

View File

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

76
dlib/cv/docx_export.py Normal file
View File

@@ -0,0 +1,76 @@
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()

79
dlib/cv/pdf_export.py Normal file
View File

@@ -0,0 +1,79 @@
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,8 +11,18 @@ services:
build: build:
context: ./ context: ./
dockerfile: ./docker/webapp.Dockerfile dockerfile: ./docker/webapp.Dockerfile
args:
NEXT_PUBLIC_AUTHENTIK_ISSUER: ${NEXT_PUBLIC_AUTHENTIK_ISSUER}
NEXT_PUBLIC_AUTHENTIK_CLIENT_ID: ${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-https://cv.alves.world}
environment: environment:
- API_BASE_URL=http://cvfs-backend:8080 - API_BASE_URL=http://cvfs-backend:8080
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER}
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID}
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET}
- NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER}
- NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://cv.alves.world}
networks: networks:
- dokploy-network - dokploy-network
- cvfs-network - cvfs-network
@@ -43,6 +53,9 @@ services:
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0 - CELERY_BROKER_URL=redis://cvfs-redis:6379/0
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0 - CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER}
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE}
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
depends_on: depends_on:
- postgres - postgres
- minio - minio

View File

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

View File

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