Merge pull request #963 from rommapp/feature/fav_for_siblings

Select main sibling
This commit is contained in:
Zurdi
2024-07-02 17:53:10 +02:00
committed by GitHub
31 changed files with 846 additions and 475 deletions

View File

@@ -0,0 +1,97 @@
"""empty message
Revision ID: 0021_rom_user
Revises: 0020_created_and_updated
Create Date: 2024-06-29 00:11:51.800988
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0021_rom_user"
down_revision = "0020_created_and_updated"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"rom_user",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("note_raw_markdown", sa.Text(), nullable=False),
sa.Column("note_is_public", sa.Boolean(), nullable=True),
sa.Column("is_main_sibling", sa.Boolean(), nullable=False),
sa.Column("rom_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["rom_id"], ["roms.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("rom_id", "user_id", name="unique_rom_user_props"),
)
op.execute(
"""
INSERT INTO rom_user (id, updated_at, note_raw_markdown, note_is_public, is_main_sibling, rom_id, user_id)
SELECT id, updated_at, raw_markdown, is_public, FALSE, rom_id, user_id
FROM rom_notes
"""
)
op.drop_table("rom_notes")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"rom_notes",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("raw_markdown", sa.Text(), nullable=False),
sa.Column("is_public", sa.Boolean(), nullable=True),
sa.Column("rom_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["rom_id"], ["roms.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("rom_id", "user_id", name="unique_rom_user_note"),
)
# Copy the data back from the new table to the old table
op.execute(
"""
INSERT INTO rom_notes (id, updated_at, raw_markdown, is_public, rom_id, user_id)
SELECT id, updated_at, note_raw_markdown, note_is_public, rom_id, user_id
FROM rom_user
"""
)
# Drop the new table
op.drop_table("rom_user")
# ### end Alembic commands ###

View File

@@ -30,40 +30,37 @@ def platforms_webrcade_feed(request: Request) -> WebrcadeFeedSchema:
platforms = db_platform_handler.get_platforms()
with db_platform_handler.session.begin() as session:
return {
"title": "RomM Feed",
"longTitle": "Custom RomM Feed",
"description": "Custom feed from your RomM library",
"thumbnail": "https://raw.githubusercontent.com/rommapp/romm/f2dd425d87ad8e21bf47f8258ae5dcf90f56fbc2/frontend/assets/isotipo.svg",
"background": "https://raw.githubusercontent.com/rommapp/romm/release/.github/screenshots/gallery.png",
"categories": [
{
"title": p.name,
"longTitle": f"{p.name} Games",
"background": f"{ROMM_HOST}/assets/webrcade/feed/{p.slug.lower()}-background.png",
"thumbnail": f"{ROMM_HOST}/assets/webrcade/feed/{p.slug.lower()}-thumb.png",
"description": "",
"items": [
{
"title": rom.name,
"description": rom.summary,
"type": WEBRCADE_SLUG_TO_TYPE_MAP.get(p.slug, p.slug),
"thumbnail": f"{ROMM_HOST}/assets/romm/resources/{rom.path_cover_s}",
"background": f"{ROMM_HOST}/assets/romm/resources/{rom.path_cover_l}",
"props": {
"rom": f"{ROMM_HOST}/api/roms/{rom.id}/content/{rom.file_name}"
},
}
for rom in session.scalars(
db_rom_handler.get_roms(platform_id=p.id)
).all()
],
}
for p in platforms
if p.slug in WEBRCADE_SUPPORTED_PLATFORM_SLUGS
],
}
return {
"title": "RomM Feed",
"longTitle": "Custom RomM Feed",
"description": "Custom feed from your RomM library",
"thumbnail": "https://raw.githubusercontent.com/rommapp/romm/f2dd425d87ad8e21bf47f8258ae5dcf90f56fbc2/frontend/assets/isotipo.svg",
"background": "https://raw.githubusercontent.com/rommapp/romm/release/.github/screenshots/gallery.png",
"categories": [
{
"title": p.name,
"longTitle": f"{p.name} Games",
"background": f"{ROMM_HOST}/assets/webrcade/feed/{p.slug.lower()}-background.png",
"thumbnail": f"{ROMM_HOST}/assets/webrcade/feed/{p.slug.lower()}-thumb.png",
"description": "",
"items": [
{
"title": rom.name,
"description": rom.summary,
"type": WEBRCADE_SLUG_TO_TYPE_MAP.get(p.slug, p.slug),
"thumbnail": f"{ROMM_HOST}/assets/romm/resources/{rom.path_cover_s}",
"background": f"{ROMM_HOST}/assets/romm/resources/{rom.path_cover_l}",
"props": {
"rom": f"{ROMM_HOST}/api/roms/{rom.id}/content/{rom.file_name}"
},
}
for rom in db_rom_handler.get_roms(platform_id=p.id)
],
}
for p in platforms
if p.slug in WEBRCADE_SUPPORTED_PLATFORM_SLUGS
],
}
@protected_route(router.get, "/tinfoil/feed", ["roms.read"])
@@ -79,19 +76,16 @@ def tinfoil_index_feed(request: Request, slug: str = "switch") -> TinfoilFeedSch
TinfoilFeedSchema: Tinfoil feed object schema
"""
switch = db_platform_handler.get_platform_by_fs_slug(slug)
with db_rom_handler.session.begin() as session:
files: list[Rom] = session.scalars(
db_rom_handler.get_roms(platform_id=switch.id)
).all()
files: list[Rom] = db_rom_handler.get_roms(platform_id=switch.id)
return {
"files": [
{
"url": f"{ROMM_HOST}/api/roms/{file.id}/content/{file.file_name}",
"size": file.file_size_bytes,
}
for file in files
],
"directories": [],
"success": "RomM Switch Library",
}
return {
"files": [
{
"url": f"{ROMM_HOST}/api/roms/{file.id}/content/{file.file_name}",
"size": file.file_size_bytes,
}
for file in files
],
"directories": [],
"success": "RomM Switch Library",
}

View File

@@ -3,8 +3,9 @@ from datetime import datetime
from decorators.auth import protected_route
from endpoints.responses import MessageResponse
from endpoints.responses.platform import PlatformSchema
from exceptions.endpoint_exceptions import PlatformNotFoundInDatabaseException
from exceptions.fs_exceptions import PlatformAlreadyExistsException
from fastapi import APIRouter, HTTPException, Request, status
from fastapi import APIRouter, Request
from handler.database import db_platform_handler
from handler.filesystem import fs_platform_handler
from handler.metadata.igdb_handler import IGDB_PLATFORM_LIST
@@ -100,7 +101,12 @@ def get_platform(request: Request, id: int) -> PlatformSchema:
PlatformSchema: Platform
"""
return db_platform_handler.get_platform(id)
platform = db_platform_handler.get_platform(id)
if not platform:
raise PlatformNotFoundInDatabaseException(id)
return platform
@protected_route(router.put, "/platforms/{id}", ["platforms.write"])
@@ -135,10 +141,9 @@ async def delete_platforms(request: Request, id: int) -> MessageResponse:
"""
platform = db_platform_handler.get_platform(id)
if not platform:
error = f"Platform id {id} not found"
log.error(error)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
raise PlatformNotFoundInDatabaseException(id)
log.info(f"Deleting {platform.name} [{platform.fs_slug}] from database")
db_platform_handler.delete_platform(id)

View File

