diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index d90f0818a..45960dd88 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -48,6 +48,7 @@ lint: ignore: - linters: [ALL] paths: + - frontend/assets/patcherjs/** - frontend/src/__generated__/** - docker/Dockerfile - docker/nginx/js/** diff --git a/backend/alembic/versions/0068_walkthroughs.py b/backend/alembic/versions/0068_walkthroughs.py new file mode 100644 index 000000000..2695bfe2e --- /dev/null +++ b/backend/alembic/versions/0068_walkthroughs.py @@ -0,0 +1,54 @@ +"""Add walkthroughs table + +Revision ID: 0068_walkthroughs +Revises: 0067_romfile_category_enum_cheat +Create Date: 2026-01-04 18:40:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "0068_walkthroughs" +down_revision: Union[str, None] = "0067_romfile_category_enum_cheat" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "walkthroughs", + sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("rom_id", sa.Integer(), nullable=False), + sa.Column("url", sa.String(length=1000), nullable=False), + sa.Column("title", sa.String(length=500), nullable=True), + sa.Column("author", sa.String(length=250), nullable=True), + sa.Column( + "source", + sa.Enum("GAMEFAQS", "UPLOAD", name="walkthroughsource"), + nullable=False, + ), + sa.Column("file_path", sa.String(length=1000), nullable=True), + sa.Column( + "content", + sa.Text().with_variant(mysql.LONGTEXT(), "mysql"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["rom_id"], + ["roms.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_walkthroughs_rom_id"), "walkthroughs", ["rom_id"]) + + +def downgrade() -> None: + op.drop_index(op.f("ix_walkthroughs_rom_id"), table_name="walkthroughs") + op.drop_table("walkthroughs") diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 143551787..ca718f9e8 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -2,6 +2,7 @@ import re from datetime import datetime, timezone +from pathlib import Path from typing import NotRequired, TypedDict, get_type_hints from fastapi import Request @@ -17,6 +18,7 @@ from handler.metadata.moby_handler import MobyMetadata from handler.metadata.ra_handler import RAMetadata from handler.metadata.ss_handler import SSMetadata +from handler.walkthrough_handler import WalkthroughFormat, WalkthroughSource from models.collection import Collection from models.rom import Rom, RomFileCategory, RomUserStatus @@ -100,6 +102,40 @@ class Config: ) +class WalkthroughSchema(BaseModel): + id: int + rom_id: int + url: str + title: str | None + author: str | None + source: WalkthroughSource + file_path: str | None + content: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + use_enum_values = True + + @computed_field # type: ignore[misc] + @property + def format(self) -> WalkthroughFormat: + candidate = self.file_path or self.url or "" + ext = Path(candidate).suffix.lower() + if ext == ".pdf": + return WalkthroughFormat.PDF + if ext in {".html", ".htm"}: + return WalkthroughFormat.HTML + if ext in {".txt", ".text", ".md"}: + return WalkthroughFormat.TEXT + if self.source == WalkthroughSource.GAMEFAQS: + return WalkthroughFormat.TEXT + if self.content.lstrip().startswith("<"): + return WalkthroughFormat.HTML + return WalkthroughFormat.TEXT + + def rom_user_schema_factory() -> RomUserSchema: now = datetime.now(timezone.utc) return RomUserSchema( @@ -290,6 +326,7 @@ class RomSchema(BaseModel): rom_user: RomUserSchema merged_screenshots: list[str] merged_ra_metadata: RomRAMetadata | None + walkthroughs: list[WalkthroughSchema] class Config: from_attributes = True diff --git a/backend/endpoints/walkthrough.py b/backend/endpoints/walkthrough.py new file mode 100644 index 000000000..f13a5b000 --- /dev/null +++ b/backend/endpoints/walkthrough.py @@ -0,0 +1,265 @@ +from pathlib import Path + +from fastapi import HTTPException, Request, UploadFile, status +from pydantic import BaseModel, ConfigDict, Field + +from decorators.auth import protected_route +from endpoints.responses.rom import WalkthroughSchema +from handler.auth.constants import Scope +from handler.database import db_rom_handler, db_walkthrough_handler +from handler.filesystem import fs_resource_handler +from handler.walkthrough_handler import ( + ALLOWED_MIME_TYPES, + MAX_UPLOAD_BYTES, + InvalidWalkthroughURLError, + WalkthroughContentNotFound, + WalkthroughError, + WalkthroughFetchFailed, + WalkthroughFormat, + WalkthroughResult, + WalkthroughSource, + fetch_walkthrough, + sanitize_html_fragment, +) +from logger.logger import log +from models.walkthrough import Walkthrough +from utils.router import APIRouter + +router = APIRouter( + prefix="/walkthroughs", + tags=["walkthroughs"], +) + + +async def _fetch_walkthrough_with_error_handling(url: str) -> WalkthroughResult: + """Helper function to fetch walkthrough with consistent error handling.""" + try: + return await fetch_walkthrough(url) + except WalkthroughFetchFailed as exc: + log.error("Walkthrough fetch failed", exc_info=True) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc) + ) from exc + except InvalidWalkthroughURLError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) + ) from exc + except WalkthroughContentNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + except WalkthroughError as exc: + log.error("Walkthrough fetch failed", exc_info=True) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc) + ) from exc + + +class WalkthroughRequest(BaseModel): + url: str = Field(..., description="Walkthrough URL from GameFAQs") + + model_config = ConfigDict(use_enum_values=True) + + +class WalkthroughResponse(BaseModel): + url: str + title: str | None = None + author: str | None = None + source: WalkthroughSource + format: WalkthroughFormat + file_path: str | None = None + content: str + + model_config = ConfigDict(use_enum_values=True) + + +class WalkthroughCreateRequest(BaseModel): + url: str = Field(..., description="Walkthrough URL from GameFAQs") + + +@protected_route( + router.post, "/fetch", [Scope.ROMS_READ], status_code=status.HTTP_200_OK +) +async def get_walkthrough( + request: Request, # noqa: ARG001 - required for authentication decorator + payload: WalkthroughRequest, +) -> WalkthroughResponse: + result = await _fetch_walkthrough_with_error_handling(payload.url) + return WalkthroughResponse(**result) + + +def _detect_format_from_extension(filename: str) -> WalkthroughFormat: + ext = Path(filename).suffix.lower() + if ext == ".pdf": + return WalkthroughFormat.PDF + if ext in {".html", ".htm"}: + return WalkthroughFormat.HTML + if ext in {".txt", ".text", ".md"}: + return WalkthroughFormat.TEXT + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unsupported walkthrough file type. Use PDF, HTML, or TXT.", + ) + + +@protected_route(router.get, "/roms/{rom_id}", [Scope.ROMS_READ]) +def list_walkthroughs_for_rom( + request: Request, # noqa: ARG001 - required for authentication decorator + rom_id: int, +) -> list[WalkthroughSchema]: + rom = db_rom_handler.get_rom(rom_id) + if not rom: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Rom not found" + ) + walkthroughs = db_walkthrough_handler.get_walkthroughs_for_rom(rom_id) + return [WalkthroughSchema.model_validate(wt) for wt in walkthroughs] + + +@protected_route( + router.post, + "/roms/{rom_id}", + [Scope.ROMS_WRITE], + status_code=status.HTTP_201_CREATED, +) +async def create_walkthrough_for_rom( + request: Request, # noqa: ARG001 - required for authentication decorator + rom_id: int, + payload: WalkthroughCreateRequest, +) -> WalkthroughSchema: + rom = db_rom_handler.get_rom(rom_id) + if not rom: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Rom not found" + ) + result = await _fetch_walkthrough_with_error_handling(payload.url) + + walkthrough = Walkthrough( + rom_id=rom_id, + url=payload.url, + title=result.get("title"), + author=result.get("author"), + source=result["source"], + file_path=None, + content=result["content"], + ) + saved = db_walkthrough_handler.add_or_update_walkthrough(walkthrough) + return WalkthroughSchema.model_validate(saved) + + +@protected_route( + router.post, + "/roms/{rom_id}/upload", + [Scope.ROMS_WRITE], + status_code=status.HTTP_201_CREATED, +) +async def upload_walkthrough_for_rom( + request: Request, # noqa: ARG001 - required for authentication decorator + rom_id: int, + file: UploadFile, + title: str | None = None, + author: str | None = None, +) -> WalkthroughSchema: + rom = db_rom_handler.get_rom(rom_id) + if not rom: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Rom not found" + ) + + filename = file.filename or "walkthrough" + fmt = _detect_format_from_extension(filename) + + raw_bytes = await file.read() + if not raw_bytes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Empty walkthrough file" + ) + if len(raw_bytes) > MAX_UPLOAD_BYTES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Walkthrough file is too large (max 15MB)", + ) + + if file.content_type: + content_type = file.content_type.split(";")[0].strip().lower() + if content_type not in ALLOWED_MIME_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unsupported file type. Use PDF, HTML, or TXT.", + ) + + content = "" + if fmt == WalkthroughFormat.PDF: + content = "" + elif fmt == WalkthroughFormat.HTML: + content = sanitize_html_fragment( + raw_bytes.decode("utf-8", errors="ignore") + ).strip() + if not content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unable to parse HTML walkthrough content", + ) + else: + content = raw_bytes.decode("utf-8", errors="ignore").strip() + if not content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Walkthrough text file is empty", + ) + + walkthrough = Walkthrough( + rom_id=rom_id, + url=filename, + title=title or Path(filename).stem, + author=author, + source=WalkthroughSource.UPLOAD, + content=content, + file_path=None, + ) + + saved = db_walkthrough_handler.add_or_update_walkthrough(walkthrough) + + if fmt == WalkthroughFormat.PDF: + try: + stored_path = await fs_resource_handler.store_walkthrough_file( + rom=rom, + walkthrough_id=saved.id, + data=raw_bytes, + extension="pdf", + ) + saved.file_path = stored_path + saved = db_walkthrough_handler.add_or_update_walkthrough(saved) + except Exception as e: + log.error( + f"Failed to store PDF file for walkthrough {saved.id}, rolling back database entry.", + exc_info=True, + ) + db_walkthrough_handler.delete_walkthrough(saved.id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to store walkthrough file.", + ) from e + + return WalkthroughSchema.model_validate(saved) + + +@protected_route(router.delete, "/{walkthrough_id}", [Scope.ROMS_WRITE]) +def delete_walkthrough( + request: Request, # noqa: ARG001 - required for authentication decorator + walkthrough_id: int, +) -> dict[str, bool]: + walkthrough = db_walkthrough_handler.get_walkthrough(walkthrough_id) + if not walkthrough: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Walkthrough not found" + ) + + if ( + walkthrough.source == WalkthroughSource.UPLOAD + and walkthrough.file_path is not None + ): + fs_resource_handler.remove_walkthrough_file_sync(walkthrough.file_path) + + db_walkthrough_handler.delete_walkthrough(walkthrough_id) + return {"success": True} diff --git a/backend/handler/auth/base_handler.py b/backend/handler/auth/base_handler.py index e774b1352..cecfa5a39 100644 --- a/backend/handler/auth/base_handler.py +++ b/backend/handler/auth/base_handler.py @@ -19,7 +19,6 @@ ROMM_AUTH_SECRET_KEY, ROMM_BASE_URL, ) -from decorators.auth import oauth from exceptions.auth_exceptions import OAuthCredentialsException, UserDisabledException from handler.auth.constants import ALGORITHM, DEFAULT_OAUTH_TOKEN_EXPIRY, TokenPurpose from handler.auth.middleware.redis_session_middleware import RedisSessionMiddleware @@ -288,6 +287,7 @@ async def get_current_active_user_from_bearer_token(self, token: str): class OpenIDHandler: async def get_current_active_user_from_openid_token(self, token: Any): + from decorators.auth import oauth from handler.database import db_user_handler from models.user import Role, User diff --git a/backend/handler/database/__init__.py b/backend/handler/database/__init__.py index 65816f359..2884aa186 100644 --- a/backend/handler/database/__init__.py +++ b/backend/handler/database/__init__.py @@ -7,6 +7,7 @@ from .states_handler import DBStatesHandler from .stats_handler import DBStatsHandler from .users_handler import DBUsersHandler +from .walkthroughs_handler import DBWalkthroughsHandler db_firmware_handler = DBFirmwareHandler() db_platform_handler = DBPlatformsHandler() @@ -17,3 +18,4 @@ db_stats_handler = DBStatsHandler() db_user_handler = DBUsersHandler() db_collection_handler = DBCollectionsHandler() +db_walkthrough_handler = DBWalkthroughsHandler() diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 6b1320d84..d2bac6e5e 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -137,6 +137,7 @@ def wrapper(*args, **kwargs): selectinload(Rom.files).options( joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name) ), + selectinload(Rom.walkthroughs), selectinload(Rom.sibling_roms).options( noload(Rom.platform), noload(Rom.metadatum) ), diff --git a/backend/handler/database/walkthroughs_handler.py b/backend/handler/database/walkthroughs_handler.py new file mode 100644 index 000000000..727360a43 --- /dev/null +++ b/backend/handler/database/walkthroughs_handler.py @@ -0,0 +1,56 @@ +from typing import Sequence + +from sqlalchemy import select + +from decorators.database import begin_session +from handler.database.base_handler import DBBaseHandler +from models.walkthrough import Walkthrough + + +class DBWalkthroughsHandler(DBBaseHandler): + @begin_session + def add_or_update_walkthrough( + self, + walkthrough: Walkthrough, + *, + session=None, # type: ignore + ) -> Walkthrough: + # Use merge to handle both insert and update operations efficiently + merged_walkthrough = session.merge(walkthrough) + session.flush() + session.refresh(merged_walkthrough) # Ensure we have the latest state + return merged_walkthrough + + @begin_session + def get_walkthroughs_for_rom( + self, + rom_id: int, + *, + session=None, # type: ignore + ) -> Sequence[Walkthrough]: + return session.scalars( + select(Walkthrough).where(Walkthrough.rom_id == rom_id) + ).all() + + @begin_session + def delete_walkthrough( + self, + walkthrough_id: int, + *, + session=None, # type: ignore + ) -> bool: + walkthrough = session.get(Walkthrough, walkthrough_id) + if not walkthrough: + return False + session.delete(walkthrough) + session.flush() + return True + + @begin_session + def get_walkthrough( + self, + walkthrough_id: int, + *, + session=None, # type: ignore + ) -> Walkthrough | None: + return session.get(Walkthrough, walkthrough_id) diff --git a/backend/handler/filesystem/resources_handler.py b/backend/handler/filesystem/resources_handler.py index 3a8b016e3..64748235d 100644 --- a/backend/handler/filesystem/resources_handler.py +++ b/backend/handler/filesystem/resources_handler.py @@ -510,3 +510,31 @@ async def remove_media_resources_path( await self.remove_directory( self.get_media_resources_path(platform_id, rom_id, media_type) ) + + # Walkthrough uploads + async def store_walkthrough_file( + self, rom: Rom, walkthrough_id: int, data: bytes, extension: str + ) -> str: + base_path = f"{rom.fs_resources_path}/walkthroughs" + await self.make_directory(base_path) + + filename = f"{walkthrough_id}.{extension}" + async with await self.write_file_streamed( + path=base_path, filename=filename + ) as f: + await f.write(data) + + stored = self.validate_path(f"{base_path}/{filename}") + return str(stored.relative_to(self.base_path)) + + async def remove_walkthrough_file(self, file_path: str) -> None: + full_path = self.validate_path(file_path) + lock = await self._get_file_lock(str(full_path)) + async with lock: + if full_path.exists(): + full_path.unlink(missing_ok=True) + + def remove_walkthrough_file_sync(self, file_path: str) -> None: + full_path = self.validate_path(file_path) + if full_path.exists(): + full_path.unlink(missing_ok=True) diff --git a/backend/handler/walkthrough_handler.py b/backend/handler/walkthrough_handler.py new file mode 100644 index 000000000..8768d3857 --- /dev/null +++ b/backend/handler/walkthrough_handler.py @@ -0,0 +1,282 @@ +import enum +import re +from typing import Final, TypedDict + +import httpx +from bs4 import BeautifulSoup, Tag + +from logger.logger import log +from utils.context import ctx_httpx_client + + +class WalkthroughSource(enum.StrEnum): + GAMEFAQS = "GAMEFAQS" + UPLOAD = "UPLOAD" + + +class WalkthroughFormat(enum.StrEnum): + HTML = "html" + TEXT = "text" + PDF = "pdf" + + +class WalkthroughResult(TypedDict): + url: str + title: str | None + author: str | None + source: WalkthroughSource + format: WalkthroughFormat + content: str + + +class WalkthroughError(Exception): + """Base exception for walkthrough errors.""" + + +class WalkthroughFetchFailed(WalkthroughError): + """Raised when the walkthrough fetch fails.""" + + +class InvalidWalkthroughURLError(WalkthroughError): + """Raised when the provided URL does not match a supported source.""" + + +class WalkthroughContentNotFound(WalkthroughError): + """Raised when the walkthrough content cannot be extracted.""" + + +GAMEFAQS_PATTERN: Final[re.Pattern[str]] = re.compile( + r"^https://gamefaqs\.gamespot\.com/[^/]+/[^/]+/faqs/.+$" +) + +ALLOWED_ATTRIBUTES: Final[set[str]] = {"href", "src", "alt", "title"} +ALLOWED_TAGS: Final[set[str]] = { + "a", + "b", + "blockquote", + "br", + "code", + "div", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "i", + "img", + "li", + "ol", + "p", + "pre", + "span", + "strong", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "ul", +} +ALLOWED_SCHEMES: Final[set[str]] = {"http", "https"} +UNWANTED_SELECTORS: Final[tuple[str, ...]] = (".ach-panel", ".lazyYT", ".disclaimer") +DEFAULT_HEADERS: Final[dict[str, str]] = { + "User-Agent": "RomM/0.0.1 (+https://romm.app)", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.8", +} +MAX_UPLOAD_BYTES: Final[int] = 15 * 1024 * 1024 # 15 MB +ALLOWED_MIME_TYPES: Final[set[str]] = { + "text/plain", + "text/html", + "application/pdf", + "application/xhtml+xml", + "text/markdown", +} + + +def is_gamefaqs_url(url: str) -> bool: + return bool(GAMEFAQS_PATTERN.match(url)) + + +def match_url(url: str) -> WalkthroughSource | None: + if is_gamefaqs_url(url): + return WalkthroughSource.GAMEFAQS + return None + + +def _strip_unwanted_attributes(node: Tag) -> None: + for tag in node.find_all(True): + for attr in list(tag.attrs): + if attr not in ALLOWED_ATTRIBUTES: + tag.attrs.pop(attr, None) + + +def _remove_unwanted_elements(node: Tag) -> None: + for selector in UNWANTED_SELECTORS: + for match in node.select(selector): + match.decompose() + + for tag in node.find_all(["script", "style", "noscript"]): + tag.decompose() + + +def _remove_empty_elements(node: Tag) -> None: + for tag_name in ("div", "span", "p"): + for tag in list(node.find_all(tag_name)): + if not tag.get_text(strip=True) and not tag.find("img"): + tag.decompose() + + +def _is_allowed_url(value: str) -> bool: + value = value.strip() + if not value: + return False + if value.startswith("//"): + return True + match = re.match(r"^(?P[a-zA-Z][a-zA-Z0-9+.-]*):", value) + if not match: + return True + return match.group("scheme").lower() in ALLOWED_SCHEMES + + +def _remove_disallowed_tags(node: Tag) -> None: + for tag in list(node.find_all(True)): + if tag.name not in ALLOWED_TAGS: + tag.decompose() + continue + + if tag.name == "a": + href = tag.get("href") + if href and not _is_allowed_url(href): + tag.attrs.pop("href", None) + if tag.name == "img": + src = tag.get("src") + if src and not _is_allowed_url(src): + tag.decompose() + + +def _sanitize_node(node: Tag) -> None: + _remove_unwanted_elements(node) + _remove_empty_elements(node) + _remove_disallowed_tags(node) + _strip_unwanted_attributes(node) + + +def _serialize_html(nodes: list[Tag]) -> str: + """Serialize cleaned HTML fragments into a single string.""" + return "\n\n".join(node.decode(formatter="html") for node in nodes).strip() + + +def sanitize_html_fragment(html: str) -> str: + """Clean arbitrary HTML input to avoid XSS and extract walkthrough content.""" + soup = BeautifulSoup(html, "html.parser") + + # First, try to extract only the body content if it exists + body = soup.find("body") + if body: + soup = body + + # For GameFAQs pages, try to extract just the walkthrough content from
 tags
+    pre_tags = soup.find_all("pre")
+    if (
+        pre_tags and len(pre_tags) > 3
+    ):  # Likely a GameFAQs page with multiple content blocks
+        # Extract content from all pre tags and join them
+        walkthrough_content = []
+        for pre in pre_tags:
+            content = pre.get_text().strip()
+            if content:  # Only include non-empty pre tags
+                walkthrough_content.append(content)
+
+        if walkthrough_content:
+            # Join all content and wrap in a single pre tag
+            combined_content = "\n\n".join(walkthrough_content)
+            return f"
{combined_content}
" + + # For other HTML, use the standard sanitization on the body content + _sanitize_node(soup) + return _serialize_html([soup]) + + +def _build_client(client: httpx.AsyncClient | None) -> tuple[httpx.AsyncClient, bool]: + """Return an httpx client and whether it should be closed by the caller.""" + if client: + return client, False + + try: + ctx_client = ctx_httpx_client.get() + except LookupError: + ctx_client = None + + if ctx_client: + return ctx_client, False + + return httpx.AsyncClient(), True + + +async def _fetch_html(url: str, client: httpx.AsyncClient | None = None) -> str: + http_client, should_close = _build_client(client) + try: + response = await http_client.get(url, timeout=30, headers=DEFAULT_HEADERS) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + log.error("Failed to fetch walkthrough", exc_info=True) + raise WalkthroughFetchFailed( + f"Failed to fetch walkthrough content (status {exc.response.status_code})" + ) from exc + except httpx.HTTPError as exc: + log.error("Failed to fetch walkthrough", exc_info=True) + raise WalkthroughFetchFailed("Failed to fetch walkthrough content") from exc + finally: + if should_close: + await http_client.aclose() + + return response.text + + +def _parse_gamefaqs(html: str) -> tuple[str | None, str | None, str]: + soup = BeautifulSoup(html, "html.parser") + pre_tags = soup.select("#faqtext pre") + if not pre_tags: + raise WalkthroughContentNotFound("Guide content not found") + + title = None + author = None + + title_tag = soup.select_one("h2.title, h2.text") + if title_tag: + title = title_tag.get_text(strip=True) + + author_tag = soup.select_one(".contrib1") + if author_tag: + author = author_tag.get_text(strip=True) + + # GameFAQs content is always converted to clean text format + return title, author, "\n\n".join(pre.get_text() for pre in pre_tags).strip() + + +async def fetch_walkthrough( + url: str, + client: httpx.AsyncClient | None = None, +) -> WalkthroughResult: + source = match_url(url) + if not source: + raise InvalidWalkthroughURLError( + "URL is not a supported walkthrough source (GameFAQs only)" + ) + + html = await _fetch_html(url, client) + + title, author, content = _parse_gamefaqs(html) + + return WalkthroughResult( + url=url, + title=title, + author=author, + source=source, + format=WalkthroughFormat.TEXT, + content=content, + ) diff --git a/backend/main.py b/backend/main.py index 1ea55e139..96b56df5c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -43,6 +43,7 @@ stats, tasks, user, + walkthrough, ) from handler.auth.hybrid_auth import HybridAuthBackend from handler.auth.middleware.csrf_middleware import CSRFMiddleware @@ -137,6 +138,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.include_router(collections.router, prefix="/api") app.include_router(gamelist.router, prefix="/api") app.include_router(netplay.router, prefix="/api") +app.include_router(walkthrough.router, prefix="/api") app.mount("/ws", socket_handler.socket_app) app.mount("/netplay", netplay_socket_handler.socket_app) diff --git a/backend/models/rom.py b/backend/models/rom.py index 7fe40aedd..27497d237 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -34,6 +34,7 @@ from models.collection import Collection from models.platform import Platform from models.user import User + from models.walkthrough import Walkthrough class RomFileCategory(enum.StrEnum): @@ -263,6 +264,9 @@ class Rom(BaseModel): lazy="raise", back_populates="rom" ) rom_users: Mapped[list[RomUser]] = relationship(lazy="raise", back_populates="rom") + walkthroughs: Mapped[list[Walkthrough]] = relationship( + "Walkthrough", lazy="raise", back_populates="rom", cascade="all, delete-orphan" + ) notes: Mapped[list[RomNote]] = relationship(lazy="raise", back_populates="rom") metadatum: Mapped[RomMetadata] = relationship( lazy="joined", back_populates="rom", uselist=False diff --git a/backend/models/walkthrough.py b/backend/models/walkthrough.py new file mode 100644 index 000000000..4e0c4e576 --- /dev/null +++ b/backend/models/walkthrough.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from sqlalchemy import Enum, ForeignKey, Integer, String, Text +from sqlalchemy.dialects import mysql +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from handler.walkthrough_handler import WalkthroughSource +from models.base import BaseModel + + +class Walkthrough(BaseModel): + __tablename__ = "walkthroughs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + rom_id: Mapped[int] = mapped_column(ForeignKey("roms.id", ondelete="CASCADE")) + url: Mapped[str] = mapped_column(String(length=1000)) + title: Mapped[str | None] = mapped_column(String(length=500), default=None) + author: Mapped[str | None] = mapped_column(String(length=250), default=None) + source: Mapped[WalkthroughSource] = mapped_column( + Enum(WalkthroughSource, values_callable=lambda e: [item.value for item in e]) + ) + file_path: Mapped[str | None] = mapped_column(String(length=1000), default=None) + content: Mapped[str] = mapped_column(Text().with_variant(mysql.LONGTEXT(), "mysql")) + + rom: Mapped["Rom"] = relationship(back_populates="walkthroughs") diff --git a/backend/tests/endpoints/test_walkthroughs.py b/backend/tests/endpoints/test_walkthroughs.py new file mode 100644 index 000000000..dff6dc976 --- /dev/null +++ b/backend/tests/endpoints/test_walkthroughs.py @@ -0,0 +1,138 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from main import app + +from handler.walkthrough_handler import ( + InvalidWalkthroughURLError, + WalkthroughContentNotFound, + WalkthroughFormat, + WalkthroughSource, +) + + +@pytest.fixture +def client(): + with TestClient(app) as client: + yield client + + +def test_walkthrough_requires_auth(client): + response = client.post( + "/api/walkthroughs/fetch", + json={ + "url": "https://gamefaqs.gamespot.com/snes/guide/faqs/1", + "format": "html", + }, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_walkthrough_success(client, access_token): + fake_result = { + "url": "https://gamefaqs.gamespot.com/snes/guide/faqs/1", + "title": "Guide title", + "author": "Guide author", + "source": WalkthroughSource.GAMEFAQS, + "format": WalkthroughFormat.HTML, + "content": "
content
", + } + + with patch( + "endpoints.walkthrough.fetch_walkthrough", + AsyncMock(return_value=fake_result), + ): + response = client.post( + "/api/walkthroughs/fetch", + headers={"Authorization": f"Bearer {access_token}"}, + json={"url": fake_result["url"], "format": "html"}, + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["title"] == fake_result["title"] + assert payload["author"] == fake_result["author"] + assert payload["source"] == WalkthroughSource.GAMEFAQS + assert payload["format"] == WalkthroughFormat.HTML + assert payload["content"] == fake_result["content"] + + +def test_walkthrough_invalid_url(client, access_token): + with patch( + "endpoints.walkthrough.fetch_walkthrough", + AsyncMock(side_effect=InvalidWalkthroughURLError("bad url")), + ): + response = client.post( + "/api/walkthroughs/fetch", + headers={"Authorization": f"Bearer {access_token}"}, + json={"url": "https://example.com", "format": "html"}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_walkthrough_not_found(client, access_token): + with patch( + "endpoints.walkthrough.fetch_walkthrough", + AsyncMock(side_effect=WalkthroughContentNotFound("missing content")), + ): + response = client.post( + "/api/walkthroughs/fetch", + headers={"Authorization": f"Bearer {access_token}"}, + json={ + "url": "https://gamefaqs.gamespot.com/snes/563504-secret-of-mana/faqs/55474", + "format": "text", + }, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_create_walkthrough_for_rom(client, access_token, rom): + fake_result = { + "url": "https://gamefaqs.gamespot.com/snes/guide/faqs/1", + "title": "Guide title", + "author": "Guide author", + "source": WalkthroughSource.GAMEFAQS, + "format": WalkthroughFormat.HTML, + "content": "
content
", + } + with patch( + "endpoints.walkthrough.fetch_walkthrough", + AsyncMock(return_value=fake_result), + ): + response = client.post( + f"/api/walkthroughs/roms/{rom.id}", + headers={"Authorization": f"Bearer {access_token}"}, + json={"url": fake_result["url"]}, + ) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["rom_id"] == rom.id + assert data["url"] == fake_result["url"] + assert data["title"] == fake_result["title"] + assert data["author"] == fake_result["author"] + assert data["source"] == WalkthroughSource.GAMEFAQS + + # Fetch list + list_response = client.get( + f"/api/walkthroughs/roms/{rom.id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert list_response.status_code == status.HTTP_200_OK + items = list_response.json() + assert len(items) == 1 + assert items[0]["id"] == data["id"] + assert items[0]["title"] == fake_result["title"] + assert items[0]["author"] == fake_result["author"] + + # Delete + delete_response = client.delete( + f"/api/walkthroughs/{data['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert delete_response.status_code == status.HTTP_200_OK diff --git a/backend/tests/handler/test_walkthrough_handler.py b/backend/tests/handler/test_walkthrough_handler.py new file mode 100644 index 000000000..c5a9d347f --- /dev/null +++ b/backend/tests/handler/test_walkthrough_handler.py @@ -0,0 +1,83 @@ +from contextlib import asynccontextmanager + +import httpx +import pytest + +from handler.walkthrough_handler import ( + InvalidWalkthroughURLError, + WalkthroughContentNotFound, + WalkthroughFormat, + WalkthroughSource, + fetch_walkthrough, + is_gamefaqs_url, +) +from utils.context import ctx_httpx_client + + +@asynccontextmanager +async def mock_http_client(responses: dict[str, str]): + async def handler(request: httpx.Request) -> httpx.Response: + body = responses.get(str(request.url)) + if body is None: + return httpx.Response(status_code=404) + return httpx.Response(status_code=200, text=body) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + token = ctx_httpx_client.set(client) + try: + yield + finally: + ctx_httpx_client.reset(token) + await client.aclose() + + +def test_url_matchers(): + assert is_gamefaqs_url( + "https://gamefaqs.gamespot.com/snes/563504-secret-of-mana/faqs/55474" + ) + assert not is_gamefaqs_url("https://example.com/guide") + assert not is_gamefaqs_url( + "https://steamcommunity.com/sharedfiles/filedetails/?id=3579263600" + ) + + +@pytest.mark.asyncio +async def test_fetch_gamefaqs_html_format(): + url = "https://gamefaqs.gamespot.com/snes/563504-secret-of-mana/faqs/55474" + html = """ +

Secret of Mana Walkthrough

+
Jane Doe
+
First section
Second section
+ """ + + async with mock_http_client({url: html}): + result = await fetch_walkthrough(url) + + assert result["source"] == WalkthroughSource.GAMEFAQS + assert result["format"] == WalkthroughFormat.TEXT + assert result["title"] == "Secret of Mana Walkthrough" + assert result["author"] == "Jane Doe" + assert "First section" in result["content"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url", + [ + "https://example.com/guide", + "https://steamcommunity.com/sharedfiles/filedetails/?id=3579263600", + ], +) +async def test_invalid_url_raises(url: str): + with pytest.raises(InvalidWalkthroughURLError): + await fetch_walkthrough(url) + + +@pytest.mark.asyncio +async def test_missing_content_raises(): + url = "https://gamefaqs.gamespot.com/snes/563504-secret-of-mana/faqs/55474" + html = "
" + + async with mock_http_client({url: html}): + with pytest.raises(WalkthroughContentNotFound): + await fetch_walkthrough(url) diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index baabd29be..862cc6d41 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -21,6 +21,7 @@ export type { Body_update_collection_api_collections__id__put } from './models/B export type { Body_update_platform_api_platforms__id__put } from './models/Body_update_platform_api_platforms__id__put'; export type { Body_update_rom_api_roms__id__put } from './models/Body_update_rom_api_roms__id__put'; export type { Body_update_rom_user_api_roms__id__props_put } from './models/Body_update_rom_user_api_roms__id__props_put'; +export type { Body_upload_walkthrough_for_rom_api_walkthroughs_roms__rom_id__upload_post } from './models/Body_upload_walkthrough_for_rom_api_walkthroughs_roms__rom_id__upload_post'; export type { BulkOperationResponse } from './models/BulkOperationResponse'; export type { CleanupStats } from './models/CleanupStats'; export type { CleanupTaskMeta } from './models/CleanupTaskMeta'; @@ -89,6 +90,7 @@ export type { SimpleRomSchema } from './models/SimpleRomSchema'; export type { SmartCollectionSchema } from './models/SmartCollectionSchema'; export type { StateSchema } from './models/StateSchema'; export type { StatsReturn } from './models/StatsReturn'; +export type { StoredWalkthroughResponse } from './models/StoredWalkthroughResponse'; export type { SystemDict } from './models/SystemDict'; export type { TaskExecutionResponse } from './models/TaskExecutionResponse'; export type { TaskInfo } from './models/TaskInfo'; @@ -106,6 +108,12 @@ export type { UserNoteSchema } from './models/UserNoteSchema'; export type { UserSchema } from './models/UserSchema'; export type { ValidationError } from './models/ValidationError'; export type { VirtualCollectionSchema } from './models/VirtualCollectionSchema'; +export type { WalkthroughCreateRequest } from './models/WalkthroughCreateRequest'; +export type { WalkthroughFormat } from './models/WalkthroughFormat'; +export type { WalkthroughRequest } from './models/WalkthroughRequest'; +export type { WalkthroughResponse } from './models/WalkthroughResponse'; +export type { WalkthroughSchema } from './models/WalkthroughSchema'; +export type { WalkthroughSource } from './models/WalkthroughSource'; export type { WatcherTaskMeta } from './models/WatcherTaskMeta'; export type { WatcherTaskStatusResponse } from './models/WatcherTaskStatusResponse'; export type { WebrcadeFeedCategorySchema } from './models/WebrcadeFeedCategorySchema'; diff --git a/frontend/src/__generated__/models/Body_upload_walkthrough_for_rom_api_walkthroughs_roms__rom_id__upload_post.ts b/frontend/src/__generated__/models/Body_upload_walkthrough_for_rom_api_walkthroughs_roms__rom_id__upload_post.ts new file mode 100644 index 000000000..f1ef654f1 --- /dev/null +++ b/frontend/src/__generated__/models/Body_upload_walkthrough_for_rom_api_walkthroughs_roms__rom_id__upload_post.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Body_upload_walkthrough_for_rom_api_walkthroughs_roms__rom_id__upload_post = { + file: Blob; +}; + diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index 3e6166011..75fa8f7ff 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -21,6 +21,7 @@ import type { SiblingRomSchema } from './SiblingRomSchema'; import type { StateSchema } from './StateSchema'; import type { UserCollectionSchema } from './UserCollectionSchema'; import type { UserNoteSchema } from './UserNoteSchema'; +import type { WalkthroughSchema } from './WalkthroughSchema'; export type DetailedRomSchema = { id: number; igdb_id: (number | null); @@ -89,6 +90,7 @@ export type DetailedRomSchema = { rom_user: RomUserSchema; merged_screenshots: Array; merged_ra_metadata: (RomRAMetadata | null); + walkthroughs: Array; user_saves: Array; user_states: Array; user_screenshots: Array; diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index 146b7805e..654b29d32 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -16,6 +16,7 @@ import type { RomRAMetadata } from './RomRAMetadata'; import type { RomSSMetadata } from './RomSSMetadata'; import type { RomUserSchema } from './RomUserSchema'; import type { SiblingRomSchema } from './SiblingRomSchema'; +import type { WalkthroughSchema } from './WalkthroughSchema'; export type SimpleRomSchema = { id: number; igdb_id: (number | null); @@ -84,5 +85,6 @@ export type SimpleRomSchema = { rom_user: RomUserSchema; merged_screenshots: Array; merged_ra_metadata: (RomRAMetadata | null); + walkthroughs: Array; }; diff --git a/frontend/src/__generated__/models/StoredWalkthroughResponse.ts b/frontend/src/__generated__/models/StoredWalkthroughResponse.ts new file mode 100644 index 000000000..3cb20b8c0 --- /dev/null +++ b/frontend/src/__generated__/models/StoredWalkthroughResponse.ts @@ -0,0 +1,20 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { WalkthroughFormat } from './WalkthroughFormat'; +import type { WalkthroughSource } from './WalkthroughSource'; +export type StoredWalkthroughResponse = { + id: number; + rom_id: number; + url: string; + title: (string | null); + author: (string | null); + source: WalkthroughSource; + format: WalkthroughFormat; + file_path: (string | null); + content: string; + created_at: string; + updated_at: string; +}; + diff --git a/frontend/src/__generated__/models/WalkthroughCreateRequest.ts b/frontend/src/__generated__/models/WalkthroughCreateRequest.ts new file mode 100644 index 000000000..53b11b5d2 --- /dev/null +++ b/frontend/src/__generated__/models/WalkthroughCreateRequest.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type WalkthroughCreateRequest = { + /** + * Walkthrough URL from GameFAQs + */ + url: string; +}; + diff --git a/frontend/src/__generated__/models/WalkthroughFormat.ts b/frontend/src/__generated__/models/WalkthroughFormat.ts new file mode 100644 index 000000000..17aa7e84d --- /dev/null +++ b/frontend/src/__generated__/models/WalkthroughFormat.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type WalkthroughFormat = 'html' | 'text' | 'pdf'; diff --git a/frontend/src/__generated__/models/WalkthroughRequest.ts b/frontend/src/__generated__/models/WalkthroughRequest.ts new file mode 100644 index 000000000..2374401d4 --- /dev/null +++ b/frontend/src/__generated__/models/WalkthroughRequest.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type WalkthroughRequest = { + /** + * Walkthrough URL from GameFAQs + */ + url: string; +}; + diff --git a/frontend/src/__generated__/models/WalkthroughResponse.ts b/frontend/src/__generated__/models/WalkthroughResponse.ts new file mode 100644 index 000000000..5d607cc91 --- /dev/null +++ b/frontend/src/__generated__/models/WalkthroughResponse.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { WalkthroughFormat } from './WalkthroughFormat'; +import type { WalkthroughSource } from './WalkthroughSource'; +export type WalkthroughResponse = { + url: string; + title?: (string | null); + author?: (string | null); + source: WalkthroughSource; + format: WalkthroughFormat; + file_path?: (string | null); + content: string; +}; + diff --git a/frontend/src/__generated__/models/WalkthroughSchema.ts b/frontend/src/__generated__/models/WalkthroughSchema.ts new file mode 100644 index 000000000..3490bc196 --- /dev/null +++ b/frontend/src/__generated__/models/WalkthroughSchema.ts @@ -0,0 +1,20 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { WalkthroughFormat } from './WalkthroughFormat'; +import type { WalkthroughSource } from './WalkthroughSource'; +export type WalkthroughSchema = { + id: number; + rom_id: number; + url: string; + title: (string | null); + author: (string | null); + source: WalkthroughSource; + format: WalkthroughFormat; + file_path: (string | null); + content: string; + created_at: string; + updated_at: string; +}; + diff --git a/frontend/src/__generated__/models/WalkthroughSource.ts b/frontend/src/__generated__/models/WalkthroughSource.ts new file mode 100644 index 000000000..852a39a9b --- /dev/null +++ b/frontend/src/__generated__/models/WalkthroughSource.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type WalkthroughSource = 'GAMEFAQS' | 'UPLOAD'; diff --git a/frontend/src/components/Details/Walkthroughs/WalkthroughModal.vue b/frontend/src/components/Details/Walkthroughs/WalkthroughModal.vue new file mode 100644 index 000000000..88da022a1 --- /dev/null +++ b/frontend/src/components/Details/Walkthroughs/WalkthroughModal.vue @@ -0,0 +1,267 @@ + + + + + diff --git a/frontend/src/components/Details/Walkthroughs/WalkthroughProgress.vue b/frontend/src/components/Details/Walkthroughs/WalkthroughProgress.vue new file mode 100644 index 000000000..5d34975ef --- /dev/null +++ b/frontend/src/components/Details/Walkthroughs/WalkthroughProgress.vue @@ -0,0 +1,27 @@ + + diff --git a/frontend/src/components/Details/Walkthroughs/Walkthroughs.vue b/frontend/src/components/Details/Walkthroughs/Walkthroughs.vue new file mode 100644 index 000000000..0b0c49fcd --- /dev/null +++ b/frontend/src/components/Details/Walkthroughs/Walkthroughs.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/src/components/Settings/LibraryManagement/Config/Dialog/CreateExclusion.vue b/frontend/src/components/Settings/LibraryManagement/Config/Dialog/CreateExclusion.vue index 61612db70..4797da5fe 100644 --- a/frontend/src/components/Settings/LibraryManagement/Config/Dialog/CreateExclusion.vue +++ b/frontend/src/components/Settings/LibraryManagement/Config/Dialog/CreateExclusion.vue @@ -163,7 +163,10 @@ function closeDialog() { {{ t("settings.add-exclusion-for") }} {{ exclusionTitle }}

>>>>>> b2f995ec7 (feat: Refactor Library Management settings and add Missing Games component) v-model="exclusionValue" :label="t('settings.exclusion-value')" :placeholder="t('settings.exclusion-placeholder')" @@ -189,6 +192,7 @@ function closeDialog() { {{ t("common.cancel") }} +======= + prepend-icon="mdi-plus" + variant="outlined" + class="text-primary" + @click="addExclusion()" + > + {{ t("common.add") }} + + + + + diff --git a/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue b/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue index 6dc35db29..1d27faf6a 100644 --- a/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue +++ b/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue @@ -29,227 +29,236 @@ const loading = ref(false); let timeout: ReturnType = setTimeout(() => {}, 400); const allPlatforms = computed(() => - [ - ...new Map( - filteredRoms.value - .map((rom) => platformsStore.get(rom.platform_id)) - .filter((platform) => !!platform) - .map((platform) => [platform!.id, platform]), - ).values(), - ].sort((a, b) => a!.name.localeCompare(b!.name)), + [ + ...new Map( + filteredRoms.value + .map((rom) => platformsStore.get(rom.platform_id)) + .filter((platform) => !!platform) + .map((platform) => [platform!.id, platform]), + ).values(), + ].sort((a, b) => a!.name.localeCompare(b!.name)), ); const onFilterChange = debounce( - () => { - romsStore.resetPagination(); - galleryFilterStore.setFilterMissing(true); - romsStore.fetchRoms({ - galleryFilter: galleryFilterStore, - platformsStore: platformsStore, - concat: false, - }); + () => { + romsStore.resetPagination(); + galleryFilterStore.setFilterMissing(true); + romsStore.fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + concat: false, + }); - const url = new URL(window.location.href); - // Update URL with filters - Object.entries({ - platform: selectedPlatform.value - ? String(selectedPlatform.value.id) - : null, - }).forEach(([key, value]) => { - if (value) { - url.searchParams.set(key, value); - } else { - url.searchParams.delete(key); - } - }); - galleryFilterStore.setFilterMissing(false); - }, - 500, - { leading: false, trailing: true }, + const url = new URL(window.location.href); + // Update URL with filters + Object.entries({ + platform: selectedPlatform.value + ? String(selectedPlatform.value.id) + : null, + }).forEach(([key, value]) => { + if (value) { + url.searchParams.set(key, value); + } else { + url.searchParams.delete(key); + } + }); + galleryFilterStore.setFilterMissing(false); + }, + 500, + { leading: false, trailing: true }, ); async function fetchRoms() { - if (fetchingRoms.value) return; + if (fetchingRoms.value) return; - loading.value = true; + loading.value = true; - galleryFilterStore.setFilterMissing(true); - romsStore - .fetchRoms({ - galleryFilter: galleryFilterStore, - platformsStore: platformsStore, - }) - .catch((error) => { - console.error("Error fetching missing games:", error); - emitter?.emit("snackbarShow", { - msg: t("settings.couldnt-fetch-missing-roms", { error }), - icon: "mdi-close-circle", - color: "red", - timeout: 4000, - }); - }) - .finally(() => { - galleryFilterStore.setFilterMissing(false); - loading.value = false; - }); + galleryFilterStore.setFilterMissing(true); + romsStore + .fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + }) + .catch((error) => { + console.error("Error fetching missing games:", error); + emitter?.emit("snackbarShow", { + msg: t("settings.couldnt-fetch-missing-roms", { error }), + icon: "mdi-close-circle", + color: "red", + timeout: 4000, + }); + }) + .finally(() => { + galleryFilterStore.setFilterMissing(false); + loading.value = false; + }); } function cleanupAll() { - romsStore.setLimit(10000); - galleryFilterStore.setFilterMissing(true); - romsStore - .fetchRoms({ - galleryFilter: galleryFilterStore, - platformsStore: platformsStore, - }) - .then(() => { - emitter?.emit("showLoadingDialog", { - loading: false, - scrim: false, - }); - if (filteredRoms.value.length > 0) { - emitter?.emit("showDeleteRomDialog", filteredRoms.value); - } else { - emitter?.emit("snackbarShow", { - msg: t("settings.no-missing-roms-to-delete"), - icon: "mdi-close-circle", - color: "red", - timeout: 4000, + romsStore.setLimit(10000); + galleryFilterStore.setFilterMissing(true); + romsStore + .fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + }) + .then(() => { + emitter?.emit("showLoadingDialog", { + loading: false, + scrim: false, + }); + if (filteredRoms.value.length > 0) { + emitter?.emit("showDeleteRomDialog", filteredRoms.value); + } else { + emitter?.emit("snackbarShow", { + msg: t("settings.no-missing-roms-to-delete"), + icon: "mdi-close-circle", + color: "red", + timeout: 4000, + }); + } + }) + .catch((error) => { + console.error("Error fetching missing games:", error); + emitter?.emit("snackbarShow", { + msg: t("settings.couldnt-fetch-missing-roms", { error }), + icon: "mdi-close-circle", + color: "red", + timeout: 4000, + }); + }) + .finally(() => { + galleryFilterStore.setFilterMissing(false); }); - } - }) - .catch((error) => { - console.error("Error fetching missing games:", error); - emitter?.emit("snackbarShow", { - msg: t("settings.couldnt-fetch-missing-roms", { error }), - icon: "mdi-close-circle", - color: "red", - timeout: 4000, - }); - }) - .finally(() => { - galleryFilterStore.setFilterMissing(false); - }); } function resetMissingRoms() { - romsStore.reset(); - galleryFilterStore.resetFilters(); + romsStore.reset(); + galleryFilterStore.resetFilters(); } const { y: windowY } = useScroll(window, { throttle: 500 }); watch(windowY, () => { - clearTimeout(timeout); + clearTimeout(timeout); - window.setTimeout(async () => { - scrolledToTop.value = windowY.value === 0; - if ( - windowY.value + window.innerHeight >= document.body.scrollHeight - 300 && - fetchTotalRoms.value > filteredRoms.value.length - ) { - await fetchRoms(); - } - }, 100); + window.setTimeout(async () => { + scrolledToTop.value = windowY.value === 0; + if ( + windowY.value + window.innerHeight >= + document.body.scrollHeight - 300 && + fetchTotalRoms.value > filteredRoms.value.length + ) { + await fetchRoms(); + } + }, 100); }); onMounted(() => { - resetMissingRoms(); - fetchRoms(); + resetMissingRoms(); + fetchRoms(); }); onUnmounted(() => { - resetMissingRoms(); + resetMissingRoms(); }); diff --git a/frontend/src/components/common/Game/Dialog/EditRom.vue b/frontend/src/components/common/Game/Dialog/EditRom.vue index 8b9483252..77a30d8d4 100644 --- a/frontend/src/components/common/Game/Dialog/EditRom.vue +++ b/frontend/src/components/common/Game/Dialog/EditRom.vue @@ -15,6 +15,7 @@ import { getMissingCoverImage } from "@/utils/covers"; import AdditionalDetails from "./EditRom/AdditionalDetails.vue"; import MetadataIdSection from "./EditRom/MetadataIdSection.vue"; import MetadataSections from "./EditRom/MetadataSections.vue"; +import WalkthroughPanel from "./EditRom/WalkthroughPanel.vue"; const { t } = useI18n(); const { lgAndUp } = useDisplay(); @@ -417,6 +418,10 @@ function handleRomUpdateFromMetadata(updatedRom: UpdateRom) { :rom="rom" @update:rom="handleRomUpdateFromMetadata" /> + diff --git a/frontend/src/components/common/Game/Dialog/EditRom/WalkthroughPanel.vue b/frontend/src/components/common/Game/Dialog/EditRom/WalkthroughPanel.vue new file mode 100644 index 000000000..aced286a9 --- /dev/null +++ b/frontend/src/components/common/Game/Dialog/EditRom/WalkthroughPanel.vue @@ -0,0 +1,158 @@ + + + diff --git a/frontend/src/composables/useUploadWalkthrough.ts b/frontend/src/composables/useUploadWalkthrough.ts new file mode 100644 index 000000000..7477f8dcd --- /dev/null +++ b/frontend/src/composables/useUploadWalkthrough.ts @@ -0,0 +1,163 @@ +import type { Emitter } from "mitt"; +import { computed, inject, onMounted, ref, watch } from "vue"; +import { + createWalkthroughForRom, + deleteWalkthrough, + listWalkthroughsForRom, + uploadWalkthroughForRom, + type StoredWalkthrough, +} from "@/services/api/walkthrough"; +import type { Events } from "@/types/emitter"; + +type ErrorWalkthrough = + | { response?: { data?: { detail?: string } }; message?: string } + | null + | undefined; + +export const useUploadWalkthrough = (props: { + romId: number; + initialWalkthroughs?: StoredWalkthrough[]; +}) => { + const emitter = inject>("emitter"); + const url = ref(""); + const loading = ref(false); + const removingId = ref(null); + const error = ref(null); + const walkthroughs = ref( + props.initialWalkthroughs || [], + ); + + const uploadFile = ref(null); + const uploading = ref(false); + + const showError = (err: ErrorWalkthrough, defaultMsg: string) => { + const detail = err?.response?.data?.detail || err?.message || defaultMsg; + emitter?.emit("snackbarShow", { + msg: detail, + icon: "mdi-close-circle", + color: "red", + }); + }; + const showSuccess = (msg: string) => { + emitter?.emit("snackbarShow", { + msg, + icon: "mdi-check-bold", + color: "green", + }); + }; + + const hasRom = computed(() => !!props.romId); + + watch( + () => props.initialWalkthroughs, + (next) => { + if (next) walkthroughs.value = next; + }, + ); + + watch( + () => props.romId, + () => { + void loadWalkthroughs(); + }, + ); + + async function loadWalkthroughs() { + if (!hasRom.value) return; + try { + const { data } = await listWalkthroughsForRom(props.romId); + walkthroughs.value = data; + } catch (err) { + console.error(err); + } + } + + onMounted(() => { + void loadWalkthroughs(); + }); + + async function runFetch() { + if (!url.value) { + emitter?.emit("snackbarShow", { + msg: "Walkthrough URL is required", + icon: "mdi-close-circle", + color: "red", + }); + return; + } + + if (!hasRom.value) return; + + loading.value = true; + error.value = null; + + try { + await createWalkthroughForRom({ + romId: props.romId, + url: url.value.trim(), + }); + url.value = ""; + await loadWalkthroughs(); + showSuccess("Walkthrough added to ROM"); + } catch (err) { + showError( + err as ErrorWalkthrough, + "Failed to add walkthrough. Please verify the URL.", + ); + } finally { + loading.value = false; + } + } + + async function uploadFileToRom() { + if (!hasRom.value) return; + if (!uploadFile.value) { + emitter?.emit("snackbarShow", { + msg: "Choose a walkthrough file (PDF, HTML, or TXT)", + icon: "mdi-close-circle", + color: "red", + }); + return; + } + + uploading.value = true; + try { + await uploadWalkthroughForRom({ + romId: props.romId, + file: uploadFile.value, + }); + uploadFile.value = null; + await loadWalkthroughs(); + showSuccess("Walkthrough uploaded"); + } catch (err) { + showError(err as ErrorWalkthrough, "Failed to upload walkthrough."); + } finally { + uploading.value = false; + } + } + + async function removeSavedWalkthrough(id: number) { + removingId.value = id; + try { + await deleteWalkthrough(id); + walkthroughs.value = walkthroughs.value.filter((w) => w.id !== id); + showSuccess("Walkthrough removed"); + } catch (err) { + showError(err as ErrorWalkthrough, "Failed to remove walkthrough."); + } finally { + removingId.value = null; + } + } + + return { + url, + loading, + walkthroughs, + uploadFile, + uploading, + removingId, + runFetch, + uploadFileToRom, + removeSavedWalkthrough, + }; +}; diff --git a/frontend/src/composables/useWalkthrough.ts b/frontend/src/composables/useWalkthrough.ts new file mode 100644 index 000000000..7a9ee0891 --- /dev/null +++ b/frontend/src/composables/useWalkthrough.ts @@ -0,0 +1,288 @@ +import { useLocalStorage } from "@vueuse/core"; +import { nextTick, ref, watch, computed, type Ref } from "vue"; +import { FRONTEND_RESOURCES_PATH } from "@/utils"; + +// Types +export interface Walkthrough { + id: number; + url: string; + title?: string | null; + author?: string | null; + source: string; + format: "html" | "text" | "pdf"; + file_path?: string | null; + content: string; +} + +interface WalkthroughProgress { + lines?: number; + scroll?: number; + percent?: number; +} + +interface UseWalkthroughOptions { + openPanels?: Ref; +} + +// Constants +const CONFIG = { + SCROLL_THRESHOLD: 200, + LINE_CHUNK: 400, + MAX_PROGRESS_PERCENT: 100, + COMPLETION_THRESHOLD: 99, +} as const; + +// Utility functions +/** + * Splits walkthrough content into lines and caches the result + * Reformats text by preserving paragraph breaks but removing arbitrary line breaks + */ +function getWalkthroughLines( + walkthrough: Walkthrough, + cache: Map, +): string[] { + if (cache.has(walkthrough.id)) { + return cache.get(walkthrough.id)!; + } + + if (!walkthrough.content) { + const emptyLines: string[] = []; + cache.set(walkthrough.id, emptyLines); + return emptyLines; + } + + let lines: string[]; + + if (walkthrough.format === "text") { + // For text files, reformat to improve readability + // Split into paragraphs (double line breaks) + const paragraphs = walkthrough.content.split(/\r?\n\s*\r?\n/); + + lines = paragraphs + .map((paragraph) => { + // Within each paragraph, replace single line breaks with spaces + // but preserve the paragraph structure + return paragraph.replace(/\r?\n/g, " ").trim(); + }) + .filter((paragraph) => paragraph.length > 0); // Remove empty paragraphs + } else { + // For HTML and other formats, keep original line splitting + lines = walkthrough.content.split(/\r?\n/); + } + + cache.set(walkthrough.id, lines); + return lines; +} + +/** + * Calculates the number of lines to show based on current progress + */ +function calculateVisibleLineCount( + walkthrough: Walkthrough, + savedProgress: WalkthroughProgress | undefined, + currentVisible: number | undefined, + totalLines: number, +): number { + const savedLines = savedProgress?.lines; + const fallback = Math.min(totalLines, CONFIG.LINE_CHUNK); + + return currentVisible ?? savedLines ?? fallback; +} + +/** + * Enhanced walkthrough composable with better organization and type safety + */ +export function useWalkthrough({ + openPanels = ref([]), +}: UseWalkthroughOptions) { + const visibleLines = ref>({}); + const contentRefs = new Map(); + const lineCache = new Map(); + const storedProgress = useLocalStorage>( + "walkthrough.progress", + {}, + ); + const progressData = computed(() => storedProgress.value); + + // Core functionality + /** + * Gets visible text lines for a walkthrough + */ + function getVisibleText(walkthrough: Walkthrough): string[] { + const lines = getWalkthroughLines(walkthrough, lineCache); + const savedProgress = progressData.value[walkthrough.id]; + const currentVisible = visibleLines.value[walkthrough.id]; + + const lineCount = calculateVisibleLineCount( + walkthrough, + savedProgress, + currentVisible, + lines.length, + ); + + visibleLines.value[walkthrough.id] = lineCount; + return lines.slice(0, lineCount); + } + + /** + * Checks if more content can be shown + */ + function canShowMore(walkthrough: Walkthrough): boolean { + const totalLines = getWalkthroughLines(walkthrough, lineCache).length; + const currentVisible = + visibleLines.value[walkthrough.id] ?? CONFIG.LINE_CHUNK; + + return currentVisible < totalLines; + } + + function showMore(walkthrough: Walkthrough): void { + const totalLines = getWalkthroughLines(walkthrough, lineCache).length; + const currentVisible = + visibleLines.value[walkthrough.id] ?? CONFIG.LINE_CHUNK; + const nextVisible = Math.min( + currentVisible + CONFIG.LINE_CHUNK, + totalLines, + ); + + visibleLines.value[walkthrough.id] = nextVisible; + } + + function getPdfUrl(walkthrough: Walkthrough): string { + return walkthrough.file_path + ? `${FRONTEND_RESOURCES_PATH}/${walkthrough.file_path}` + : ""; + } + + // Progress tracking + function getProgressPercent(walkthrough: Walkthrough): number { + const storedProgress = progressData.value[walkthrough.id]; + if (storedProgress?.percent !== undefined) { + return storedProgress.percent; + } + + if (walkthrough.format === "text") { + const totalLines = + getWalkthroughLines(walkthrough, lineCache).length || 1; + // Use stored lines progress or current visible lines, fallback to 0 + const savedLines = storedProgress?.lines ?? 0; + const currentVisible = visibleLines.value[walkthrough.id] ?? savedLines; + return Math.min( + CONFIG.MAX_PROGRESS_PERCENT, + Math.round((currentVisible / totalLines) * CONFIG.MAX_PROGRESS_PERCENT), + ); + } + + // For HTML and PDF formats without stored data, return 0 + return 0; + } + + const getProgressDisplay = (walkthrough: Walkthrough) => { + const percent = getProgressPercent(walkthrough); + + return { + text: percent >= CONFIG.COMPLETION_THRESHOLD ? "Completed" : percent, + value: percent, + color: + percent === 0 + ? "grey" + : percent < 50 + ? "orange" + : percent < 100 + ? "blue" + : "success", + }; + }; + + function updateProgress( + walkthroughId: number, + updates: Partial, + ): void { + storedProgress.value = { + ...storedProgress.value, + [walkthroughId]: { + ...storedProgress.value[walkthroughId], + ...updates, + }, + }; + } + + // Event handlers + + function handleScroll(walkthrough: Walkthrough, event: Event): void { + const target = event.target as HTMLElement; + if (!target) return; + + const { scrollHeight, scrollTop, clientHeight } = target; + const distanceToBottom = scrollHeight - (scrollTop + clientHeight); + + // Load more content if near bottom + if ( + distanceToBottom < CONFIG.SCROLL_THRESHOLD && + canShowMore(walkthrough) + ) { + showMore(walkthrough); + } + + // Update progress + updateProgress(walkthrough.id, { + lines: visibleLines.value[walkthrough.id] ?? CONFIG.LINE_CHUNK, + scroll: scrollTop, + percent: getProgressPercent(walkthrough), + }); + } + + function storeHtmlProgress(walkthrough: Walkthrough, event: Event): void { + const target = event.target as HTMLElement; + if (!target) return; + + const { scrollHeight, scrollTop, clientHeight } = target; + const percent = Math.min( + CONFIG.MAX_PROGRESS_PERCENT, + Math.round( + ((scrollTop + clientHeight) / scrollHeight) * + CONFIG.MAX_PROGRESS_PERCENT, + ), + ); + + updateProgress(walkthrough.id, { + percent, + scroll: scrollTop, + }); + } + + // Element reference management + function setContentRef(id: number, element: HTMLElement | null): void { + if (element) { + contentRefs.set(id, element); + } else { + contentRefs.delete(id); + } + } + + // Watchers + watch( + () => openPanels.value.slice(), + async (panelIds) => { + await nextTick(); + + panelIds.forEach((id) => { + const savedProgress = progressData.value[id]; + const element = contentRefs.get(id); + + if (savedProgress?.scroll != null && element) { + element.scrollTop = savedProgress.scroll; + } + }); + }, + ); + + return { + getVisibleText, + getPdfUrl, + getProgressDisplay, + handleScroll, + storeHtmlProgress, + setContentRef, + openPanels, + }; +} diff --git a/frontend/src/layouts/Auth.vue b/frontend/src/layouts/Auth.vue index c8533607b..eec2a12c0 100644 --- a/frontend/src/layouts/Auth.vue +++ b/frontend/src/layouts/Auth.vue @@ -7,34 +7,55 @@ const heartbeatStore = storeHeartbeat(); diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts index d7ea00584..bde81fe4d 100644 --- a/frontend/src/services/api/rom.ts +++ b/frontend/src/services/api/rom.ts @@ -5,6 +5,7 @@ import type { RomUserSchema, UserNoteSchema, RomFiltersDict, + WalkthroughSchema, } from "@/__generated__"; import { type CustomLimitOffsetPage_SimpleRomSchema_ as GetRomsResponse } from "@/__generated__/models/CustomLimitOffsetPage_SimpleRomSchema_"; import api from "@/services/api"; @@ -367,6 +368,7 @@ export type UpdateRom = SimpleRom & { flashpoint_metadata?: string; hltb_metadata?: string; }; + walkthroughs?: Array; }; async function updateRom({ diff --git a/frontend/src/services/api/walkthrough.ts b/frontend/src/services/api/walkthrough.ts new file mode 100644 index 000000000..c22966fd9 --- /dev/null +++ b/frontend/src/services/api/walkthrough.ts @@ -0,0 +1,83 @@ +import api from "@/services/api"; + +export type WalkthroughFormat = "html" | "text" | "pdf"; + +export interface WalkthroughResponse { + url: string; + title?: string | null; + author?: string | null; + source: "GAMEFAQS" | "UPLOAD"; + format: WalkthroughFormat; + file_path?: string | null; + content: string; +} + +export interface StoredWalkthrough { + id: number; + rom_id: number; + url: string; + title: string | null; + author: string | null; + source: "GAMEFAQS" | "UPLOAD"; + format: WalkthroughFormat; + file_path: string | null; + content: string; + created_at: string; + updated_at: string; +} + +export async function fetchWalkthrough({ + url, +}: { + url: string; +}): Promise<{ data: WalkthroughResponse }> { + return api.post("/walkthroughs/fetch", { url }); +} + +export async function listWalkthroughsForRom( + romId: number, +): Promise<{ data: StoredWalkthrough[] }> { + return api.get(`/walkthroughs/roms/${romId}`); +} + +export async function createWalkthroughForRom({ + romId, + url, +}: { + romId: number; + url: string; +}): Promise<{ data: StoredWalkthrough }> { + return api.post(`/walkthroughs/roms/${romId}`, { url }); +} + +export async function uploadWalkthroughForRom({ + romId, + file, + title, + author, +}: { + romId: number; + file: File; + title?: string; + author?: string; +}): Promise<{ data: StoredWalkthrough }> { + const form = new FormData(); + form.append("file", file); + if (title) form.append("title", title); + if (author) form.append("author", author); + return api.post(`/walkthroughs/roms/${romId}/upload`, form, { + headers: { "Content-Type": "multipart/form-data" }, + }); +} + +export async function deleteWalkthrough(walkthroughId: number): Promise { + await api.delete(`/walkthroughs/${walkthroughId}`); +} + +export default { + fetchWalkthrough, + listWalkthroughsForRom, + createWalkthroughForRom, + uploadWalkthroughForRom, + deleteWalkthrough, +}; diff --git a/frontend/src/styles/common.css b/frontend/src/styles/common.css index 1b2742514..88f19a891 100644 --- a/frontend/src/styles/common.css +++ b/frontend/src/styles/common.css @@ -1,88 +1,88 @@ html, body { - background-color: rgba(var(--v-theme-background)) !important; - margin: 0 !important; + background-color: rgba(var(--v-theme-background)) !important; + margin: 0 !important; } .main-layout { - z-index: 1010 !important; + z-index: 1010 !important; } .text-shadow { - text-shadow: - 1px 1px 3px #000000, - 0 0 3px #000000 !important; + text-shadow: + 1px 1px 3px #000000, + 0 0 3px #000000 !important; } .translucent { - background: rgba(0, 0, 0, 0.5) !important; - font-weight: 500 !important; + background: rgba(0, 0, 0, 0.5) !important; + font-weight: 500 !important; } -.translucent-light { - background: rgba(0, 0, 0, 0.2) !important; - font-weight: 500 !important; +.transparent { + background: transparent !important; + box-shadow: none !important; } .transparent { background: transparent !important; box-shadow: none !important; } .tooltip :deep(.v-overlay__content) { - background: rgba(255, 255, 255, 1) !important; - color: rgb(41, 41, 41) !important; + background: rgba(255, 255, 255, 1) !important; + color: rgb(41, 41, 41) !important; } .scroll { - overflow-x: visible !important; - overflow-y: auto !important; + overflow-x: visible !important; + overflow-y: auto !important; } .scroll-hidden { - overflow: hidden; + overflow: hidden; } .emoji-collection { - mask-image: linear-gradient( - to right, - black 0%, - black 70%, - transparent 100% - ) !important; + mask-image: linear-gradient( + to right, + black 0%, + black 70%, + transparent 100% + ) !important; } .emoji { - margin: 0 2px !important; + margin: 0 2px !important; } .file-input { - display: none !important; + display: none !important; } .transform-scale { - transition-property: all !important; - transition-duration: 0.1s !important; + transition-property: all !important; + transition-duration: 0.1s !important; } .transform-scale:hover, .transform-scale:focus { - transform: scale(1.07) !important; + transform: scale(1.07) !important; } .pointer { - cursor: pointer !important; + cursor: pointer !important; } .border-selected { - border: 1px solid rgba(var(--v-theme-primary)) !important; - transform: scale(1.04); + border: 1px solid rgba(var(--v-theme-primary)) !important; + transform: scale(1.04); } .greyscale { - filter: grayscale(100%); + filter: grayscale(100%); } .unset-height { - height: unset !important; + height: unset !important; } .drawer-mobile { - width: calc(100% - 16px) !important; + width: calc(100% - 16px) !important; } .bottom-0 { - bottom: 0 !important; + bottom: 0 !important; } .bottom-50 { - bottom: 50px !important; + bottom: 50px !important; } .max-h-50 { - height: auto !important; - max-height: 50dvh !important; + height: auto !important; + max-height: 50dvh !important; } .max-h-70 { - height: auto !important; - max-height: 70dvh !important; + height: auto !important; + max-height: 70dvh !important; } diff --git a/frontend/src/views/GameDetails.vue b/frontend/src/views/GameDetails.vue index 4ffa839d1..d280d7231 100644 --- a/frontend/src/views/GameDetails.vue +++ b/frontend/src/views/GameDetails.vue @@ -15,6 +15,7 @@ import GameInfo from "@/components/Details/Info/GameInfo.vue"; import Personal from "@/components/Details/Personal.vue"; import RelatedGames from "@/components/Details/RelatedGames.vue"; import TitleInfo from "@/components/Details/Title.vue"; +import Walkthroughs from "@/components/Details/Walkthroughs/Walkthroughs.vue"; import EmptyGame from "@/components/common/EmptyStates/EmptyGame.vue"; import GameCard from "@/components/common/Game/Card/Base.vue"; import romApi from "@/services/api/rom"; @@ -42,6 +43,7 @@ const validTabs = [ "additionalcontent", "screenshots", "relatedgames", + "walkthroughs", ] as const; // Initialize tab from query parameter or default to "details" @@ -54,6 +56,7 @@ const tab = ref< | "additionalcontent" | "screenshots" | "relatedgames" + | "walkthroughs" >( validTabs.includes(route.query.tab as any) ? (route.query.tab as @@ -64,7 +67,8 @@ const tab = ref< | "timetobeat" | "additionalcontent" | "screenshots" - | "relatedgames") + | "relatedgames" + | "walkthroughs") : "details", ); const { smAndDown, mdAndDown, mdAndUp, lgAndUp } = useDisplay(); @@ -86,7 +90,10 @@ async function fetchDetails() { noRomError.value = true; }) .finally(() => { - emitter?.emit("showLoadingDialog", { loading: false, scrim: false }); + emitter?.emit("showLoadingDialog", { + loading: false, + scrim: false, + }); fetchingRoms.value = false; }); } @@ -157,7 +164,10 @@ watch( {{ t("rom.personal") }} + + Walkthroughs + {{ t("rom.how-long-to-beat") }} @@ -248,6 +264,12 @@ watch( + + + diff --git a/frontend/src/views/Settings/LibraryManagement.vue b/frontend/src/views/Settings/LibraryManagement.vue index 61288d07e..b0184ae1c 100644 --- a/frontend/src/views/Settings/LibraryManagement.vue +++ b/frontend/src/views/Settings/LibraryManagement.vue @@ -17,97 +17,109 @@ const validTabs = ["mapping", "excluded", "missing"] as const; // Initialize tab from query parameter or default to "config" const tab = ref<"mapping" | "excluded" | "missing">( - validTabs.includes(route.query.tab as any) - ? (route.query.tab as "mapping" | "excluded" | "missing") - : "mapping", + validTabs.includes(route.query.tab as any) + ? (route.query.tab as "mapping" | "excluded" | "missing") + : "mapping", ); const configStore = storeConfig(); const { config } = storeToRefs(configStore); // Watch for tab changes and update URL watch(tab, (newTab) => { - router.replace({ - path: route.path, - query: { - ...route.query, - tab: newTab, - }, - }); + router.replace({ + path: route.path, + query: { + ...route.query, + tab: newTab, + }, + }); }); // Watch for URL changes and update tab watch( - () => route.query.tab, - (newTab) => { - if (newTab && validTabs.includes(newTab as any) && tab.value !== newTab) { - tab.value = newTab as "mapping" | "excluded" | "missing"; - } - }, - { immediate: true }, + () => route.query.tab, + (newTab) => { + if ( + newTab && + validTabs.includes(newTab as any) && + tab.value !== newTab + ) { + tab.value = newTab as "mapping" | "excluded" | "missing"; + } + }, + { immediate: true }, ); diff --git a/pyproject.toml b/pyproject.toml index d0de33cb7..85d520f11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "alembic ~= 1.13", "anyio ~= 4.4", "authlib ~= 1.6.5", + "beautifulsoup4 ~= 4.12", "colorama ~= 0.4", "defusedxml ~= 0.7", "fastapi-pagination[sqlalchemy] ~= 0.15", diff --git a/uv.lock b/uv.lock index 0e03a8e4c..42fb89c0f 100644 --- a/uv.lock +++ b/uv.lock @@ -238,6 +238,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "bidict" version = "0.23.1" @@ -1939,6 +1952,7 @@ dependencies = [ { name = "alembic" }, { name = "anyio" }, { name = "authlib" }, + { name = "beautifulsoup4" }, { name = "colorama" }, { name = "defusedxml" }, { name = "fastapi", extra = ["standard-no-fastapi-cloud-cli"] }, @@ -2007,6 +2021,7 @@ requires-dist = [ { name = "alembic", specifier = "~=1.13" }, { name = "anyio", specifier = "~=4.4" }, { name = "authlib", specifier = "~=1.6.5" }, + { name = "beautifulsoup4", specifier = "~=4.12" }, { name = "colorama", specifier = "~=0.4" }, { name = "defusedxml", specifier = "~=0.7" }, { name = "fakeredis", marker = "extra == 'test'", specifier = "~=2.21" }, @@ -2160,6 +2175,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" }, +] + [[package]] name = "sqlakeyset" version = "2.0.1746777265"