mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-07-16 03:13:36 +00:00
Compare commits
50 Commits
claude/cv-
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b38f9f703 | ||
|
|
8bc501fa85 | ||
|
|
b18ad7712c | ||
|
|
ad91369371 | ||
|
|
7435a0f1bf | ||
|
|
b63417b8b3 | ||
| 66bf016747 | |||
| dfc3764bcc | |||
| b5053c5536 | |||
| d2ad0c3fdd | |||
| effb9161f8 | |||
| fa215009cd | |||
| e7bac3b178 | |||
| ba0612efb8 | |||
| 7e5f2bb06a | |||
| 5a8e8f1572 | |||
| 9f90b000e2 | |||
| dce592c086 | |||
| 531c27b669 | |||
| 81165ca9db | |||
| 3f6b9a4f81 | |||
| dcfe207389 | |||
| 5ccae82cfd | |||
| af7a9cb63f | |||
|
|
d6e5e563f1 | ||
|
|
77d454cf09 | ||
|
|
7543402c83 | ||
|
|
83b609f815 | ||
|
|
5d815cd24d | ||
|
|
300a577fbe | ||
|
|
2002a78509 | ||
|
|
8d72dfa09d | ||
|
|
684936ab72 | ||
|
|
01f34915f6 | ||
| 9a8add0bcd | |||
| abf424779d | |||
| 3ebe9d3fb8 | |||
| 1ff7c5b23a | |||
| 0d1020e503 | |||
| 28678ab17f | |||
| 93f9e88fc4 | |||
| bd116ff247 | |||
| 02a682b401 | |||
|
|
f1a85da721 | ||
|
|
fe820c7969 | ||
|
|
6fe9c84034 | ||
|
|
72f9993261 | ||
|
|
3f00bf209f | ||
|
|
71b6388660 | ||
|
|
62c8d355ca |
@@ -6,15 +6,16 @@ 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"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=DocumentListResponse)
|
@router.get("", response_model=DocumentListResponse)
|
||||||
async def list_user_documents(
|
async def list_user_documents(
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
user: AuthenticatedUser = Depends(get_current_user),
|
user: AuthenticatedUser = Depends(get_current_user),
|
||||||
@@ -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,
|
||||||
@@ -58,7 +60,7 @@ async def download_version_docx(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=DocumentResponse)
|
@router.post("", response_model=DocumentResponse)
|
||||||
async def upload_document(
|
async def upload_document(
|
||||||
title: str = Form(...),
|
title: str = Form(...),
|
||||||
description: str | None = Form(default=None),
|
description: str | None = Form(default=None),
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
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
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
|
||||||
asset = result.scalars().one_or_none()
|
|
||||||
if not asset:
|
@router.get("/{slug}/pdf")
|
||||||
raise HTTPException(status_code=404, detail="Asset not found")
|
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,
|
||||||
|
|||||||
@@ -9,15 +9,44 @@ from app.schemas import (
|
|||||||
SubmissionCreateRequest,
|
SubmissionCreateRequest,
|
||||||
SubmissionResponse,
|
SubmissionResponse,
|
||||||
SuggestionResponse,
|
SuggestionResponse,
|
||||||
|
SuggestionUpdateRequest,
|
||||||
|
)
|
||||||
|
from app.services.submissions import (
|
||||||
|
create_submission,
|
||||||
|
get_submission,
|
||||||
|
list_submissions,
|
||||||
|
request_ai_suggestions,
|
||||||
|
update_suggestion,
|
||||||
)
|
)
|
||||||
from app.services.submissions import create_submission, request_ai_suggestions
|
|
||||||
from dlib.auth import AuthenticatedUser
|
from dlib.auth import AuthenticatedUser
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/submissions", tags=["submissions"])
|
router = APIRouter(prefix="/submissions", tags=["submissions"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=SubmissionResponse)
|
@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)
|
||||||
async def create_submission_endpoint(
|
async def create_submission_endpoint(
|
||||||
payload: SubmissionCreateRequest,
|
payload: SubmissionCreateRequest,
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
@@ -54,3 +83,23 @@ async def request_submissions_ai(
|
|||||||
if suggestions is None:
|
if suggestions is None:
|
||||||
raise HTTPException(status_code=404, detail="Submission not found")
|
raise HTTPException(status_code=404, detail="Submission not found")
|
||||||
return [SuggestionResponse.model_validate(item) for item in suggestions]
|
return [SuggestionResponse.model_validate(item) for item in suggestions]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{submission_id}/suggestions/{suggestion_id}", response_model=SuggestionResponse)
|
||||||
|
async def update_suggestion_endpoint(
|
||||||
|
submission_id: str,
|
||||||
|
suggestion_id: str,
|
||||||
|
payload: SuggestionUpdateRequest,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
suggestion = await update_suggestion(
|
||||||
|
session,
|
||||||
|
owner_id=user.sub,
|
||||||
|
submission_id=submission_id,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
accepted=payload.accepted,
|
||||||
|
)
|
||||||
|
if not suggestion:
|
||||||
|
raise HTTPException(status_code=404, detail="Suggestion not found")
|
||||||
|
return SuggestionResponse.model_validate(suggestion)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"
|
"CvVersion",
|
||||||
|
back_populates="document",
|
||||||
|
foreign_keys="[CvVersion.document_id]",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -41,7 +46,9 @@ class CvVersion(Base, IdentifierMixin, TimestampMixin):
|
|||||||
structured_blocks: Mapped[list[dict] | None] = mapped_column(JSONB, default=list)
|
structured_blocks: Mapped[list[dict] | None] = mapped_column(JSONB, default=list)
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
|
metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
|
||||||
|
|
||||||
document: Mapped[CvDocument] = relationship("CvDocument", back_populates="versions")
|
document: Mapped[CvDocument] = relationship(
|
||||||
|
"CvDocument", back_populates="versions", foreign_keys="[CvVersion.document_id]"
|
||||||
|
)
|
||||||
parent: Mapped["CvVersion | None"] = relationship(
|
parent: Mapped["CvVersion | None"] = relationship(
|
||||||
"CvVersion", remote_side="[CvVersion.id]"
|
"CvVersion", remote_side="[CvVersion.id]"
|
||||||
)
|
)
|
||||||
@@ -132,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):
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ from .cv import (
|
|||||||
DocumentCreateResult,
|
DocumentCreateResult,
|
||||||
DocumentListResponse,
|
DocumentListResponse,
|
||||||
DocumentResponse,
|
DocumentResponse,
|
||||||
|
PublicAssetAnalyticsResponse,
|
||||||
PublicAssetLookupResponse,
|
PublicAssetLookupResponse,
|
||||||
PublicAssetResponse,
|
PublicAssetResponse,
|
||||||
PublishRequest,
|
PublishRequest,
|
||||||
SubmissionCreateRequest,
|
SubmissionCreateRequest,
|
||||||
SubmissionResponse,
|
SubmissionResponse,
|
||||||
SuggestionResponse,
|
SuggestionResponse,
|
||||||
|
SuggestionUpdateRequest,
|
||||||
VersionResponse,
|
VersionResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +25,9 @@ __all__ = [
|
|||||||
"SubmissionResponse",
|
"SubmissionResponse",
|
||||||
"AiSuggestionRequest",
|
"AiSuggestionRequest",
|
||||||
"SuggestionResponse",
|
"SuggestionResponse",
|
||||||
|
"SuggestionUpdateRequest",
|
||||||
"PublishRequest",
|
"PublishRequest",
|
||||||
"PublicAssetResponse",
|
"PublicAssetResponse",
|
||||||
"PublicAssetLookupResponse",
|
"PublicAssetLookupResponse",
|
||||||
|
"PublicAssetAnalyticsResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -123,3 +123,13 @@ class PublicAssetResponse(BaseModel):
|
|||||||
|
|
||||||
class PublicAssetLookupResponse(BaseModel):
|
class PublicAssetLookupResponse(BaseModel):
|
||||||
asset: PublicAssetResponse
|
asset: PublicAssetResponse
|
||||||
|
|
||||||
|
|
||||||
|
class PublicAssetAnalyticsResponse(BaseModel):
|
||||||
|
slug: str
|
||||||
|
view_count: int
|
||||||
|
last_viewed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SuggestionUpdateRequest(BaseModel):
|
||||||
|
accepted: bool
|
||||||
|
|||||||
@@ -36,8 +36,15 @@ async def create_document(
|
|||||||
|
|
||||||
session.add(doc)
|
session.add(doc)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(doc, attribute_names=["versions"])
|
await session.refresh(doc)
|
||||||
return doc
|
|
||||||
|
stmt = (
|
||||||
|
select(CvDocument)
|
||||||
|
.where(CvDocument.id == doc.id)
|
||||||
|
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalars().unique().one()
|
||||||
|
|
||||||
|
|
||||||
async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocument]:
|
async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocument]:
|
||||||
@@ -61,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
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ async def publish_version(
|
|||||||
if submission_id:
|
if submission_id:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Submission)
|
select(Submission)
|
||||||
.join(CvVersion)
|
.join(Submission.version)
|
||||||
.join(CvDocument)
|
.join(CvVersion.document)
|
||||||
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
|
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
@@ -34,7 +34,7 @@ async def publish_version(
|
|||||||
elif version_id:
|
elif version_id:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(CvVersion)
|
select(CvVersion)
|
||||||
.join(CvDocument)
|
.join(CvVersion.document)
|
||||||
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
|
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
|
|||||||
@@ -84,12 +84,61 @@ async def request_ai_suggestions(
|
|||||||
return created
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
async def list_submissions(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
owner_id: str,
|
||||||
|
version_id: str | None = None,
|
||||||
|
) -> list[Submission]:
|
||||||
|
stmt = (
|
||||||
|
select(Submission)
|
||||||
|
.join(Submission.version)
|
||||||
|
.join(CvVersion.document)
|
||||||
|
.where(CvDocument.owner_id == owner_id)
|
||||||
|
.options(selectinload(Submission.suggestions))
|
||||||
|
)
|
||||||
|
if version_id:
|
||||||
|
stmt = stmt.where(Submission.version_id == version_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_submission(
|
||||||
|
session: AsyncSession, *, owner_id: str, submission_id: str
|
||||||
|
) -> Submission | None:
|
||||||
|
return await _get_submission_for_owner(session, owner_id, submission_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_suggestion(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
owner_id: str,
|
||||||
|
submission_id: str,
|
||||||
|
suggestion_id: str,
|
||||||
|
accepted: bool,
|
||||||
|
) -> AiSuggestion | None:
|
||||||
|
submission = await _get_submission_for_owner(session, owner_id, submission_id)
|
||||||
|
if not submission:
|
||||||
|
return None
|
||||||
|
stmt = select(AiSuggestion).where(
|
||||||
|
AiSuggestion.id == suggestion_id, AiSuggestion.submission_id == submission_id
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
suggestion = result.scalars().one_or_none()
|
||||||
|
if not suggestion:
|
||||||
|
return None
|
||||||
|
suggestion.accepted = accepted
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(suggestion)
|
||||||
|
return suggestion
|
||||||
|
|
||||||
|
|
||||||
async def _get_version_for_owner(
|
async def _get_version_for_owner(
|
||||||
session: AsyncSession, owner_id: str, version_id: str
|
session: AsyncSession, owner_id: str, version_id: str
|
||||||
) -> CvVersion | None:
|
) -> CvVersion | None:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(CvVersion)
|
select(CvVersion)
|
||||||
.join(CvDocument)
|
.join(CvVersion.document)
|
||||||
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
|
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
@@ -103,8 +152,8 @@ async def _get_submission_for_owner(
|
|||||||
) -> Submission | None:
|
) -> Submission | None:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Submission)
|
select(Submission)
|
||||||
.join(CvVersion)
|
.join(Submission.version)
|
||||||
.join(CvDocument)
|
.join(CvVersion.document)
|
||||||
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
|
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
|
||||||
.options(selectinload(Submission.version))
|
.options(selectinload(Submission.version))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async def create_branch(
|
|||||||
) -> CvVersion | None:
|
) -> CvVersion | None:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(CvVersion)
|
select(CvVersion)
|
||||||
.join(CvDocument)
|
.join(CvVersion.document)
|
||||||
.where(CvVersion.id == parent_version_id, CvDocument.owner_id == owner_id)
|
.where(CvVersion.id == parent_version_id, CvDocument.owner_id == owner_id)
|
||||||
.options(selectinload(CvVersion.patches))
|
.options(selectinload(CvVersion.patches))
|
||||||
)
|
)
|
||||||
@@ -74,5 +74,36 @@ async def create_branch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(new_version)
|
|
||||||
return new_version
|
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
|
||||||
|
|||||||
@@ -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*` }];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
56
apps/webapp/src/app/api/auth/callback/route.ts
Normal file
56
apps/webapp/src/app/api/auth/callback/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
27
apps/webapp/src/app/api/auth/login/route.ts
Normal file
27
apps/webapp/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
8
apps/webapp/src/app/api/auth/logout/route.ts
Normal file
8
apps/webapp/src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
6
apps/webapp/src/app/api/auth/token/route.ts
Normal file
6
apps/webapp/src/app/api/auth/token/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
38
apps/webapp/src/app/cv/[slug]/route.ts
Normal file
38
apps/webapp/src/app/cv/[slug]/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -5,26 +5,29 @@ 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, publishVersion, uploadDocument, Version,
|
Document, downloadVersionUrl,
|
||||||
|
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
|
||||||
|
publishVersion, PublicAsset, PublicAssetAnalytics,
|
||||||
|
requestAiSuggestions,
|
||||||
|
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
|
||||||
} from '@/libs/api';
|
} from '@/libs/api';
|
||||||
|
|
||||||
// ─── tiny helpers ────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function fmt(iso: string) {
|
function fmt(iso: string) {
|
||||||
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function statusBadge(status: string) {
|
||||||
return (
|
const cls = ({
|
||||||
<div>
|
draft: 'badge-draft', tailoring: 'badge-submitted', pending_review: 'badge-interviewing',
|
||||||
<div className="label" style={{ padding: '0 0 8px' }}>{title}</div>
|
published: 'badge-public', archived: 'badge-closed',
|
||||||
{children}
|
} as Record<string, string>)[status] ?? 'badge-draft';
|
||||||
</div>
|
return <span className={`badge ${cls}`}>{status.replace('_', ' ')}</span>;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── modals ───────────────────────────────────────────────────────────────────
|
// ── modals ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: Document) => void }) {
|
function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: Document) => void }) {
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
@@ -46,17 +49,18 @@ function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: D
|
|||||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||||
<div className="modal-title">Upload CV</div>
|
<div className="modal-title">Upload CV</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
<input placeholder="Title (e.g. My Resume)" value={title} onChange={e => setTitle(e.target.value)} />
|
<input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} autoFocus />
|
||||||
<input placeholder="Description (optional)" value={desc} onChange={e => setDesc(e.target.value)} />
|
<input placeholder="Description (optional)" value={desc} onChange={e => setDesc(e.target.value)} />
|
||||||
<div
|
<div onClick={() => ref.current?.click()} style={{
|
||||||
onClick={() => ref.current?.click()}
|
border: '1px dashed var(--border-strong)', borderRadius: 5, padding: '16px 0',
|
||||||
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)' }}
|
textAlign: 'center', cursor: 'pointer', fontSize: 13,
|
||||||
>
|
color: file ? 'var(--text)' : 'var(--text-muted)',
|
||||||
{file ? file.name : 'Click to select .docx file'}
|
}}>
|
||||||
|
{file ? file.name : 'Click to select .docx'}
|
||||||
</div>
|
</div>
|
||||||
<input ref={ref} type="file" accept=".docx" style={{ display: 'none' }} onChange={e => setFile(e.target.files?.[0] ?? null)} />
|
<input ref={ref} type="file" accept=".docx" style={{ display: 'none' }} onChange={e => setFile(e.target.files?.[0] ?? null)} />
|
||||||
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
|
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
|
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
|
||||||
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
|
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
|
||||||
{loading ? 'Uploading…' : 'Upload'}
|
{loading ? 'Uploading…' : 'Upload'}
|
||||||
@@ -68,31 +72,51 @@ function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: D
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BranchModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (v: Version) => void }) {
|
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;
|
||||||
|
}) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [label, setLabel] = useState('');
|
const [label, setLabel] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const patches = initialPatches ?? [];
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!name.trim()) { setError('Branch name required.'); return; }
|
if (!name.trim()) { setError('Branch name required.'); return; }
|
||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
try { onDone(await createBranch(version.id, name.trim(), label.trim() || null)); }
|
try { onDone(await createBranch(version.id, name.trim(), label.trim() || null, patches as Record<string, unknown>[])); }
|
||||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
|
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" onClick={onClose}>
|
||||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
<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-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 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
<input placeholder="Branch name (e.g. ml-engineer)" value={name} onChange={e => setName(e.target.value)} />
|
<input placeholder="Branch name (e.g. ml-engineer)" value={name} onChange={e => setName(e.target.value)} autoFocus />
|
||||||
<input placeholder="Label (optional)" value={label} onChange={e => setLabel(e.target.value)} />
|
<input placeholder="Label (optional)" value={label} onChange={e => setLabel(e.target.value)} />
|
||||||
|
{patches.length > 0 && (
|
||||||
|
<div style={{ padding: '8px 10px', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }}>
|
||||||
|
<div className="label" style={{ marginBottom: 6 }}>Staged edits ({patches.length})</div>
|
||||||
|
{patches.map((p, i) => (
|
||||||
|
<div key={i} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>
|
||||||
|
± {p.target_path}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
|
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
|
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
|
||||||
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
|
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
|
||||||
{loading ? 'Creating…' : 'Create'}
|
{loading ? 'Creating…' : 'Create branch'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,30 +125,38 @@ function BranchModal({ version, onClose, onDone }: { version: Version; onClose:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubmissionModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: () => void }) {
|
function SubmissionModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (s: Submission) => void }) {
|
||||||
const [company, setCompany] = useState('');
|
const [company, setCompany] = useState('');
|
||||||
const [role, setRole] = useState('');
|
const [role, setRole] = useState('');
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
|
const [jd, setJd] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; }
|
if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; }
|
||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
try { await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null); onDone(); }
|
try { onDone(await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null, jd.trim() || null)); }
|
||||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
|
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" onClick={onClose}>
|
||||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 480 }}>
|
||||||
<div className="modal-title">New submission from <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 400 }}>{version.branch_name}</span></div>
|
<div className="modal-title">New submission from <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 400 }}>{version.branch_name}</span></div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
<input placeholder="Company name" value={company} onChange={e => setCompany(e.target.value)} />
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<input placeholder="Role title" value={role} onChange={e => setRole(e.target.value)} />
|
<input placeholder="Company" value={company} onChange={e => setCompany(e.target.value)} autoFocus />
|
||||||
|
<input placeholder="Role title" value={role} onChange={e => setRole(e.target.value)} />
|
||||||
|
</div>
|
||||||
<input placeholder="Job URL (optional)" value={url} onChange={e => setUrl(e.target.value)} />
|
<input placeholder="Job URL (optional)" value={url} onChange={e => setUrl(e.target.value)} />
|
||||||
|
<textarea
|
||||||
|
placeholder="Paste job description (used for AI tailoring)"
|
||||||
|
value={jd} onChange={e => setJd(e.target.value)}
|
||||||
|
style={{ height: 100, resize: 'vertical' }}
|
||||||
|
/>
|
||||||
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
|
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
|
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
|
||||||
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
|
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
|
||||||
{loading ? 'Saving…' : 'Create'}
|
{loading ? 'Saving…' : 'Create'}
|
||||||
@@ -136,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('');
|
||||||
@@ -145,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); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -154,12 +186,12 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
|
|||||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||||
<div className="modal-title">Publish version</div>
|
<div className="modal-title">Publish version</div>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}>
|
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||||
Creates an immutable public artifact. The link stays stable even if you edit further.
|
Freezes an immutable public artifact. Existing shares remain stable.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
<input placeholder="Custom slug (optional)" value={slug} onChange={e => setSlug(e.target.value)} />
|
<input placeholder="Custom slug (optional)" value={slug} onChange={e => setSlug(e.target.value)} autoFocus />
|
||||||
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
|
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
|
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
|
||||||
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
|
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
|
||||||
{loading ? 'Publishing…' : 'Publish'}
|
{loading ? 'Publishing…' : 'Publish'}
|
||||||
@@ -171,9 +203,275 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── main dashboard ───────────────────────────────────────────────────────────
|
// ── 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 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null;
|
type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null;
|
||||||
|
type Tab = 'content' | 'patches' | 'submissions';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [docs, setDocs] = useState<Document[]>([]);
|
const [docs, setDocs] = useState<Document[]>([]);
|
||||||
@@ -182,25 +480,50 @@ 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 [submissions, setSubmissions] = useState<Submission[]>([]);
|
||||||
|
const [subsLoading, setSubsLoading] = useState(false);
|
||||||
|
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [docHovered, setDocHovered] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDocuments()
|
fetchDocuments()
|
||||||
.then(d => { setDocs(d); if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); } })
|
.then(d => {
|
||||||
.catch(e => setError(e.message))
|
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.'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPendingEdits(new Map());
|
||||||
|
}, [selectedVersionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'submissions' || !selectedVersionId) return;
|
||||||
|
setSubsLoading(true);
|
||||||
|
fetchSubmissions(selectedVersionId)
|
||||||
|
.then(setSubmissions)
|
||||||
|
.catch(() => { })
|
||||||
|
.finally(() => setSubsLoading(false));
|
||||||
|
}, [activeTab, selectedVersionId]);
|
||||||
|
|
||||||
const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null;
|
const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null;
|
||||||
const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null;
|
const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null;
|
||||||
|
|
||||||
const refreshDocs = async () => {
|
const refreshDocs = async () => {
|
||||||
try {
|
const fresh = await fetchDocuments().catch(() => docs);
|
||||||
const fresh = await fetchDocuments();
|
setDocs(fresh);
|
||||||
setDocs(fresh);
|
return fresh;
|
||||||
const doc = fresh.find(d => d.id === selectedDocId) ?? fresh[0] ?? null;
|
};
|
||||||
if (doc) { setSelectedDocId(doc.id); }
|
|
||||||
} catch { /* silent */ }
|
const refreshSubs = () => {
|
||||||
|
if (!selectedVersionId) return;
|
||||||
|
fetchSubmissions(selectedVersionId).then(setSubmissions).catch(() => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUploadDone = (doc: Document) => {
|
const onUploadDone = (doc: Document) => {
|
||||||
@@ -208,28 +531,117 @@ 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 = (v: Version) => {
|
const onBranchDone = async (v: Version) => {
|
||||||
refreshDocs().then(() => setSelectedVersionId(v.id));
|
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());
|
||||||
setModal(null);
|
setModal(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSubmissionDone = (s: Submission) => {
|
||||||
|
setSubmissions(prev => [s, ...prev]);
|
||||||
|
setModal(null);
|
||||||
|
setActiveTab('submissions');
|
||||||
|
};
|
||||||
|
|
||||||
|
const stageEdit = (path: string, old_value: string, new_value: string) => {
|
||||||
|
setPendingEdits(prev => new Map(prev).set(path, { old_value, new_value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const discardEdits = () => setPendingEdits(new Map());
|
||||||
|
|
||||||
|
const handleDeleteDoc = async (docId: string) => {
|
||||||
|
if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
await deleteDocument(docId);
|
||||||
|
const updated = docs.filter(d => d.id !== docId);
|
||||||
|
setDocs(updated);
|
||||||
|
if (selectedDocId === docId) {
|
||||||
|
setSelectedDocId(updated[0]?.id ?? null);
|
||||||
|
setSelectedVersionId(updated[0]?.root_version_id ?? null);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Delete failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteVersion = async (versionId: string) => {
|
||||||
|
const version = selectedDoc?.versions.find(v => v.id === versionId);
|
||||||
|
const hasChildren = selectedDoc?.versions.some(v => v.parent_version_id === versionId);
|
||||||
|
const msg = hasChildren
|
||||||
|
? 'Delete this branch and all its sub-branches? This cannot be undone.'
|
||||||
|
: 'Delete this branch? This cannot be undone.';
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
try {
|
||||||
|
await deleteVersion(versionId);
|
||||||
|
const fresh = await refreshDocs();
|
||||||
|
if (selectedVersionId === versionId) {
|
||||||
|
const doc = fresh.find(d => d.id === selectedDocId);
|
||||||
|
setSelectedVersionId(doc?.root_version_id ?? null);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Delete failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectVersion = (id: string) => {
|
||||||
|
setSelectedVersionId(id);
|
||||||
|
setActiveTab('content');
|
||||||
|
setSidebarOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingCount = pendingEdits.size;
|
||||||
|
const stagedPatches = [...pendingEdits.entries()].map(([path, { old_value, new_value }]) => ({
|
||||||
|
target_path: path, operation: 'replace_text', old_value, new_value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
window.location.href = '/login';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden', background: 'var(--bg)' }}>
|
<div className="dashboard-root">
|
||||||
{/* top bar */}
|
{/* top bar */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px', height: 44, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
<div className="topbar">
|
||||||
<Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
Resume Branches
|
<button
|
||||||
</Link>
|
className="btn btn-ghost sidebar-toggle"
|
||||||
<button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: 12 }} onClick={() => setModal('upload')}>
|
style={{ padding: '4px 8px', fontSize: 16 }}
|
||||||
+ Upload CV
|
onClick={() => setSidebarOpen(o => !o)}
|
||||||
</button>
|
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>
|
</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={{ width: 240, flexShrink: 0, borderRight: '1px solid var(--border)', background: 'var(--surface)', overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
<div className={`sidebar${sidebarOpen ? ' sidebar-open' : ''}`}>
|
||||||
{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>}
|
||||||
|
|
||||||
@@ -244,30 +656,60 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{docs.length > 0 && (
|
{docs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* document selector */}
|
|
||||||
<div style={{ padding: '10px 12px 6px' }}>
|
<div style={{ padding: '10px 12px 6px' }}>
|
||||||
<div className="label" style={{ marginBottom: 6 }}>Documents</div>
|
<div className="label" style={{ marginBottom: 6 }}>Documents</div>
|
||||||
{docs.map(d => (
|
{docs.map(d => (
|
||||||
<div
|
<div
|
||||||
key={d.id}
|
key={d.id}
|
||||||
onClick={() => { setSelectedDocId(d.id); setSelectedVersionId(d.root_version_id ?? null); }}
|
onMouseEnter={() => setDocHovered(d.id)}
|
||||||
style={{ padding: '5px 8px', borderRadius: 4, cursor: 'pointer', fontSize: 13, fontWeight: d.id === selectedDocId ? 600 : 400, background: d.id === selectedDocId ? 'var(--selected-bg)' : 'transparent' }}
|
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,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{d.title}
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="divider" style={{ margin: '6px 0' }} />
|
<hr className="divider" style={{ margin: '4px 0' }} />
|
||||||
|
|
||||||
{/* version tree */}
|
|
||||||
{selectedDoc && (
|
{selectedDoc && (
|
||||||
<div style={{ padding: '6px 0' }}>
|
<div style={{ padding: '6px 0' }}>
|
||||||
<div className="label" style={{ padding: '0 12px 6px' }}>Versions</div>
|
<div className="label" style={{ padding: '0 12px 6px' }}>Branches</div>
|
||||||
<CVTree
|
<CVTree
|
||||||
versions={selectedDoc.versions}
|
versions={selectedDoc.versions}
|
||||||
selectedVersionId={selectedVersionId}
|
selectedVersionId={selectedVersionId}
|
||||||
onSelect={setSelectedVersionId}
|
onSelect={selectVersion}
|
||||||
|
onDeleteVersion={handleDeleteVersion}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -275,85 +717,151 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* main content */}
|
{/* main panel */}
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>
|
<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 version to view details.
|
Select a branch to view details.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedVersion && (
|
{selectedVersion && (
|
||||||
<div style={{ maxWidth: 680 }}>
|
<>
|
||||||
{/* version header */}
|
{/* version header */}
|
||||||
<div style={{ marginBottom: 20 }}>
|
<div style={{ padding: '16px 20px 0', flexShrink: 0 }}>
|
||||||
<h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
|
||||||
{selectedVersion.version_label || selectedVersion.branch_name}
|
<div style={{ minWidth: 0 }}>
|
||||||
</h2>
|
<h2 style={{ fontSize: 17, fontWeight: 600, marginBottom: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-muted)' }}>
|
{selectedVersion.version_label || selectedVersion.branch_name}
|
||||||
{selectedVersion.parent_version_id && (
|
</h2>
|
||||||
<span>
|
<div style={{ display: 'flex', gap: 14, fontSize: 12, color: 'var(--text-muted)', flexWrap: 'wrap' }}>
|
||||||
branched from{' '}
|
{selectedVersion.parent_version_id ? (
|
||||||
<span style={{ fontFamily: 'var(--font-mono)' }}>
|
<span>
|
||||||
{selectedDoc?.versions.find(v => v.id === selectedVersion.parent_version_id)?.branch_name ?? '…'}
|
branched from{' '}
|
||||||
</span>
|
<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' : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
<button
|
||||||
{!selectedVersion.parent_version_id && <span style={{ fontFamily: 'var(--font-mono)' }}>root</span>}
|
className="btn btn-primary"
|
||||||
<span>{fmt(selectedVersion.created_at)}</span>
|
style={{ fontSize: 12, padding: '3px 10px', background: '#92400e', borderColor: '#92400e' }}
|
||||||
{selectedVersion.patches.length > 0 && (
|
onClick={() => setModal('branch')}
|
||||||
<span>{selectedVersion.patches.length} patch{selectedVersion.patches.length !== 1 ? 'es' : ''}</span>
|
>
|
||||||
)}
|
Save as branch
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={discardEdits}>
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', overflowX: 'auto' }}>
|
||||||
|
{(['content', 'patches', 'submissions'] as Tab[]).map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setActiveTab(t)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px', fontSize: 13, background: 'none', border: 'none',
|
||||||
|
cursor: 'pointer', color: activeTab === t ? 'var(--text)' : 'var(--text-muted)',
|
||||||
|
borderBottom: activeTab === t ? '2px solid var(--text)' : '2px solid transparent',
|
||||||
|
fontWeight: activeTab === t ? 500 : 400,
|
||||||
|
marginBottom: -1, transition: 'color 0.1s', whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t === 'patches' ? `Patches (${selectedVersion.patches.length})` : t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* action bar */}
|
{/* tab content */}
|
||||||
<div style={{ display: 'flex', gap: 6, marginBottom: 24, flexWrap: 'wrap' }}>
|
<div style={{ padding: '16px 20px', flex: 1, overflow: 'auto' }}>
|
||||||
<button className="btn btn-ghost" onClick={() => setModal('branch')}>New branch</button>
|
{activeTab === 'content' && (
|
||||||
<button className="btn btn-ghost" onClick={() => setModal('submission')}>New submission</button>
|
<ContentTab
|
||||||
<button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>
|
blocks={selectedVersion.structured_blocks ?? []}
|
||||||
{selectedVersion.artifact_docx_key && selectedDoc && (
|
pendingEdits={pendingEdits}
|
||||||
<a
|
onEdit={stageEdit}
|
||||||
href={downloadVersionUrl(selectedDoc.id, selectedVersion.id)}
|
/>
|
||||||
download
|
)}
|
||||||
className="btn btn-ghost"
|
{activeTab === 'patches' && (
|
||||||
>
|
<DiffViewer patches={selectedVersion.patches} />
|
||||||
↓ DOCX
|
)}
|
||||||
</a>
|
{activeTab === 'submissions' && (
|
||||||
|
<SubmissionsTab
|
||||||
|
submissions={submissions}
|
||||||
|
loading={subsLoading}
|
||||||
|
versionId={selectedVersionId!}
|
||||||
|
onNewSubmission={() => setModal('submission')}
|
||||||
|
onRefresh={refreshSubs}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,16 +871,21 @@ export default function Dashboard() {
|
|||||||
<UploadModal onClose={() => setModal(null)} onDone={onUploadDone} />
|
<UploadModal onClose={() => setModal(null)} onDone={onUploadDone} />
|
||||||
)}
|
)}
|
||||||
{modal === 'branch' && selectedVersion && (
|
{modal === 'branch' && selectedVersion && (
|
||||||
<BranchModal version={selectedVersion} onClose={() => setModal(null)} onDone={onBranchDone} />
|
<BranchModal
|
||||||
|
version={selectedVersion}
|
||||||
|
initialPatches={stagedPatches}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
onDone={onBranchDone}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{modal === 'submission' && selectedVersion && (
|
{modal === 'submission' && selectedVersion && (
|
||||||
<SubmissionModal version={selectedVersion} onClose={() => setModal(null)} onDone={() => { setModal(null); }} />
|
<SubmissionModal version={selectedVersion} onClose={() => setModal(null)} onDone={onSubmissionDone} />
|
||||||
)}
|
)}
|
||||||
{modal === 'publish' && selectedVersion && (
|
{modal === 'publish' && selectedVersion && (
|
||||||
<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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -1,46 +1,3 @@
|
|||||||
'use server'
|
// Auth is handled via /api/auth/* route handlers.
|
||||||
|
// This file retained for compatibility; Supabase sign-in is no longer used.
|
||||||
import { revalidatePath } from 'next/cache'
|
export {};
|
||||||
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('/')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,14 +1,144 @@
|
|||||||
import { login, signup } from './actions'
|
'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}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
const router = useRouter();
|
||||||
<form>
|
const [username, setUsername] = useState('');
|
||||||
<label htmlFor="email">Email:</label>
|
const [password, setPassword] = useState('');
|
||||||
<input id="email" name="email" type="email" required />
|
const [loading, setLoading] = useState(false);
|
||||||
<label htmlFor="password">Password:</label>
|
const [error, setError] = useState('');
|
||||||
<input id="password" name="password" type="password" required />
|
const oidcUrl = typeof window !== 'undefined' ? authentikUrl() : null;
|
||||||
<button formAction={login}>Log in</button>
|
|
||||||
<button formAction={signup}>Sign up</button>
|
const submit = async (e: React.FormEvent) => {
|
||||||
</form>
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]) => (
|
||||||
|
|||||||
@@ -15,57 +15,136 @@ function buildTree(versions: Version[]): TreeNode | null {
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Node({ node, depth, selectedId, onSelect }: {
|
const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2'];
|
||||||
node: TreeNode; depth: number; selectedId: string | null; onSelect: (id: string) => void;
|
|
||||||
|
function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
||||||
|
node: TreeNode; depth: number; selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
|
colorIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
const v = node.version;
|
const v = node.version;
|
||||||
|
const isRoot = !v.parent_version_id;
|
||||||
const isSelected = v.id === selectedId;
|
const isSelected = v.id === selectedId;
|
||||||
const hasChildren = node.children.length > 0;
|
const isLeaf = node.children.length === 0;
|
||||||
|
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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)}
|
onClick={() => onSelect(v.id)}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 4,
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
paddingLeft: 12 + depth * 16, paddingRight: 8,
|
paddingLeft: depth > 0 ? 18 : 8, paddingRight: 4,
|
||||||
height: 30, cursor: 'pointer',
|
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 ? '2px solid var(--selected-border)' : '2px solid transparent',
|
borderLeft: isSelected && depth === 0 ? '2px solid var(--selected-border)' : '2px solid transparent',
|
||||||
transition: 'background 0.1s',
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'var(--hover)'; }}
|
|
||||||
onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); setOpen(o => !o); }}
|
onClick={e => { e.stopPropagation(); setOpen(o => !o); }}
|
||||||
style={{ 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 }}
|
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',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" style={{ transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}>
|
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor"
|
||||||
|
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.12s' }}>
|
||||||
<path d="M2 1l4 3-4 3V1z" />
|
<path d="M2 1l4 3-4 3V1z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span style={{ flex: 1, fontSize: 13, fontWeight: !v.parent_version_id ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text)' }}>
|
<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)',
|
||||||
|
}}>
|
||||||
{v.version_label || v.branch_name}
|
{v.version_label || v.branch_name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{v.patches.length > 0 && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, color: 'var(--text-faint)',
|
||||||
|
background: 'var(--hover)', padding: '1px 4px',
|
||||||
|
borderRadius: 3, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{v.patches.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isRoot && onDelete && hovered && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
|
||||||
|
title="Delete branch"
|
||||||
|
aria-label="Delete branch"
|
||||||
|
style={{
|
||||||
|
width: 18, height: 18, display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'center', cursor: 'pointer', background: 'none',
|
||||||
|
border: 'none', padding: 0, color: '#dc2626', flexShrink: 0,
|
||||||
|
borderRadius: 3, fontSize: 14, lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{open && node.children.map(child => (
|
|
||||||
<Node key={child.version.id} node={child} depth={depth + 1} selectedId={selectedId} onSelect={onSelect} />
|
{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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// Empty base: all API calls go to /api/* which Next.js rewrites to the backend.
|
|
||||||
// The actual backend URL is set via API_BASE_URL env var in next.config.ts (server-side, runtime).
|
|
||||||
const API = "";
|
const API = "";
|
||||||
|
|
||||||
export type StructuredBlock = {
|
export type StructuredBlock = {
|
||||||
@@ -42,6 +40,16 @@ export type Document = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Suggestion = {
|
||||||
|
id: string;
|
||||||
|
target_path: string;
|
||||||
|
operation: string;
|
||||||
|
proposed_text?: string | null;
|
||||||
|
rationale?: string | null;
|
||||||
|
accepted?: boolean | null;
|
||||||
|
metadata_json?: { keywords?: string[]; confidence?: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type Submission = {
|
export type Submission = {
|
||||||
id: string;
|
id: string;
|
||||||
version_id: string;
|
version_id: string;
|
||||||
@@ -50,6 +58,7 @@ export type Submission = {
|
|||||||
job_url?: string | null;
|
job_url?: string | null;
|
||||||
job_description?: string | null;
|
job_description?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
suggestions: Suggestion[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,10 +73,23 @@ export type PublicAsset = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PublicAssetAnalytics = {
|
||||||
|
slug: string;
|
||||||
|
view_count: number;
|
||||||
|
last_viewed_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// reads OIDC bearer token from client-readable cookie (set by /api/auth/callback)
|
||||||
|
function getAuthHeader(): Record<string, string> {
|
||||||
|
if (typeof document === 'undefined') return {};
|
||||||
|
const token = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('oidc_token_pub='))?.split('=')[1];
|
||||||
|
return token ? { authorization: `Bearer ${decodeURIComponent(token)}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${API}${path}`, {
|
const res = await fetch(`${API}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: { accept: "application/json", ...init?.headers },
|
headers: { accept: 'application/json', ...getAuthHeader(), ...init?.headers },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await res.text().catch(() => res.statusText);
|
const detail = await res.text().catch(() => res.statusText);
|
||||||
@@ -77,17 +99,17 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const fetchDocuments = (): Promise<Document[]> =>
|
export const fetchDocuments = (): Promise<Document[]> =>
|
||||||
req<{ items: Document[] }>("/api/v1/documents", { cache: "no-store" }).then(r => r.items);
|
req<{ items: Document[] }>('/api/v1/documents', { cache: 'no-store' }).then(r => r.items);
|
||||||
|
|
||||||
export const fetchDocument = (id: string): Promise<Document> =>
|
export const fetchDocument = (id: string): Promise<Document> =>
|
||||||
req<Document>(`/api/v1/documents/${id}`, { cache: "no-store" });
|
req<Document>(`/api/v1/documents/${id}`, { cache: 'no-store' });
|
||||||
|
|
||||||
export async function uploadDocument(title: string, description: string | null, file: File): Promise<Document> {
|
export async function uploadDocument(title: string, description: string | null, file: File): Promise<Document> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("title", title);
|
form.append('title', title);
|
||||||
if (description) form.append("description", description);
|
if (description) form.append('description', description);
|
||||||
form.append("file", file);
|
form.append('file', file);
|
||||||
return req<Document>("/api/v1/documents", { method: "POST", body: form });
|
return req<Document>('/api/v1/documents', { method: 'POST', body: form });
|
||||||
}
|
}
|
||||||
|
|
||||||
export const downloadVersionUrl = (documentId: string, versionId: string): string =>
|
export const downloadVersionUrl = (documentId: string, versionId: string): string =>
|
||||||
@@ -99,9 +121,9 @@ export async function createBranch(
|
|||||||
versionLabel?: string | null,
|
versionLabel?: string | null,
|
||||||
patches: Record<string, unknown>[] = [],
|
patches: Record<string, unknown>[] = [],
|
||||||
): Promise<Version> {
|
): Promise<Version> {
|
||||||
return req<Version>("/api/v1/versions/branches", {
|
return req<Version>('/api/v1/versions/branches', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { "content-type": "application/json" },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ parent_version_id: parentVersionId, branch_name: branchName, version_label: versionLabel ?? null, patches }),
|
body: JSON.stringify({ parent_version_id: parentVersionId, branch_name: branchName, version_label: versionLabel ?? null, patches }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -113,22 +135,79 @@ export async function createSubmission(
|
|||||||
jobUrl?: string | null,
|
jobUrl?: string | null,
|
||||||
jobDescription?: string | null,
|
jobDescription?: string | null,
|
||||||
): Promise<Submission> {
|
): Promise<Submission> {
|
||||||
return req<Submission>("/api/v1/submissions", {
|
return req<Submission>('/api/v1/submissions', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { "content-type": "application/json" },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ version_id: versionId, company_name: companyName, role_title: roleTitle, job_url: jobUrl ?? null, job_description: jobDescription ?? null }),
|
body: JSON.stringify({ version_id: versionId, company_name: companyName, role_title: roleTitle, job_url: jobUrl ?? null, job_description: jobDescription ?? null }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const fetchSubmissions = (versionId: string): Promise<Submission[]> =>
|
||||||
|
req<Submission[]>(`/api/v1/submissions?version_id=${versionId}`);
|
||||||
|
|
||||||
|
export const fetchSubmission = (id: string): Promise<Submission> =>
|
||||||
|
req<Submission>(`/api/v1/submissions/${id}`);
|
||||||
|
|
||||||
|
export async function requestAiSuggestions(
|
||||||
|
submissionId: string,
|
||||||
|
jobDescription: string,
|
||||||
|
focusKeywords: string[] = [],
|
||||||
|
): Promise<Suggestion[]> {
|
||||||
|
return req<Suggestion[]>(`/api/v1/submissions/${submissionId}/ai`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ job_description: jobDescription, focus_keywords: focusKeywords }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSuggestion(
|
||||||
|
submissionId: string,
|
||||||
|
suggestionId: string,
|
||||||
|
accepted: boolean,
|
||||||
|
): Promise<Suggestion> {
|
||||||
|
return req<Suggestion>(`/api/v1/submissions/${submissionId}/suggestions/${suggestionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ accepted }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function publishVersion(
|
export async function publishVersion(
|
||||||
versionId?: string | null,
|
versionId?: string | null,
|
||||||
submissionId?: string | null,
|
submissionId?: string | null,
|
||||||
slug?: string | null,
|
slug?: string | null,
|
||||||
): Promise<PublicAsset> {
|
): Promise<PublicAsset> {
|
||||||
return req<PublicAsset>("/api/v1/public/publish", {
|
return req<PublicAsset>('/api/v1/public/publish', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { "content-type": "application/json" },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }),
|
body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getPublicPdfUrl = (slug: string): string =>
|
||||||
|
`${API}/api/v1/public/${encodeURIComponent(slug)}/pdf`;
|
||||||
|
|
||||||
|
export const fetchPublicAssetAnalytics = (slug: string): Promise<PublicAssetAnalytics> =>
|
||||||
|
req<PublicAssetAnalytics>(`/api/v1/public/${encodeURIComponent(slug)}/analytics`);
|
||||||
|
|
||||||
|
export async function deleteDocument(documentId: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/api/v1/documents/${documentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { accept: 'application/json', ...getAuthHeader() },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => res.statusText);
|
||||||
|
throw new Error(detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteVersion(versionId: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/api/v1/versions/${versionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { accept: 'application/json', ...getAuthHeader() },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => res.statusText);
|
||||||
|
throw new Error(detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
29
apps/webapp/src/middleware.ts
Normal file
29
apps/webapp/src/middleware.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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*'] };
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
76
dlib/cv/docx_export.py
Normal 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
79
dlib/cv/pdf_export.py
Normal 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()
|
||||||
@@ -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
|
||||||
@@ -32,18 +42,20 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- BACKEND_PORT=8080
|
- BACKEND_PORT=8080
|
||||||
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches
|
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches
|
||||||
- MINIO_ENDPOINT=http://cvfs-minio:9000
|
- MINIO_ENDPOINT=https://storage.cv.alves.world
|
||||||
- MINIO_BUCKET=resume-branches
|
- MINIO_BUCKET=resume-branches
|
||||||
- MINIO_REGION=us-east-1
|
- MINIO_REGION=us-east-1
|
||||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
- CORS_ORIGINS=https://cv.alves.world,https://api.cv.alves.world
|
|
||||||
- PUBLIC_BASE_URL=https://cv.alves.world
|
- PUBLIC_BASE_URL=https://cv.alves.world
|
||||||
- CV_PUBLIC_DOMAIN=cv.alves.world
|
- CV_PUBLIC_DOMAIN=cv.alves.world
|
||||||
- REDIS_URL=redis://cvfs-redis:6379/0
|
- REDIS_URL=redis://cvfs-redis:6379/0
|
||||||
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER}
|
||||||
|
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE}
|
||||||
|
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- minio
|
- minio
|
||||||
@@ -68,7 +80,7 @@ services:
|
|||||||
- REDIS_URL=redis://cvfs-redis:6379/0
|
- REDIS_URL=redis://cvfs-redis:6379/0
|
||||||
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
|
||||||
- MINIO_ENDPOINT=http://cvfs-minio:9000
|
- MINIO_ENDPOINT=https://storage.cv.alves.world
|
||||||
- MINIO_BUCKET=resume-branches
|
- MINIO_BUCKET=resume-branches
|
||||||
- MINIO_REGION=us-east-1
|
- MINIO_REGION=us-east-1
|
||||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||||
@@ -108,13 +120,14 @@ services:
|
|||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
container_name: "cvfs-minio"
|
container_name: "cvfs-minio"
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
networks:
|
networks:
|
||||||
- cvfs-network
|
- cvfs-network
|
||||||
|
- dokploy-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
create-bucket:
|
create-bucket:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user