@@ -28,25 +28,39 @@ RomMobyMetadata = TypedDict( # type: ignore[misc]
)
class RomNoteSchema(BaseModel):
class RomUserSchema(BaseModel):
id: int
user_id: int
rom_id: int
created_at: datetime
updated_at: datetime
raw_markdown: str
is_public: bool
note_raw_markdown: str
note_is_public: bool
is_main_sibling: bool
user__username: str
class Config:
from_attributes = True
@classmethod
def for_user(cls, db_rom: Rom, user_id: int) -> list[RomNoteSchema]:
def for_user(cls, db_rom: Rom, user_id: int) -> RomUserSchema | None:
for n in db_rom.rom_users:
if n.user_id == user_id:
return cls.model_validate(n)
return None
@classmethod
def notes_for_user(cls, db_rom: Rom, user_id: int) -> list[UserNotesSchema]:
return [
cls.model_validate(n)
for n in db_rom.notes
{
"user_id": n.user_id,
"username": n.user__username,
"note_raw_markdown": n.note_raw_markdown,
}
for n in db_rom.rom_users
# This is what filters out private notes
if n.user_id == user_id or n.is_public
if n.user_id == user_id or n.note_is_public
]
@@ -95,13 +109,29 @@ class RomSchema(BaseModel):
multi: bool
files: list[str]
full_path: str
created_at: datetime
updated_at: datetime
rom_user: RomUserSchema | None = Field(default=None)
class Config:
from_attributes = True
@classmethod
def from_orm_with_request(cls, db_rom: Rom, request: Request) -> RomSchema:
rom = cls.model_validate(db_rom)
user_id = request.user.id
rom.rom_user = RomUserSchema.for_user(db_rom, user_id)
return rom
@classmethod
def from_orm_with_request_list(
cls, db_roms: list[Rom], request: Request
) -> list[RomSchema]:
return [cls.from_orm_with_request(rom, request) for rom in db_roms]
@computed_field # type: ignore
@property
def sort_comparator(self) -> str:
@@ -117,17 +147,20 @@ class RomSchema(BaseModel):
class DetailedRomSchema(RomSchema):
merged_screenshots: list[str]
rom_user: RomUserSchema | None = Field(default=None)
sibling_roms: list[RomSchema] = Field(default_factory=list)
user_saves: list[SaveSchema] = Field(default_factory=list)
user_states: list[StateSchema] = Field(default_factory=list)
user_screenshots: list[ScreenshotSchema] = Field(default_factory=list)
user_notes: list[RomNoteSchema] = Field(default_factory=list)
user_notes: list[UserNotesSchema] = Field(default_factory=list)
@classmethod
def from_orm_with_request(cls, db_rom: Rom, request: Request) -> DetailedRomSchema:
rom = cls.model_validate(db_rom)
user_id = request.user.id
rom.rom_user = RomUserSchema.for_user(db_rom, user_id)
rom.user_notes = RomUserSchema.notes_for_user(db_rom, user_id)
rom.sibling_roms = [
RomSchema.model_validate(r) for r in db_rom.get_sibling_roms()
]
@@ -142,11 +175,16 @@ class DetailedRomSchema(RomSchema):
for s in db_rom.screenshots
if s.user_id == user_id
]
rom.user_notes = RomNoteSchema.for_user(db_rom, user_id)
return rom
class UserNotesSchema(TypedDict):
user_id: int
username: str
note_raw_markdown: str
class AddRomsResponse(TypedDict):
uploaded_roms: list[str]
skipped_roms: list[str]

View File

