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