diff --git a/app/api/media.py b/app/api/media.py new file mode 100644 index 0000000..00943bd --- /dev/null +++ b/app/api/media.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import mimetypes + +from fastapi import APIRouter, Depends +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_api_current_user +from app.api.errors import ApiException +from app.db.models import User +from app.db.session import get_db_session +from app.services.domains.scholars import application as scholar_service +from app.settings import settings + +router = APIRouter(tags=["media"]) + + +@router.get("/scholar-images/{scholar_profile_id}/upload") +async def get_uploaded_scholar_image( + scholar_profile_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=current_user.id, + scholar_profile_id=scholar_profile_id, + ) + if profile is None: + raise ApiException( + status_code=404, + code="scholar_not_found", + message="Scholar not found.", + ) + if not profile.profile_image_upload_path: + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) + + try: + image_path = scholar_service.resolve_upload_file_path( + upload_dir=settings.scholar_image_upload_dir, + relative_path=profile.profile_image_upload_path, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) from exc + + if not image_path.exists() or not image_path.is_file(): + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) + + media_type = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream" + return FileResponse(path=image_path, media_type=media_type) diff --git a/app/api/routers/scholars.py b/app/api/routers/scholars.py index 7a8038a..f4be187 100644 --- a/app/api/routers/scholars.py +++ b/app/api/routers/scholars.py @@ -2,10 +2,8 @@ from __future__ import annotations import asyncio import logging -import mimetypes from fastapi import APIRouter, Depends, File, Query, Request, UploadFile -from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_api_current_user @@ -35,14 +33,14 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/scholars", tags=["api-scholars"]) -def _uploaded_image_api_path(scholar_profile_id: int) -> str: - return f"/api/v1/scholars/{scholar_profile_id}/image/upload" +def _uploaded_image_media_path(scholar_profile_id: int) -> str: + return f"/scholar-images/{scholar_profile_id}/upload" def _serialize_scholar(profile) -> dict[str, object]: uploaded_image_url = None if profile.profile_image_upload_path: - uploaded_image_url = _uploaded_image_api_path(int(profile.id)) + uploaded_image_url = _uploaded_image_media_path(int(profile.id)) profile_image_url, profile_image_source = scholar_service.resolve_profile_image( profile, @@ -557,51 +555,3 @@ async def clear_scholar_image_customization( data=_serialize_scholar(updated), ) - -@router.get( - "/{scholar_profile_id}/image/upload", -) -async def get_uploaded_scholar_image( - scholar_profile_id: int, - db_session: AsyncSession = Depends(get_db_session), - current_user: User = Depends(get_api_current_user), -): - profile = await scholar_service.get_user_scholar_by_id( - db_session, - user_id=current_user.id, - scholar_profile_id=scholar_profile_id, - ) - if profile is None: - raise ApiException( - status_code=404, - code="scholar_not_found", - message="Scholar not found.", - ) - if not profile.profile_image_upload_path: - raise ApiException( - status_code=404, - code="scholar_image_not_found", - message="Scholar image not found.", - ) - - try: - image_path = scholar_service.resolve_upload_file_path( - upload_dir=settings.scholar_image_upload_dir, - relative_path=profile.profile_image_upload_path, - ) - except scholar_service.ScholarServiceError as exc: - raise ApiException( - status_code=404, - code="scholar_image_not_found", - message="Scholar image not found.", - ) from exc - - if not image_path.exists() or not image_path.is_file(): - raise ApiException( - status_code=404, - code="scholar_image_not_found", - message="Scholar image not found.", - ) - - media_type = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream" - return FileResponse(path=image_path, media_type=media_type) diff --git a/app/main.py b/app/main.py index f8f9c18..254eb4a 100644 --- a/app/main.py +++ b/app/main.py @@ -10,6 +10,7 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from app.api.errors import register_api_exception_handlers +from app.api.media import router as media_router from app.api.router import router as api_router from app.api.runtime_deps import get_ingestion_service, get_scholar_source from app.db.session import check_database @@ -106,6 +107,7 @@ app.add_middleware( strict_transport_security_preload=settings.security_strict_transport_security_preload, ) app.include_router(api_router) +app.include_router(media_router) @app.get("/healthz") diff --git a/app/services/domains/publications/application.py b/app/services/domains/publications/application.py index cad6828..b1161fb 100644 --- a/app/services/domains/publications/application.py +++ b/app/services/domains/publications/application.py @@ -8,7 +8,6 @@ from app.services.domains.publications.counts import ( ) from app.services.domains.publications.listing import ( list_for_user, - list_new_for_latest_run_for_user, retry_pdf_for_user, list_unread_for_user, ) @@ -22,7 +21,6 @@ from app.services.domains.publications.modes import ( MODE_LATEST, MODE_NEW, MODE_UNREAD, - resolve_mode, resolve_publication_view_mode, ) from app.services.domains.publications.queries import ( @@ -52,13 +50,11 @@ __all__ = [ "PublicationListItem", "UnreadPublicationItem", "resolve_publication_view_mode", - "resolve_mode", "get_latest_completed_run_id_for_user", "publications_query", "get_publication_item_for_user", "list_for_user", "list_unread_for_user", - "list_new_for_latest_run_for_user", "retry_pdf_for_user", "hydrate_pdf_enrichment_state", "schedule_retry_pdf_enrichment_for_row", diff --git a/app/services/domains/publications/listing.py b/app/services/domains/publications/listing.py index 376168a..f4f518b 100644 --- a/app/services/domains/publications/listing.py +++ b/app/services/domains/publications/listing.py @@ -4,7 +4,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.services.domains.publications.modes import ( MODE_ALL, - MODE_LATEST, MODE_UNREAD, resolve_publication_view_mode, ) @@ -81,34 +80,3 @@ async def list_unread_for_user( ) ) return [unread_item_from_row(row) for row in result.all()] - - -async def list_new_for_latest_run_for_user( - db_session: AsyncSession, - *, - user_id: int, - limit: int = 100, -) -> list[UnreadPublicationItem]: - rows = await list_for_user( - db_session, - user_id=user_id, - mode=MODE_LATEST, - scholar_profile_id=None, - limit=limit, - ) - return [_to_unread_item(row) for row in rows] - - -def _to_unread_item(row: PublicationListItem) -> UnreadPublicationItem: - return UnreadPublicationItem( - publication_id=row.publication_id, - scholar_profile_id=row.scholar_profile_id, - scholar_label=row.scholar_label, - title=row.title, - year=row.year, - citation_count=row.citation_count, - venue_text=row.venue_text, - pub_url=row.pub_url, - doi=row.doi, - pdf_url=row.pdf_url, - ) diff --git a/app/services/domains/publications/modes.py b/app/services/domains/publications/modes.py index 33629b4..2975169 100644 --- a/app/services/domains/publications/modes.py +++ b/app/services/domains/publications/modes.py @@ -12,7 +12,3 @@ def resolve_publication_view_mode(value: str | None) -> str: if value in {MODE_LATEST, MODE_NEW}: return MODE_LATEST return MODE_ALL - - -def resolve_mode(value: str | None) -> str: - return resolve_publication_view_mode(value) diff --git a/app/services/domains/scholars/application.py b/app/services/domains/scholars/application.py index 5581bca..8da041f 100644 --- a/app/services/domains/scholars/application.py +++ b/app/services/domains/scholars/application.py @@ -344,11 +344,6 @@ def _author_search_cooldown_remaining_seconds( return max(0, remaining_seconds) -def _reset_author_search_runtime_state_for_tests() -> None: - # Runtime state now lives in the database; tests should reset via DB fixtures. - return None - - async def list_scholars_for_user( db_session: AsyncSession, *, diff --git a/docs/reference/api-contract.md b/docs/reference/api-contract.md index 1994943..8b72186 100644 --- a/docs/reference/api-contract.md +++ b/docs/reference/api-contract.md @@ -9,6 +9,8 @@ All API responses under `/api/v1` use one of these envelopes: `meta.request_id` must be present for success and error responses. +Binary media assets are served outside `/api/v1` (for example, `GET /scholar-images/{scholar_profile_id}/upload`). + ## Publications Semantics - `GET /api/v1/publications` supports `mode=all|unread|latest`. diff --git a/frontend/docs/website/package-lock.json b/frontend/docs/website/package-lock.json new file mode 100644 index 0000000..4ff32e6 --- /dev/null +++ b/frontend/docs/website/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "website", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/frontend/public/android-chrome-192x192.png b/frontend/public/android-chrome-192x192.png index 7658ad0..83681a8 100644 Binary files a/frontend/public/android-chrome-192x192.png and b/frontend/public/android-chrome-192x192.png differ diff --git a/frontend/public/android-chrome-512x512.png b/frontend/public/android-chrome-512x512.png index 76ae1ee..5720538 100644 Binary files a/frontend/public/android-chrome-512x512.png and b/frontend/public/android-chrome-512x512.png differ diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png index a6167e9..75849b8 100644 Binary files a/frontend/public/apple-touch-icon.png and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png index b6f5578..c6a492e 100644 Binary files a/frontend/public/favicon-16x16.png and b/frontend/public/favicon-16x16.png differ diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png index 503d730..f4db572 100644 Binary files a/frontend/public/favicon-32x32.png and b/frontend/public/favicon-32x32.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 783fa02..06484f0 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/scholar_logo.png b/frontend/public/scholar_logo.png new file mode 100644 index 0000000..a5f2e40 Binary files /dev/null and b/frontend/public/scholar_logo.png differ diff --git a/frontend/public/scholarr_favicon.png b/frontend/public/scholarr_favicon.png new file mode 100644 index 0000000..77cab83 Binary files /dev/null and b/frontend/public/scholarr_favicon.png differ diff --git a/frontend/scripts/generate_brand_assets.sh b/frontend/scripts/generate_brand_assets.sh index b64a016..6011698 100755 --- a/frontend/scripts/generate_brand_assets.sh +++ b/frontend/scripts/generate_brand_assets.sh @@ -3,8 +3,10 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FRONTEND_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" -SRC_LOGO="${FRONTEND_DIR}/src/logo.png" PUBLIC_DIR="${FRONTEND_DIR}/public" +SRC_LOGO="${PUBLIC_DIR}/scholar_logo.png" +SRC_FAVICON="${PUBLIC_DIR}/scholar_favicon.png" +SRC_FAVICON_ALT="${PUBLIC_DIR}/scholarr_favicon.png" TMP_DIR="$(mktemp -d)" TMP_ALPHA="${TMP_DIR}/logo-alpha-mask.png" TMP_GLYPH="${TMP_DIR}/logo-glyph-white.png" @@ -23,22 +25,36 @@ if [[ ! -f "${SRC_LOGO}" ]]; then exit 1 fi +if [[ ! -f "${SRC_FAVICON}" && -f "${SRC_FAVICON_ALT}" ]]; then + SRC_FAVICON="${SRC_FAVICON_ALT}" +fi + mkdir -p "${PUBLIC_DIR}" -# Build a high-contrast icon: solid brand background + white logo. -magick "${SRC_LOGO}" -alpha extract "${TMP_ALPHA}" -magick -size 1024x1024 xc:white "${TMP_ALPHA}" \ - -compose CopyOpacity \ - -composite \ - "${TMP_GLYPH}" +if [[ -f "${SRC_FAVICON}" ]]; then + # Prefer dedicated favicon artwork when provided. + magick "${SRC_FAVICON}" \ + -background none \ + -gravity center \ + -resize 1024x1024^ \ + -extent 1024x1024 \ + "${TMP_ICON}" +else + # Build a high-contrast icon from the logo: solid brand background + white logo. + magick "${SRC_LOGO}" -alpha extract "${TMP_ALPHA}" + magick -size 1024x1024 xc:white "${TMP_ALPHA}" \ + -compose CopyOpacity \ + -composite \ + "${TMP_GLYPH}" -magick -size 1024x1024 "xc:${BRAND_COLOR}" "${TMP_CANVAS}" -magick "${TMP_GLYPH}" -resize 760x760 "${TMP_GLYPH}" -magick "${TMP_CANVAS}" "${TMP_GLYPH}" \ - -gravity center \ - -compose over \ - -composite \ - "${TMP_ICON}" + magick -size 1024x1024 "xc:${BRAND_COLOR}" "${TMP_CANVAS}" + magick "${TMP_GLYPH}" -resize 760x760 "${TMP_GLYPH}" + magick "${TMP_CANVAS}" "${TMP_GLYPH}" \ + -gravity center \ + -compose over \ + -composite \ + "${TMP_ICON}" +fi magick "${TMP_ICON}" -resize 16x16 "${PUBLIC_DIR}/favicon-16x16.png" magick "${TMP_ICON}" -resize 32x32 "${PUBLIC_DIR}/favicon-32x32.png" @@ -53,4 +69,4 @@ magick \ rm -rf "${TMP_DIR}" -echo "Generated favicon + app icon assets in ${PUBLIC_DIR}" +echo "Generated favicon + app icon assets in ${PUBLIC_DIR} (logo: ${SRC_LOGO}, favicon source: ${SRC_FAVICON})" diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue index 2bcbdba..64f833c 100644 --- a/frontend/src/components/layout/AppHeader.vue +++ b/frontend/src/components/layout/AppHeader.vue @@ -60,7 +60,7 @@ function onToggleMenu(): void { to="/dashboard" class="inline-flex items-center gap-2.5 rounded-sm font-display text-xl tracking-tight text-ink-primary transition hover:text-ink-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset" > - + scholarr diff --git a/frontend/src/components/ui/AppBrandMark.vue b/frontend/src/components/ui/AppBrandMark.vue index 6ef1d1d..0061ca4 100644 --- a/frontend/src/components/ui/AppBrandMark.vue +++ b/frontend/src/components/ui/AppBrandMark.vue @@ -1,8 +1,6 @@ diff --git a/frontend/src/logo.png b/frontend/src/logo.png deleted file mode 100644 index 5fbd13e..0000000 Binary files a/frontend/src/logo.png and /dev/null differ diff --git a/tests/integration/test_api_v1.py b/tests/integration/test_api_v1.py index ea2a410..bb21f34 100644 --- a/tests/integration/test_api_v1.py +++ b/tests/integration/test_api_v1.py @@ -777,9 +777,9 @@ async def test_api_scholars_search_and_profile_image_management( assert upload_response.status_code == 200 upload_data = upload_response.json()["data"] assert upload_data["profile_image_source"] == "upload" - assert upload_data["profile_image_url"] == f"/api/v1/scholars/{scholar_profile_id}/image/upload" + assert upload_data["profile_image_url"] == f"/scholar-images/{scholar_profile_id}/upload" - uploaded_image_response = client.get(f"/api/v1/scholars/{scholar_profile_id}/image/upload") + uploaded_image_response = client.get(f"/scholar-images/{scholar_profile_id}/upload") assert uploaded_image_response.status_code == 200 assert uploaded_image_response.headers["content-type"].startswith("image/png") assert uploaded_image_response.content == uploaded_bytes