@@ -16,9 +16,10 @@ from endpoints.responses.rom import (
AddRomsResponse,
CustomStreamingResponse,
DetailedRomSchema,
RomNoteSchema,
RomSchema,
RomUserSchema,
)
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
from exceptions.fs_exceptions import RomAlreadyExistsException
from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import FileResponse
@@ -107,16 +108,15 @@ def get_roms(
Returns:
list[RomSchema]: List of roms stored in the database
"""
db_roms = db_rom_handler.get_roms(
platform_id=platform_id,
search_term=search_term.lower(),
order_by=order_by.lower(),
order_dir=order_dir.lower(),
limit=limit,
)
with db_rom_handler.session.begin() as session:
return session.scalars(
db_rom_handler.get_roms(
platform_id=platform_id,
search_term=search_term.lower(),
order_by=order_by.lower(),
order_dir=order_dir.lower(),
).limit(limit)
).all()
return RomSchema.from_orm_with_request_list(db_roms, request)
@protected_route(
@@ -134,7 +134,13 @@ def get_rom(request: Request, id: int) -> DetailedRomSchema:
Returns:
DetailedRomSchema: Rom stored in the database
"""
return DetailedRomSchema.from_orm_with_request(db_rom_handler.get_rom(id), request)
rom = db_rom_handler.get_rom(id)
if not rom:
raise RomNotFoundInDatabaseException(id)
return DetailedRomSchema.from_orm_with_request(rom, request)
@protected_route(
@@ -155,6 +161,10 @@ def head_rom_content(request: Request, id: int, file_name: str):
"""
rom = db_rom_handler.get_rom(id)
if not rom:
raise RomNotFoundInDatabaseException(id)
rom_path = f"{LIBRARY_BASE_PATH}/{rom.full_path}"
return FileResponse(
@@ -190,6 +200,10 @@ def get_rom_content(
"""
rom = db_rom_handler.get_rom(id)
if not rom:
raise RomNotFoundInDatabaseException(id)
rom_path = f"{LIBRARY_BASE_PATH}/{rom.full_path}"
files_to_download = files or rom.files
@@ -273,7 +287,10 @@ async def update_rom(
data = await request.form()
db_rom = db_rom_handler.get_rom(id)
rom = db_rom_handler.get_rom(id)
if not rom:
raise RomNotFoundInDatabaseException(id)
cleaned_data = {}
cleaned_data["igdb_id"] = data.get("igdb_id", None)
@@ -291,25 +308,23 @@ async def update_rom(
else:
cleaned_data.update({"igdb_metadata": {}})
cleaned_data["name"] = data.get("name", db_rom.name)
cleaned_data["summary"] = data.get("summary", db_rom.summary)
cleaned_data["name"] = data.get("name", rom.name)
cleaned_data["summary"] = data.get("summary", rom.summary)
fs_safe_file_name = (
data.get("file_name", db_rom.file_name).strip().replace("/", "-")
)
fs_safe_file_name = data.get("file_name", rom.file_name).strip().replace("/", "-")
fs_safe_name = cleaned_data["name"].strip().replace("/", "-")
if rename_as_source:
fs_safe_file_name = db_rom.file_name.replace(
db_rom.file_name_no_tags or db_rom.file_name_no_ext, fs_safe_name
fs_safe_file_name = rom.file_name.replace(
rom.file_name_no_tags or rom.file_name_no_ext, fs_safe_name
)
try:
if db_rom.file_name != fs_safe_file_name:
if rom.file_name != fs_safe_file_name:
fs_rom_handler.rename_file(
old_name=db_rom.file_name,
old_name=rom.file_name,
new_name=fs_safe_file_name,
file_path=db_rom.file_path,
file_path=rom.file_path,
)
except RomAlreadyExistsException as exc:
log.error(str(exc))
@@ -326,7 +341,7 @@ async def update_rom(
)
if remove_cover:
cleaned_data.update(fs_resource_handler.remove_cover(rom=db_rom))
cleaned_data.update(fs_resource_handler.remove_cover(rom=rom))
cleaned_data.update({"url_cover": ""})
else:
if artwork is not None:
@@ -335,7 +350,7 @@ async def update_rom(
path_cover_l,
path_cover_s,
artwork_path,
) = fs_resource_handler.build_artwork_path(db_rom, file_ext)
) = fs_resource_handler.build_artwork_path(rom, file_ext)
cleaned_data["path_cover_l"] = path_cover_l
cleaned_data["path_cover_s"] = path_cover_s
@@ -350,22 +365,19 @@ async def update_rom(
with open(file_location_l, "wb+") as artwork_l:
artwork_l.write(artwork_file)
else:
cleaned_data["url_cover"] = data.get("url_cover", db_rom.url_cover)
cleaned_data["url_cover"] = data.get("url_cover", rom.url_cover)
path_cover_s, path_cover_l = fs_resource_handler.get_rom_cover(
overwrite=True,
rom=db_rom,
rom=rom,
url_cover=cleaned_data.get("url_cover", ""),
)
cleaned_data.update(
{"path_cover_s": path_cover_s, "path_cover_l": path_cover_l}
)
if (
cleaned_data["igdb_id"] != db_rom.igdb_id
or cleaned_data["moby_id"] != db_rom.moby_id
):
if cleaned_data["igdb_id"] != rom.igdb_id or cleaned_data["moby_id"] != rom.moby_id:
path_screenshots = fs_resource_handler.get_rom_screenshots(
rom=db_rom,
rom=rom,
url_screenshots=cleaned_data.get("url_screenshots", []),
)
cleaned_data.update({"path_screenshots": path_screenshots})
@@ -398,10 +410,9 @@ async def delete_roms(
for id in roms_ids:
rom = db_rom_handler.get_rom(id)
if not rom:
error = f"Rom with id {id} not found"
log.error(error)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
raise RomNotFoundInDatabaseException(id)
log.info(f"Deleting {rom.file_name} from database")
db_rom_handler.delete_rom(id)
@@ -427,21 +438,28 @@ async def delete_roms(
return {"msg": f"{len(roms_ids)} roms deleted successfully!"}
@protected_route(router.put, "/roms/{id}/note", ["notes.write"])
async def update_rom_note(request: Request, id: int) -> RomNoteSchema:
db_note = db_rom_handler.get_rom_note(id, request.user.id)
if not db_note:
db_note = db_rom_handler.add_rom_note(id, request.user.id)
@protected_route(router.put, "/roms/{id}/props", ["roms.user.write"])
async def update_rom_user(request: Request, id: int) -> RomUserSchema:
data = await request.json()
db_rom_handler.update_rom_note(
db_note.id,
{
"updated_at": datetime.now(),
"raw_markdown": data.get("raw_markdown", db_note.raw_markdown),
"is_public": data.get("is_public", db_note.is_public),
},
rom = db_rom_handler.get_rom(id)
if not rom:
raise RomNotFoundInDatabaseException(id)
db_rom_user = db_rom_handler.get_rom_user(
id, request.user.id
) or db_rom_handler.add_rom_user(id, request.user.id)
cleaned_data = {}
cleaned_data["note_raw_markdown"] = data.get(
"note_raw_markdown", db_rom_user.note_raw_markdown
)
cleaned_data["note_is_public"] = data.get(
"note_is_public", db_rom_user.note_is_public
)
cleaned_data["is_main_sibling"] = data.get(
"is_main_sibling", db_rom_user.is_main_sibling
)
db_note = db_rom_handler.get_rom_note(id, request.user.id)
return db_note
return db_rom_handler.update_rom_user(db_rom_user.id, cleaned_data)

View File

@@ -29,7 +29,7 @@ from rq import Worker
from rq.job import Job
from sqlalchemy.inspection import inspect
EXCLUDED_FROM_DUMP: Final = {"created_at", "updated_at"}
EXCLUDED_FROM_DUMP: Final = {"created_at", "updated_at", "rom_user"}
class ScanStats:

View File

@@ -0,0 +1,24 @@
from fastapi import HTTPException, status
from logger.logger import log
class PlatformNotFoundInDatabaseException(Exception):
def __init__(self, id):
self.message = f"Platform with id '{id}' not found"
super().__init__(self.message)
log.critical(self.message)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=self.message)
def __repr__(self) -> str:
return self.message
class RomNotFoundInDatabaseException(Exception):
def __init__(self, id):
self.message = f"Rom with id '{id}' not found"
super().__init__(self.message)
log.critical(self.message)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=self.message)
def __repr__(self) -> str:
return self.message

View File

@@ -22,8 +22,8 @@ DEFAULT_SCOPES_MAP: Final = {
"assets.read": "View assets",
"assets.write": "Modify assets",
"firmware.read": "View firmware",
"notes.read": "View notes",
"notes.write": "Modify notes",
"roms.user.read": "View user-rom properties",
"roms.user.write": "Modify user-rom properties",
}
WRITE_SCOPES_MAP: Final = {

View File

@@ -1,35 +1,52 @@
import functools
from decorators.database import begin_session
from models.rom import Rom, RomNote
from sqlalchemy import Select, and_, delete, func, or_, select, update
from models.rom import Rom, RomUser
from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.orm import Query, Session, selectinload
from .base_handler import DBBaseHandler
def with_assets(func):
def with_details(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
session = kwargs.get("session")
if session is None:
raise ValueError("session is required")
raise TypeError(
f"{func} is missing required kwarg 'session' with type 'Session'"
)
kwargs["query"] = select(Rom).options(
selectinload(Rom.saves),
selectinload(Rom.states),
selectinload(Rom.screenshots),
selectinload(Rom.notes),
selectinload(Rom.rom_users),
)
return func(*args, **kwargs)
return wrapper
def with_simple(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
session = kwargs.get("session")
if session is None:
raise TypeError(
f"{func} is missing required kwarg 'session' with type 'Session'"
)
kwargs["query"] = select(Rom).options(selectinload(Rom.rom_users))
return func(*args, **kwargs)
return wrapper
class DBRomsHandler(DBBaseHandler):
def _filter(self, data, platform_id: int | None, search_term: str):
if platform_id:
data = data.filter_by(platform_id=platform_id)
data = data.filter(Rom.platform_id == platform_id)
if search_term:
data = data.filter(
@@ -53,7 +70,7 @@ class DBRomsHandler(DBBaseHandler):
return data.order_by(_column.asc())
@begin_session
@with_assets
@with_details
def add_rom(self, rom: Rom, query: Query = None, session: Session = None) -> Rom:
rom = session.merge(rom)
session.flush()
@@ -61,12 +78,14 @@ class DBRomsHandler(DBBaseHandler):
return session.scalar(query.filter_by(id=rom.id).limit(1))
@begin_session
@with_assets
@with_details
def get_rom(
self, id: int, *, query: Query = None, session: Session = None
) -> Rom | None:
return session.scalar(query.filter_by(id=id).limit(1))
@begin_session
@with_simple
def get_roms(
self,
*,
@@ -74,15 +93,24 @@ class DBRomsHandler(DBBaseHandler):
search_term: str = "",
order_by: str = "name",
order_dir: str = "asc",
) -> Select[tuple[Rom]]:
return self._order(
self._filter(select(Rom), platform_id, search_term),
order_by,
order_dir,
limit: int | None = None,
query: Query = None,
session: Session = None,
) -> list[Rom]:
return (
session.scalars(
self._order(
self._filter(query, platform_id, search_term),
order_by,
order_dir,
).limit(limit)
)
.unique()
.all()
)
@begin_session
@with_assets
@with_details
def get_rom_by_filename(
self,
platform_id: int,
@@ -95,7 +123,7 @@ class DBRomsHandler(DBBaseHandler):
)
@begin_session
@with_assets
@with_details
def get_rom_by_filename_no_tags(
self, file_name_no_tags: str, query: Query = None, session: Session = None
) -> Rom | None:
@@ -104,7 +132,7 @@ class DBRomsHandler(DBBaseHandler):
)
@begin_session
@with_assets
@with_details
def get_rom_by_filename_no_ext(
self, file_name_no_ext: str, query: Query = None, session: Session = None
) -> Rom | None:
@@ -112,6 +140,32 @@ class DBRomsHandler(DBBaseHandler):
query.filter_by(file_name_no_ext=file_name_no_ext).limit(1)
)
@begin_session
@with_simple
def get_sibling_roms(
self, rom: Rom, query: Query = None, session: Session = None
) -> list[Rom]:
return session.scalars(
query.where(
and_(
Rom.platform_id == rom.platform_id,
Rom.id != rom.id,
or_(
and_(
Rom.igdb_id == rom.igdb_id,
Rom.igdb_id.isnot(None),
Rom.igdb_id != "",
),
and_(
Rom.moby_id == rom.moby_id,
Rom.moby_id.isnot(None),
Rom.moby_id != "",
),
),
)
)
).all()
@begin_session
def update_rom(self, id: int, data: dict, session: Session = None) -> Rom:
return session.execute(
@@ -140,24 +194,46 @@ class DBRomsHandler(DBBaseHandler):
)
@begin_session
def get_rom_note(
def add_rom_user(
self, rom_id: int, user_id: int, session: Session = None
) -> RomNote | None:
) -> RomUser:
return session.merge(RomUser(rom_id=rom_id, user_id=user_id))
@begin_session
def get_rom_user(
self, rom_id: int, user_id: int, session: Session = None
) -> RomUser | None:
return session.scalar(
select(RomNote).filter_by(rom_id=rom_id, user_id=user_id).limit(1)
select(RomUser).filter_by(rom_id=rom_id, user_id=user_id).limit(1)
)
@begin_session
def add_rom_note(
self, rom_id: int, user_id: int, session: Session = None
) -> RomNote:
return session.merge(RomNote(rom_id=rom_id, user_id=user_id))
def get_rom_user_by_id(self, id: int, session: Session = None) -> RomUser | None:
return session.scalar(select(RomUser).filter_by(id=id).limit(1))
@begin_session
def update_rom_note(self, id: int, data: dict, session: Session = None) -> RomNote:
return session.execute(
update(RomNote)
.where(RomNote.id == id)
def update_rom_user(self, id: int, data: dict, session: Session = None) -> RomUser:
session.execute(
update(RomUser)
.where(RomUser.id == id)
.values(**data)
.execution_options(synchronize_session="evaluate")
)
rom_user = self.get_rom_user_by_id(id)
if data["is_main_sibling"]:
session.execute(
update(RomUser)
.where(
and_(
RomUser.rom_id.in_(
[rom.id for rom in rom_user.rom.get_sibling_roms()]
),
RomUser.user_id == rom_user.user_id,
)
)
.values(is_main_sibling=False)
)
return self.get_rom_user_by_id(id)

View File

@@ -29,7 +29,7 @@ class DBUsersHandler(DBBaseHandler):
)
@begin_session
def get_users(self, session: Session = None):
def get_users(self, session: Session = None) -> list[User]:
return session.scalars(select(User)).all()
@begin_session
@@ -41,5 +41,5 @@ class DBUsersHandler(DBBaseHandler):
)
@begin_session
def get_admin_users(self, session: Session = None):
def get_admin_users(self, session: Session = None) -> list[User]:
return session.scalars(select(User).filter_by(role=Role.ADMIN)).all()

View File

@@ -46,9 +46,8 @@ def test_roms(rom: Rom, platform: Platform):
)
)
with db_rom_handler.session.begin() as session:
roms = session.scalars(db_rom_handler.get_roms(platform_id=platform.id)).all()
assert len(roms) == 2
roms = db_rom_handler.get_roms(platform_id=platform.id)
assert len(roms) == 2
rom = db_rom_handler.get_rom(roms[0].id)
assert rom is not None
@@ -61,26 +60,23 @@ def test_roms(rom: Rom, platform: Platform):
db_rom_handler.delete_rom(rom.id)
with db_rom_handler.session.begin() as session:
roms = session.scalars(db_rom_handler.get_roms(platform_id=platform.id)).all()
assert len(roms) == 1
roms = db_rom_handler.get_roms(platform_id=platform.id)
assert len(roms) == 1
db_rom_handler.purge_roms(rom_2.platform_id, [rom_2.id])
with db_rom_handler.session.begin() as session:
roms = session.scalars(db_rom_handler.get_roms(platform_id=platform.id)).all()
assert len(roms) == 0
roms = db_rom_handler.get_roms(platform_id=platform.id)
assert len(roms) == 0
def test_utils(rom: Rom, platform: Platform):
with db_rom_handler.session.begin() as session:
roms = session.scalars(db_rom_handler.get_roms(platform_id=platform.id)).all()
assert (
db_rom_handler.get_rom_by_filename(
platform_id=platform.id, file_name=rom.file_name
).id
== roms[0].id
)
roms = db_rom_handler.get_roms(platform_id=platform.id)
assert (
db_rom_handler.get_rom_by_filename(
platform_id=platform.id, file_name=rom.file_name
).id
== roms[0].id
)
def test_users(admin_user):

View File

@@ -1,24 +1,11 @@
from __future__ import annotations
from datetime import datetime
from functools import cached_property
from typing import TYPE_CHECKING, Any
from config import FRONTEND_RESOURCES_PATH
from models.base import BaseModel
from sqlalchemy import (
JSON,
BigInteger,
DateTime,
ForeignKey,
String,
Text,
UniqueConstraint,
and_,
func,
or_,
select,
)
from sqlalchemy import JSON, BigInteger, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.dialects.mysql.json import JSON as MySQLJSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -82,7 +69,7 @@ class Rom(BaseModel):
saves: Mapped[list[Save]] = relationship(back_populates="rom")
states: Mapped[list[State]] = relationship(back_populates="rom")
screenshots: Mapped[list[Screenshot]] = relationship(back_populates="rom")
notes: Mapped[list[RomNote]] = relationship(back_populates="rom")
rom_users: Mapped[list[RomUser]] = relationship(back_populates="rom")
@property
def platform_slug(self) -> str:
@@ -114,27 +101,7 @@ class Rom(BaseModel):
def get_sibling_roms(self) -> list[Rom]:
from handler.database import db_rom_handler
with db_rom_handler.session.begin() as session:
return session.scalars(
select(Rom).where(
and_(
Rom.platform_id == self.platform_id,
Rom.id != self.id,
or_(
and_(
Rom.igdb_id == self.igdb_id,
Rom.igdb_id is not None,
Rom.igdb_id != "",
),
and_(
Rom.moby_id == self.moby_id,
Rom.moby_id is not None,
Rom.moby_id != "",
),
),
)
)
).all()
return db_rom_handler.get_sibling_roms(self)
# Metadata fields
@property
@@ -181,24 +148,24 @@ class Rom(BaseModel):
return self.file_name
class RomNote(BaseModel):
__tablename__ = "rom_notes"
class RomUser(BaseModel):
__tablename__ = "rom_user"
__table_args__ = (
UniqueConstraint("rom_id", "user_id", name="unique_rom_user_note"),
UniqueConstraint("rom_id", "user_id", name="unique_rom_user_props"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
raw_markdown: Mapped[str] = mapped_column(Text, default="")
is_public: Mapped[bool | None] = mapped_column(default=False)
note_raw_markdown: Mapped[str] = mapped_column(Text, default="")
note_is_public: Mapped[bool | None] = mapped_column(default=False)
is_main_sibling: Mapped[bool | None] = mapped_column(default=False)
rom_id: Mapped[int] = mapped_column(ForeignKey("roms.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
rom: Mapped[Rom] = relationship(lazy="joined", back_populates="notes")
user: Mapped[User] = relationship(lazy="joined", back_populates="notes")
rom: Mapped[Rom] = relationship(lazy="joined", back_populates="rom_users")
user: Mapped[User] = relationship(lazy="joined", back_populates="rom_users")
@property
def user__username(self) -> str:

View File

@@ -11,7 +11,7 @@ from starlette.authentication import SimpleUser
if TYPE_CHECKING:
from models.assets import Save, Screenshot, State
from models.rom import RomNote
from models.rom import RomUser
class Role(enum.Enum):
@@ -39,7 +39,7 @@ class User(BaseModel, SimpleUser):
saves: Mapped[list[Save]] = relationship(back_populates="user")
states: Mapped[list[State]] = relationship(back_populates="user")
screenshots: Mapped[list[Screenshot]] = relationship(back_populates="user")
notes: Mapped[list[RomNote]] = relationship(back_populates="user")
rom_users: Mapped[list[RomUser]] = relationship(back_populates="user")
@property
def oauth_scopes(self):

View File

@@ -27,8 +27,8 @@ export type { PlatformSchema } from "./models/PlatformSchema";
export type { Role } from "./models/Role";
export type { RomIGDBMetadata } from "./models/RomIGDBMetadata";
export type { RomMobyMetadata } from "./models/RomMobyMetadata";
export type { RomNoteSchema } from "./models/RomNoteSchema";
export type { RomSchema } from "./models/RomSchema";
export type { RomUserSchema } from "./models/RomUserSchema";
export type { SaveSchema } from "./models/SaveSchema";
export type { SchedulerDict } from "./models/SchedulerDict";
export type { ScreenshotSchema } from "./models/ScreenshotSchema";
@@ -42,6 +42,7 @@ export type { TokenResponse } from "./models/TokenResponse";
export type { UploadedSavesResponse } from "./models/UploadedSavesResponse";
export type { UploadedScreenshotsResponse } from "./models/UploadedScreenshotsResponse";
export type { UploadedStatesResponse } from "./models/UploadedStatesResponse";
export type { UserNotesSchema } from "./models/UserNotesSchema";
export type { UserSchema } from "./models/UserSchema";
export type { ValidationError } from "./models/ValidationError";
export type { WatcherDict } from "./models/WatcherDict";

View File

@@ -5,11 +5,12 @@
import type { RomIGDBMetadata } from "./RomIGDBMetadata";
import type { RomMobyMetadata } from "./RomMobyMetadata";
import type { RomNoteSchema } from "./RomNoteSchema";
import type { RomSchema } from "./RomSchema";
import type { RomUserSchema } from "./RomUserSchema";
import type { SaveSchema } from "./SaveSchema";
import type { ScreenshotSchema } from "./ScreenshotSchema";
import type { StateSchema } from "./StateSchema";
import type { UserNotesSchema } from "./UserNotesSchema";
export type DetailedRomSchema = {
id: number;
@@ -50,11 +51,12 @@ export type DetailedRomSchema = {
full_path: string;
created_at: string;
updated_at: string;
rom_user?: RomUserSchema | null;
merged_screenshots: Array<string>;
sibling_roms?: Array<RomSchema>;
user_saves?: Array<SaveSchema>;
user_states?: Array<StateSchema>;
user_screenshots?: Array<ScreenshotSchema>;
user_notes?: Array<RomNoteSchema>;
user_notes?: Array<UserNotesSchema>;
readonly sort_comparator: string;
};

View File

@@ -5,6 +5,7 @@
import type { RomIGDBMetadata } from "./RomIGDBMetadata";
import type { RomMobyMetadata } from "./RomMobyMetadata";
import type { RomUserSchema } from "./RomUserSchema";
export type RomSchema = {
id: number;
@@ -45,5 +46,6 @@ export type RomSchema = {
full_path: string;
created_at: string;
updated_at: string;
rom_user?: RomUserSchema | null;
readonly sort_comparator: string;
};

View File

@@ -3,12 +3,14 @@
/* tslint:disable */
/* eslint-disable */
export type RomNoteSchema = {
export type RomUserSchema = {
id: number;
user_id: number;
rom_id: number;
created_at: string;
updated_at: string;
raw_markdown: string;
is_public: boolean;
note_raw_markdown: string;
note_is_public: boolean;
is_main_sibling: boolean;
user__username: string;
};

View File

@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UserNotesSchema = {
user_id: number;
username: string;
note_raw_markdown: string;
};

View File

@@ -1,77 +1,155 @@
<script setup lang="ts">
import VersionSwitcher from "@/components/Details/VersionSwitcher.vue";
import romApi from "@/services/api/rom";
import storeAuth from "@/stores/auth";
import storeDownload from "@/stores/download";
import type { Platform } from "@/stores/platforms";
import type { DetailedRom } from "@/stores/roms";
import { formatBytes } from "@/utils";
import { ref, watch } from "vue";
defineProps<{ rom: DetailedRom; platform: Platform }>();
// Props
const props = defineProps<{ rom: DetailedRom; platform: Platform }>();
const downloadStore = storeDownload();
const auth = storeAuth();
const romUser = ref(
props.rom.rom_user ?? {
id: null,
user_id: auth.user?.id,
rom_id: props.rom.id,
updated_at: new Date(),
note_raw_markdown: "",
note_is_public: false,
is_main_sibling: false,
}
);
// Functions
async function toggleMainSibling() {
ownProps.value.is_main_sibling = !ownProps.value.is_main_sibling;
romApi.updateUserRomProps({
romId: props.rom.id,
noteRawMarkdown: ownProps.value.note_raw_markdown,
noteIsPublic: ownProps.value.note_is_public,
isMainSibling: ownProps.value.is_main_sibling,
});
}
watch(
() => props.rom,
async () => {
ownProps.value = props.rom.rom_user ?? {
id: null,
user_id: auth.user?.id,
rom_id: props.rom.id,
updated_at: new Date(),
note_raw_markdown: "",
note_is_public: false,
is_main_sibling: false,
};
}
);
</script>
<template>
<v-row
v-if="rom.sibling_roms && rom.sibling_roms.length > 0"
class="align-center my-3"
no-gutters
>
<v-col cols="3" xl="2">
<span>Ver.</span>
</v-col>
<v-row no-gutters>
<v-col>
<version-switcher :rom="rom" :platform="platform" />
</v-col>
</v-row>
<v-row v-if="!rom.multi" class="align-center my-3" no-gutters>
<v-col cols="3" xl="2">
<span>File</span>
</v-col>
<v-col class="text-body-1">
<span>{{ rom.file_name }}</span>
</v-col>
</v-row>
<v-row v-if="rom.multi" class="align-center my-3" no-gutters>
<v-col cols="3" xl="2">
<span>Files</span>
</v-col>
<v-col>
<v-select
v-model="downloadStore.filesToDownloadMultiFileRom"
:label="rom.file_name"
item-title="file_name"
:items="rom.files"
class="my-2"
density="compact"
variant="outlined"
return-object
multiple
hide-details
clearable
chips
/>
</v-col>
</v-row>
<v-row class="align-center my-3" no-gutters>
<v-col cols="3" xl="2">
<span>Size</span>
</v-col>
<v-col>
<span>{{ formatBytes(rom.file_size_bytes) }}</span>
</v-col>
</v-row>
<v-row v-if="rom.tags.length > 0" class="align-center my-3" no-gutters>
<v-col cols="3" xl="2">
<span>Tags</span>
</v-col>
<v-col>
<v-chip
v-for="tag in rom.tags"
:key="tag"
class="mr-2 py-1"
label
variant="outlined"
<v-row
v-if="rom.sibling_roms && rom.sibling_roms.length > 0"
class="align-center my-3"
no-gutters
>
{{ tag }}
</v-chip>
<v-col cols="3" xl="2">
<span>Version</span>
</v-col>
<v-col>
<v-row class="align-center" no-gutters>
<version-switcher :rom="rom" :platform="platform" />
<v-tooltip
location="top"
class="tooltip"
transition="fade-transition"
text="Set as default version"
open-delay="300"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="flat"
rounded="0"
size="small"
@click="toggleMainSibling"
><v-icon
:class="ownProps.is_main_sibling ? '' : 'mr-1'"
:color="ownProps.is_main_sibling ? 'romm-accent-1' : ''"
>{{
ownProps.is_main_sibling
? "mdi-checkbox-outline"
: "mdi-checkbox-blank-outline"
}}</v-icon
>{{ ownProps.is_main_sibling ? "" : "Default" }}</v-btn
>
</template></v-tooltip
>
</v-row>
</v-col>
</v-row>
<v-row v-if="!rom.multi" class="align-center my-3" no-gutters>
<v-col cols="3" xl="2">
<span>File</span>
</v-col>
<v-col>
<span class="text-body-1">{{ rom.file_name }}</span>
</v-col>
</v-row>
<v-row v-if="rom.multi" class="align-center my-3" no-gutters>
<v-col cols="3" xl="2">
<span>Files</span>
</v-col>
<v-col>
<v-select
v-model="downloadStore.filesToDownloadMultiFileRom"
:label="rom.file_name"
item-title="file_name"
:items="rom.files"
rounded="0"
density="compact"
variant="outlined"
return-object
multiple
hide-details
clearable
chips
/>
</v-col>
</v-row>
<v-row no-gutters class="align-center my-3">
<v-col cols="3" xl="2">
<span>Size</span>
</v-col>
<v-col>
<v-chip size="small" label>{{
formatBytes(rom.file_size_bytes)
}}</v-chip>
</v-col>
</v-row>
<v-row v-if="rom.tags.length > 0" class="align-center my-3" no-gutters>
<v-col cols="3" xl="2">
<span>Tags</span>
</v-col>
<v-col>
<v-chip
v-for="tag in rom.tags"
:key="tag"
size="small"
class="mr-2"
label
color="romm-accent-1"
variant="tonal"
>
{{ tag }}
</v-chip>
</v-col>
</v-row>
</v-col>
</v-row>
</template>

View File

@@ -10,108 +10,116 @@ const galleryFilter = storeGalleryFilter();
const show = ref(false);
</script>
<template>
<template v-for="filter in galleryFilter.filters" :key="filter">
<template>
<v-row no-gutters>
<v-col>
<v-divider class="mx-2 my-4" />
<v-row v-if="rom[filter].length > 0" class="align-center my-3" no-gutters>
<v-col cols="3" xl="2" class="text-capitalize">
<span>{{ filter }}</span>
</v-col>
<v-col>
<v-chip
v-for="value in rom[filter]"
:key="value"
class="my-1 mr-2"
label
>
{{ value }}
</v-chip>
</v-col>
</v-row>
</template>
</template>
<template v-if="rom.summary != ''">
<v-divider class="mx-2 my-4" />
<v-row no-gutters>
<v-col class="text-caption">
<p v-text="rom.summary" />
</v-col>
</v-row>
</template>
<template v-if="rom.merged_screenshots.length > 0">
<v-divider class="mx-2 my-4" />
<v-row no-gutters>
<v-col>
<v-carousel
hide-delimiter-background
delimiter-icon="mdi-square"
class="bg-primary"
show-arrows="hover"
hide-delimiters
progress="terciary"
:height="xs ? '300' : '400'"
<template v-for="filter in galleryFilter.filters" :key="filter">
<v-row
v-if="rom[filter].length > 0"
class="align-center my-3"
no-gutters
>
<template #prev="{ props }">
<v-btn
icon="mdi-chevron-left"
class="translucent-dark"
@click="props.onClick"
/>
</template>
<v-carousel-item
v-for="screenshot_url in rom.merged_screenshots"
:key="screenshot_url"
:src="screenshot_url"
class="pointer"
@click="show = true"
>
</v-carousel-item>
<template #next="{ props }">
<v-btn
icon="mdi-chevron-right"
class="translucent-dark"
@click="props.onClick"
/>
</template>
</v-carousel>
<v-dialog v-model="show">
<v-list-item>
<template #append>
<v-btn @click="show = false" icon variant="flat" size="large"
><v-icon class="text-white text-shadow" size="25">mdi-close</v-icon></v-btn
>
</template>
</v-list-item>
<v-carousel
hide-delimiter-background
delimiter-icon="mdi-square"
show-arrows="hover"
hide-delimiters
:height="xs ? '500' : '600'"
>
<template #prev="{ props }">
<v-btn
@click="props.onClick"
icon="mdi-chevron-left"
class="translucent-dark"
/>
</template>
<v-carousel-item
v-for="screenshot_url in rom.merged_screenshots"
:key="screenshot_url"
:src="screenshot_url"
<v-col cols="3" xl="2" class="text-capitalize">
<span>{{ filter }}</span>
</v-col>
<v-col>
<v-chip
v-for="value in rom[filter]"
:key="value"
size="small"
variant="outlined"
class="my-1 mr-2"
label
>
</v-carousel-item>
<template #next="{ props }">
<v-btn
icon="mdi-chevron-right"
class="translucent-dark"
@click="props.onClick"
/>
</template>
</v-carousel>
</v-dialog>
</v-col>
</v-row>
</template>
{{ value }}
</v-chip>
</v-col>
</v-row>
</template>
<template v-if="rom.summary != ''">
<v-divider class="mx-2 my-4" />
<v-row no-gutters>
<v-col class="text-caption">
<p v-text="rom.summary" />
</v-col>
</v-row>
</template>
<template v-if="rom.merged_screenshots.length > 0">
<v-divider class="mx-2 my-4" />
<v-row no-gutters>
<v-col>
<v-carousel
hide-delimiter-background
delimiter-icon="mdi-square"
class="bg-primary"
show-arrows="hover"
hide-delimiters
progress="terciary"
:height="xs ? '300' : '400'"
>
<template #prev="{ props }">
<v-btn
icon="mdi-chevron-left"
class="translucent-dark"
@click="props.onClick"
/>
</template>
<v-carousel-item
v-for="screenshot_url in rom.merged_screenshots"
:key="screenshot_url"
:src="screenshot_url"
class="pointer"
@click="show = true"
>
</v-carousel-item>
<template #next="{ props }">
<v-btn
icon="mdi-chevron-right"
class="translucent-dark"
@click="props.onClick"
/>
</template>
</v-carousel>
<v-dialog v-model="show">
<v-list-item>
<template #append>
<v-btn @click="show = false" icon variant="flat" size="large"
><v-icon class="text-white text-shadow" size="25"
>mdi-close</v-icon
></v-btn
>
</template>
</v-list-item>
<v-carousel
hide-delimiter-background
delimiter-icon="mdi-square"
show-arrows="hover"
hide-delimiters
:height="xs ? '500' : '600'"
>
<template #prev="{ props }">
<v-btn
@click="props.onClick"
icon="mdi-chevron-left"
class="translucent-dark"
/>
</template>
<v-carousel-item
v-for="screenshot_url in rom.merged_screenshots"
:key="screenshot_url"
:src="screenshot_url"
>
</v-carousel-item>
<template #next="{ props }">
<v-btn
icon="mdi-chevron-right"
class="translucent-dark"
@click="props.onClick"
/>
</template>
</v-carousel>
</v-dialog>
</v-col>
</v-row> </template></v-col
></v-row>
</template>

View File

@@ -4,45 +4,65 @@ import storeAuth from "@/stores/auth";
import type { DetailedRom } from "@/stores/roms";
import { MdEditor, MdPreview } from "md-editor-v3";
import "md-editor-v3/lib/style.css";
import { ref } from "vue";
import { ref, watch } from "vue";
import { useTheme } from "vuetify";
// Props
const props = defineProps<{ rom: DetailedRom }>();
const auth = storeAuth();
const theme = useTheme();
const editingNote = ref(false);
const ownNote = ref(
props.rom.user_notes?.find((note) => note.user_id === auth.user?.id) ?? {
const romUser = ref(
props.rom.rom_user ?? {
id: null,
user_id: auth.user?.id,
rom_id: props.rom.id,
updated_at: new Date(),
raw_markdown: "",
is_public: false,
note_raw_markdown: "",
note_is_public: false,
is_main_sibling: false,
}
);
const publicNotes =
props.rom.user_notes?.filter((note) => note.user_id !== auth.user?.id) ?? [];
// Functions
function togglePublic() {
ownNote.value.is_public = !ownNote.value.is_public;
romApi.updateRomNote({
romUser.value.note_is_public = !romUser.value.note_is_public;
romApi.updateUserRomProps({
romId: props.rom.id,
rawMarkdown: ownNote.value.raw_markdown,
isPublic: ownNote.value.is_public,
noteRawMarkdown: romUser.value.note_raw_markdown,
noteIsPublic: romUser.value.note_is_public,
isMainSibling: romUser.value.is_main_sibling,
});
}
function onEditNote() {
function editNote() {
if (editingNote.value) {
romApi.updateRomNote({
romApi.updateUserRomProps({
romId: props.rom.id,
rawMarkdown: ownNote.value.raw_markdown,
isPublic: ownNote.value.is_public,
noteRawMarkdown: romUser.value.note_raw_markdown,
noteIsPublic: romUser.value.note_is_public,
isMainSibling: romUser.value.is_main_sibling,
});
}
editingNote.value = !editingNote.value;
}
watch(
() => props.rom,
async () => {
romUser.value = props.rom.rom_user ?? {
id: null,
user_id: auth.user?.id,
rom_id: props.rom.id,
updated_at: new Date(),
note_raw_markdown: "",
note_is_public: false,
is_main_sibling: false,
};
}
);
</script>
<template>
<v-card rounded="0">
@@ -55,7 +75,7 @@ function onEditNote() {
location="top"
class="tooltip"
transition="fade-transition"
:text="ownNote.is_public ? 'Make private' : 'Make public'"
:text="romUser.note_is_public ? 'Make private' : 'Make public'"
open-delay="500"
><template #activator="{ props: tooltipProps }">
<v-btn
@@ -64,7 +84,7 @@ function onEditNote() {
class="bg-terciary"
>
<v-icon size="large">
{{ ownNote.is_public ? "mdi-eye" : "mdi-eye-off" }}
{{ romUser.note_is_public ? "mdi-eye" : "mdi-eye-off" }}
</v-icon>
</v-btn>
</template></v-tooltip
@@ -77,7 +97,7 @@ function onEditNote() {
open-delay="500"
><template #activator="{ props: tooltipProps }">
<v-btn
@click="onEditNote"
@click="editNote"
v-bind="tooltipProps"
class="bg-terciary"
>
@@ -94,7 +114,7 @@ function onEditNote() {
<v-card-text class="pa-2">
<MdEditor
v-if="editingNote"
v-model="ownNote.raw_markdown"
v-model="romUser.note_raw_markdown"
:theme="theme.name.value == 'dark' ? 'dark' : 'light'"
language="en-US"
:preview="false"
@@ -103,7 +123,7 @@ function onEditNote() {
/>
<MdPreview
v-else
:model-value="ownNote.raw_markdown"
:model-value="romUser.note_raw_markdown"
:theme="theme.name.value == 'dark' ? 'dark' : 'light'"
preview-theme="vuepress"
code-theme="github"
@@ -111,7 +131,11 @@ function onEditNote() {
</v-card-text>
</v-card>
<v-card rounded="0" v-if="publicNotes.length > 0" class="mt-2">
<v-card
rounded="0"
v-if="publicNotes && publicNotes.length > 0"
class="mt-2"
>
<v-card-title class="bg-terciary">
<v-list-item class="pl-2 pr-0">
<span class="text-h6">Public notes</span>
@@ -122,13 +146,13 @@ function onEditNote() {
<v-card-text class="pa-0">
<v-expansion-panels multiple flat rounded="0" variant="accordion">
<v-expansion-panel v-for="note in publicNotes" :key="note.id">
<v-expansion-panel v-for="note in publicNotes">
<v-expansion-panel-title class="bg-terciary">
<span class="text-body-1">{{ note.user__username }}</span>
<span class="text-body-1">{{ note.username }}</span>
</v-expansion-panel-title>
<v-expansion-panel-text class="bg-secondary">
<MdPreview
:model-value="note.raw_markdown"
:model-value="note.note_raw_markdown"
:theme="theme.name.value == 'dark' ? 'dark' : 'light'"
preview-theme="vuepress"
code-theme="github"

View File

@@ -11,14 +11,8 @@ import { useDisplay } from "vuetify";
// Props
const { xs, smAndUp, mdAndUp } = useDisplay();
const props = defineProps<{ rom: DetailedRom }>();
const romRef = ref<DetailedRom>(props.rom);
const selectedSaves = ref<SaveSchema[]>([]);
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("romUpdated", (romUpdated) => {
if (romUpdated?.id === romRef.value.id) {
romRef.value.user_saves = romUpdated.user_saves;
}
});
const HEADERS = [
{
title: "Name",
@@ -46,9 +40,9 @@ async function downloasSaves() {
}
function updateDataTablePages() {
if (romRef.value.user_saves) {
if (props.rom.user_saves) {
pageCount.value = Math.ceil(
romRef.value.user_saves.length / itemsPerPage.value
props.rom.user_saves.length / itemsPerPage.value
);
}
}
@@ -64,7 +58,7 @@ onMounted(() => {
<template>
<v-data-table
:items="romRef.user_saves"
:items="rom.user_saves"
:width="mdAndUp ? '60vw' : '95vw'"
:items-per-page="itemsPerPage"
:items-per-page-options="PER_PAGE_OPTIONS"
@@ -80,7 +74,7 @@ onMounted(() => {
<v-btn
class="bg-secondary"
size="small"
@click="emitter?.emit('addSavesDialog', romRef)"
@click="emitter?.emit('addSavesDialog', rom)"
>
<v-icon>mdi-upload</v-icon>
</v-btn>
@@ -152,7 +146,7 @@ onMounted(() => {
</td>
</template>
<template #no-data
><span>No saves found for {{ romRef?.name }}</span></template
><span>No saves found for {{ rom.name }}</span></template
>
<template #item.actions="{ item }">
<v-btn-group divided density="compact">

View File

@@ -11,14 +11,9 @@ import { useDisplay } from "vuetify";
// Props
const { xs, smAndUp, mdAndUp } = useDisplay();
const props = defineProps<{ rom: DetailedRom }>();
const romRef = ref<DetailedRom>(props.rom);
const selectedStates = ref<StateSchema[]>([]);
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("romUpdated", (romUpdated) => {
if (romUpdated?.id === romRef.value.id) {
romRef.value.user_states = romUpdated.user_states;
}
});
// emitter?.on("romUpdated", (romUpdated) => {});
const HEADERS = [
{
title: "Name",
@@ -46,9 +41,9 @@ async function downloasStates() {
}
function updateDataTablePages() {
if (romRef.value.user_states) {
if (props.rom.user_states) {
pageCount.value = Math.ceil(
romRef.value.user_states.length / itemsPerPage.value
props.rom.user_states.length / itemsPerPage.value
);
}
}
@@ -64,7 +59,7 @@ onMounted(() => {
<template>
<v-data-table
:items="romRef.user_states"
:items="rom.user_states"
:width="mdAndUp ? '60vw' : '95vw'"
:items-per-page="itemsPerPage"
:items-per-page-options="PER_PAGE_OPTIONS"
@@ -80,7 +75,7 @@ onMounted(() => {
<v-btn
class="bg-secondary"
size="small"
@click="emitter?.emit('addStatesDialog', romRef)"
@click="emitter?.emit('addStatesDialog', rom)"
>
<v-icon>mdi-upload</v-icon>
</v-btn>
@@ -152,7 +147,7 @@ onMounted(() => {
</td>
</template>
<template #no-data
><span>No states found for {{ romRef?.name }}</span></template
><span>No states found for {{ rom.name }}</span></template
>
<template #item.actions="{ item }">
<v-btn-group divided density="compact">

View File

@@ -31,9 +31,11 @@ function updateVersion() {
<v-select
v-model="version"
label="Version"
variant="outlined"
single-line
rounded="0"
variant="solo-filled"
density="compact"
class="version-select"
max-width="fit-content"
hide-details
:items="
[rom, ...rom.sibling_roms].map((i) => ({
@@ -44,9 +46,3 @@ function updateVersion() {
@update:model-value="updateVersion"
/>
</template>
<style scoped>
.version-select {
max-width: fit-content;
}
</style>

View File

@@ -145,6 +145,7 @@ onMounted(() => {
<v-img
:src="`/assets/default/cover/big_${theme.global.name.value}_missing_cover.png`"
cover
:aspect-ratio="2 / 3"
></v-img>
</template>
<template #placeholder>

View File

@@ -214,7 +214,7 @@ onBeforeUnmount(() => {
cover
>
<template #error>
<v-img :src="resource.url" cover></v-img>
<v-img :src="resource.url" cover :aspect-ratio="2 / 3"></v-img>
</template>
<template #placeholder>
<div

View File

@@ -166,18 +166,21 @@ async function deleteRoms({
});
}
async function updateRomNote({
async function updateUserRomProps({
romId,
rawMarkdown,
isPublic,
noteRawMarkdown,
noteIsPublic,
isMainSibling,
}: {
romId: number;
rawMarkdown: string;
isPublic: boolean;
noteRawMarkdown: string;
noteIsPublic: boolean;
isMainSibling: boolean;
}): Promise<{ data: DetailedRom }> {
return api.put(`/roms/${romId}/note`, {
raw_markdown: rawMarkdown,
is_public: isPublic,
return api.put(`/roms/${romId}/props`, {
note_raw_markdown: noteRawMarkdown,
note_is_public: noteIsPublic,
is_main_sibling: isMainSibling,
});
}
@@ -191,5 +194,5 @@ export default {
searchCover,
updateRom,
deleteRoms,
updateRomNote,
updateUserRomProps,
};

View File

@@ -60,10 +60,28 @@ export default defineStore("roms", {
game.igdb_id || game.moby_id || nanoid(),
),
)
.map((games) => ({
...(games.shift() as SimpleRom),
siblings: games,
}))
.map((games) => {
// Find the index of the game where the 'rom_user' property has 'is_main_sibling' set to true.
// If such a game is found, 'mainSiblingIndex' will be its index, otherwise it will be -1.
const mainSiblingIndex = games.findIndex(
(game) => game.rom_user?.is_main_sibling,
);
// Determine the primary game:
// - If 'mainSiblingIndex' is not -1 (i.e., a main sibling game was found),
// remove that game from the 'games' array and set it as 'primaryGame'.
// - If no main sibling game was found ('mainSiblingIndex' is -1),
// remove the first game from the 'games' array and set it as 'primaryGame'.
const primaryGame =
mainSiblingIndex !== -1
? games.splice(mainSiblingIndex, 1)[0]
: games.shift();
return {
...(primaryGame as SimpleRom),
siblings: games,
};
})
.sort((a, b) => {
return a.sort_comparator.localeCompare(b.sort_comparator);
});

View File

@@ -215,12 +215,14 @@ onMounted(async () => {
} else {
romsStore.setCurrentPlatform(routePlatform);
}
resetGallery();
await fetchRoms();
setFilters();
window.addEventListener("wheel", onScroll);
window.addEventListener("scroll", onScroll);
if (!noPlatformError.value) {
resetGallery();
await fetchRoms();
setFilters();
window.addEventListener("wheel", onScroll);
window.addEventListener("scroll", onScroll);
}
});
onBeforeRouteUpdate(async (to, from) => {

View File

@@ -172,8 +172,10 @@ watch(
<v-col cols="12">
<v-window disabled v-model="tab" class="py-2">
<v-window-item value="details">
<file-info :rom="rom" :platform="platform" />
<game-info :rom="rom" />
<v-row no-gutters :class="{ 'mx-2': mdAndUp }">
<file-info :rom="rom" :platform="platform" />
<game-info :rom="rom" />
</v-row>
</v-window-item>
<v-window-item value="saves">
<saves :rom="rom" />

View File

@@ -116,6 +116,7 @@ onMounted(async () => {
:disabled="gameRunning"
v-model="coreRef"
class="my-1"
rounded="0"
hide-details
variant="outlined"
clearable
@@ -132,6 +133,7 @@ onMounted(async () => {
:disabled="gameRunning"
class="my-1"
hide-details
rounded="0"
variant="outlined"
clearable
label="BIOS"
@@ -149,6 +151,7 @@ onMounted(async () => {
hide-details
variant="outlined"
clearable
rounded="0"
label="Save"
:items="
rom.user_saves?.map((s) => ({
@@ -163,6 +166,7 @@ onMounted(async () => {
:disabled="gameRunning"
class="my-1"
hide-details
rounded="0"
variant="outlined"
clearable
label="State"
@@ -181,6 +185,7 @@ onMounted(async () => {
hide-details
variant="outlined"
clearable
rounded="0"
disabled
label="Patch"
:items="[
@@ -192,30 +197,43 @@ onMounted(async () => {
</v-row>
<v-row class="px-3 py-3 text-center" no-gutters>
<v-col>
<v-chip
@click="onFullScreenChange"
:disabled="gameRunning"
:variant="fullScreenOnPlay ? 'flat' : 'outlined'"
:color="fullScreenOnPlay ? 'romm-accent-1' : ''"
><v-icon class="mr-1">{{
fullScreenOnPlay
? "mdi-checkbox-outline"
: "mdi-checkbox-blank-outline"
}}</v-icon
>Full screen</v-chip
>
<v-divider class="my-4" />
<v-btn
color="romm-accent-1"
block
:disabled="gameRunning"
rounded="0"
variant="outlined"
size="large"
prepend-icon="mdi-play"
@click="onPlay()"
>Play
</v-btn>
<v-row class="align-center" no-gutters>
<v-col>
<v-btn
block
size="large"
rounded="0"
@click="onFullScreenChange"
:disabled="gameRunning"
:variant="fullScreenOnPlay ? 'flat' : 'outlined'"
:color="fullScreenOnPlay ? 'romm-accent-1' : ''"
><v-icon class="mr-1">{{
fullScreenOnPlay
? "mdi-checkbox-outline"
: "mdi-checkbox-blank-outline"
}}</v-icon
>Full screen</v-btn
>
</v-col>
<v-col
cols="12"
:sm="gameRunning ? 12 : 7"
:xl="gameRunning ? 12 : 9"
>
<v-btn
color="romm-accent-1"
block
:disabled="gameRunning"
rounded="0"
variant="outlined"
size="large"
prepend-icon="mdi-play"
@click="onPlay()"
>Play
</v-btn>
</v-col>
</v-row>
<v-btn
class="mt-4"
block