Merge pull request #1324 from rommapp/feature/add-platform-info

feat: Add platform info drawer + per platform settings
This commit is contained in:
Zurdi
2024-12-03 19:01:16 +01:00
committed by GitHub
56 changed files with 1023 additions and 442 deletions

View File

@@ -2,12 +2,12 @@
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
version: 0.1
cli:
version: 1.22.4
version: 1.22.8
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
plugins:
sources:
- id: trunk
ref: v1.6.2
ref: v1.6.5
uri: https://github.com/trunk-io/plugins
# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes)
runtimes:
@@ -18,25 +18,25 @@ runtimes:
# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration)
lint:
enabled:
- markdownlint@0.41.0
- eslint@9.9.1
- actionlint@1.7.1
- bandit@1.7.9
- black@24.8.0
- checkov@3.2.239
- markdownlint@0.42.0
- eslint@9.14.0
- actionlint@1.7.4
- bandit@1.7.10
- black@24.10.0
- checkov@3.2.296
- git-diff-check
- isort@5.13.2
- mypy@1.13.0
- osv-scanner@1.8.4
- osv-scanner@1.9.1
- oxipng@9.1.2
- prettier@3.3.3
- ruff@0.6.3
- ruff@0.7.3
- shellcheck@0.10.0
- shfmt@3.6.0
- svgo@3.3.2
- taplo@0.9.3
- trivy@0.54.1
- trufflehog@3.81.10
- trivy@0.56.2
- trufflehog@3.83.6
- yamllint@1.35.1
ignore:
- linters: [ALL]

View File

@@ -130,7 +130,7 @@ docker exec -i romm-mariadb-dev mariadb -uroot -p<root password> < backend/romm_
### - Run tests
*\_\_*Migrations will be run automatically when running the tests.\_\_\*
*\_*Migrations will be run automatically when running the tests.\_\_\_
```sh
cd backend

View File

@@ -0,0 +1,56 @@
"""platforms_data
Revision ID: 0027_platforms_data
Revises: 0026_romuser_status_fields
Create Date: 2024-11-17 23:05:31.038917
"""
import sqlalchemy as sa
from alembic import op
from models.platform import DEFAULT_COVER_ASPECT_RATIO
# revision identifiers, used by Alembic.
revision = "0027_platforms_data"
down_revision = "0026_romuser_status_fields"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("platforms", schema=None) as batch_op:
batch_op.add_column(sa.Column("category", sa.String(length=50), nullable=True))
batch_op.add_column(sa.Column("generation", sa.Integer(), nullable=True))
batch_op.add_column(
sa.Column("family_name", sa.String(length=1000), nullable=True)
)
batch_op.add_column(
sa.Column("family_slug", sa.String(length=1000), nullable=True)
)
batch_op.add_column(sa.Column("url", sa.String(length=1000), nullable=True))
batch_op.add_column(
sa.Column("url_logo", sa.String(length=1000), nullable=True)
)
batch_op.add_column(
sa.Column(
"aspect_ratio",
sa.String(length=10),
nullable=False,
server_default=DEFAULT_COVER_ASPECT_RATIO,
)
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("platforms", schema=None) as batch_op:
batch_op.drop_column("url_logo")
batch_op.drop_column("url")
batch_op.drop_column("family_name")
batch_op.drop_column("family_slug")
batch_op.drop_column("generation")
batch_op.drop_column("category")
batch_op.drop_column("aspect_ratio")
# ### end Alembic commands ###

View File

@@ -33,9 +33,6 @@ def get_config() -> ConfigResponse:
EXCLUDED_MULTI_PARTS_FILES=cfg.EXCLUDED_MULTI_PARTS_FILES,
PLATFORMS_BINDING=cfg.PLATFORMS_BINDING,
PLATFORMS_VERSIONS=cfg.PLATFORMS_VERSIONS,
ROMS_FOLDER_NAME=cfg.ROMS_FOLDER_NAME,
FIRMWARE_FOLDER_NAME=cfg.FIRMWARE_FOLDER_NAME,
HIGH_PRIO_STRUCTURE_PATH=cfg.HIGH_PRIO_STRUCTURE_PATH,
)
except ConfigNotReadableException as exc:
log.critical(exc.message)

View File

@@ -112,17 +112,26 @@ def get_platform(request: Request, id: int) -> PlatformSchema:
@protected_route(router.put, "/platforms/{id}", [Scope.PLATFORMS_WRITE])
async def update_platform(request: Request) -> MessageResponse:
async def update_platform(request: Request, id: int) -> MessageResponse:
"""Update platform endpoint
Args:
request (Request): Fastapi Request object
id (int): Platform id
Returns:
MessageResponse: Standard message response
"""
data = await request.json()
platform_db = db_platform_handler.get_platform(id)
return {"msg": "Enpoint not available yet"}
if not platform_db:
raise PlatformNotFoundInDatabaseException(id)
platform_db.aspect_ratio = data.get("aspect_ratio", platform_db.aspect_ratio)
db_platform_handler.add_platform(platform_db)
return {"msg": "Platform updated successfully"}
@protected_route(router.delete, "/platforms/{id}", [Scope.PLATFORMS_WRITE])

View File

@@ -10,6 +10,3 @@ class ConfigResponse(TypedDict):
EXCLUDED_MULTI_PARTS_FILES: list[str]
PLATFORMS_BINDING: dict[str, str]
PLATFORMS_VERSIONS: dict[str, str]
ROMS_FOLDER_NAME: str
FIRMWARE_FOLDER_NAME: str
HIGH_PRIO_STRUCTURE_PATH: str

View File

@@ -1,5 +1,6 @@
from datetime import datetime
from models.platform import DEFAULT_COVER_ASPECT_RATIO
from pydantic import BaseModel, Field
from .firmware import FirmwareSchema
@@ -14,9 +15,15 @@ class PlatformSchema(BaseModel):
igdb_id: int | None = None
sgdb_id: int | None = None
moby_id: int | None = None
logo_path: str | None = ""
category: str | None = None
generation: int | None = None
family_name: str | None = None
family_slug: str | None = None
url: str | None = None
url_logo: str | None = None
logo_path: str | None = None
firmware: list[FirmwareSchema] = Field(default_factory=list)
aspect_ratio: str = DEFAULT_COVER_ASPECT_RATIO
created_at: datetime
updated_at: datetime

View File

@@ -9,6 +9,7 @@ class SearchRomSchema(BaseModel):
summary: str
igdb_url_cover: str = ""
moby_url_cover: str = ""
platform_id: int
class SearchCoverSchema(BaseModel):

View File

@@ -99,6 +99,7 @@ async def search_rom(
"summary": "",
"igdb_url_cover": "",
"moby_url_cover": "",
"platform_id": rom.platform_id,
},
**item,
}

View File

@@ -21,5 +21,3 @@ def test_config(client):
assert config.get("EXCLUDED_MULTI_PARTS_EXT") == []
assert config.get("EXCLUDED_MULTI_PARTS_FILES") == []
assert config.get("PLATFORMS_BINDING") == {}
assert config.get("ROMS_FOLDER_NAME") == "roms"
assert config.get("FIRMWARE_FOLDER_NAME") == "bios"

View File

@@ -35,6 +35,18 @@ class IGDBPlatform(TypedDict):
slug: str
igdb_id: int | None
name: NotRequired[str]
category: NotRequired[str]
generation: NotRequired[str]
family_name: NotRequired[str]
family_slug: NotRequired[str]
url: NotRequired[str]
url_logo: NotRequired[str]
logo_path: NotRequired[str]
class IGDBMetadataPlatform(TypedDict):
igdb_id: int
name: str
class IGDBAgeRating(TypedDict):
@@ -43,11 +55,6 @@ class IGDBAgeRating(TypedDict):
rating_cover_url: str
class IGDBMetadataPlatform(TypedDict):
igdb_id: int
name: str
class IGDBRelatedGame(TypedDict):
id: int
name: str
@@ -88,12 +95,10 @@ class IGDBRom(TypedDict):
igdb_metadata: NotRequired[IGDBMetadata]
def extract_metadata_from_igdb_rom(
rom: dict, video_id: str | None = None
) -> IGDBMetadata:
def extract_metadata_from_igdb_rom(rom: dict) -> IGDBMetadata:
return IGDBMetadata(
{
"youtube_video_id": video_id,
"youtube_video_id": str(pydash.get(rom, "videos[0].video_id", None)),
"total_rating": str(round(rom.get("total_rating", 0.0), 2)),
"aggregated_rating": str(round(rom.get("aggregated_rating", 0.0), 2)),
"first_release_date": rom.get("first_release_date", None),
@@ -120,7 +125,9 @@ def extract_metadata_from_igdb_rom(
id=e["id"],
slug=e["slug"],
name=e["name"],
cover_url=pydash.get(e, "cover.url", ""),
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(e, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="expansion",
)
for e in rom.get("expansions", [])
@@ -130,7 +137,9 @@ def extract_metadata_from_igdb_rom(
id=d["id"],
slug=d["slug"],
name=d["name"],
cover_url=pydash.get(d, "cover.url", ""),
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(d, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="dlc",
)
for d in rom.get("dlcs", [])
@@ -140,7 +149,9 @@ def extract_metadata_from_igdb_rom(
id=r["id"],
slug=r["slug"],
name=r["name"],
cover_url=pydash.get(r, "cover.url", ""),
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(r, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="remaster",
)
for r in rom.get("remasters", [])
@@ -150,7 +161,9 @@ def extract_metadata_from_igdb_rom(
id=r["id"],
slug=r["slug"],
name=r["name"],
cover_url=pydash.get(r, "cover.url", ""),
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(r, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="remake",
)
for r in rom.get("remakes", [])
@@ -160,7 +173,9 @@ def extract_metadata_from_igdb_rom(
id=g["id"],
slug=g["slug"],
name=g["name"],
cover_url=pydash.get(g, "cover.url", ""),
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(g, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="expanded",
)
for g in rom.get("expanded_games", [])
@@ -170,7 +185,9 @@ def extract_metadata_from_igdb_rom(
id=p["id"],
slug=p["slug"],
name=p["name"],
cover_url=pydash.get(p, "cover.url", ""),
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(p, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="port",
)
for p in rom.get("ports", [])
@@ -180,7 +197,9 @@ def extract_metadata_from_igdb_rom(
id=s["id"],
slug=s["slug"],
name=s["name"],
cover_url=pydash.get(s, "cover.url", ""),
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(s, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="similar",
)
for s in rom.get("similar_games", [])
@@ -199,7 +218,6 @@ class IGDBBaseHandler(MetadataHandler):
self.games_fields = GAMES_FIELDS
self.search_endpoint = f"{self.BASE_URL}/search"
self.search_fields = SEARCH_FIELDS
self.video_endpoint = f"{self.BASE_URL}/game_videos"
self.pagination_limit = 200
self.twitch_auth = TwitchAuth()
self.headers = {
@@ -318,6 +336,7 @@ class IGDBBaseHandler(MetadataHandler):
for rom_name in rom_names
)
log.debug("Searching in games endpoint with category %s", category_filter)
roms = await self._request(
self.games_endpoint,
data=f'search "{search_term}"; fields {",".join(self.games_fields)}; where platforms=[{platform_igdb_id}] {category_filter};',
@@ -327,11 +346,16 @@ class IGDBBaseHandler(MetadataHandler):
if is_exact_match(rom, search_term):
return rom
log.debug("Searching expanded in search endpoint")
roms_expanded = await self._request(
self.search_endpoint,
data=f'fields {",".join(self.search_fields)}; where game.platforms=[{platform_igdb_id}] & (name ~ *"{search_term}"* | alternative_name ~ *"{search_term}"*);',
)
if roms_expanded:
log.debug(
"Searching expanded in games endpoint for expanded game %s",
roms_expanded[0]["game"],
)
extra_roms = await self._request(
self.games_endpoint,
f'fields {",".join(self.games_fields)}; where id={roms_expanded[0]["game"]["id"]};',
@@ -354,13 +378,24 @@ class IGDBBaseHandler(MetadataHandler):
self.platform_endpoint,
data=f'fields {",".join(self.platforms_fields)}; where slug="{slug.lower()}";',
)
platform = pydash.get(platforms, "[0]", None)
if platform:
return IGDBPlatform(
igdb_id=platform["id"],
igdb_id=platform.get("id", None),
slug=slug,
name=platform["name"],
name=platform.get("name", slug),
category=IGDB_PLATFORM_CATEGORIES.get(
platform.get("category", 0), "Unknown"
),
generation=platform.get("generation", None),
family_name=pydash.get(platform, "platform_family.name", None),
family_slug=pydash.get(platform, "platform_family.slug", None),
url=platform.get("url", None),
url_logo=self._normalize_cover_url(
pydash.get(platform, "platform_logo.url", "").replace(
"t_thumb", "t_1080p"
)
),
)
# Check if platform is a version if not found
@@ -448,13 +483,19 @@ class IGDBBaseHandler(MetadataHandler):
search_term = self.normalize_search_term(search_term)
log.debug("Searching for %s on IGDB with category", search_term)
rom = await self._search_rom(search_term, platform_igdb_id, with_category=True)
if not rom:
log.debug("Searching for %s on IGDB without category", search_term)
rom = await self._search_rom(search_term, platform_igdb_id)
# Split the search term since igdb struggles with colons
if not rom and ":" in search_term:
for term in search_term.split(":")[::-1]:
log.debug(
"Searching for %s on IGDB without category after splitting semicolon",
term,
)
rom = await self._search_rom(term, platform_igdb_id)
if rom:
break
@@ -462,6 +503,10 @@ class IGDBBaseHandler(MetadataHandler):
# Some MAME games have two titles split by a slash
if not rom and "/" in search_term:
for term in search_term.split("/"):
log.debug(
"Searching for %s on IGDB without category after splitting slash",
term,
)
rom = await self._search_rom(term.strip(), platform_igdb_id)
if rom:
break
@@ -469,13 +514,6 @@ class IGDBBaseHandler(MetadataHandler):
if not rom:
return fallback_rom
# Get the video ID for the game
video_ids = await self._request(
self.video_endpoint,
f'fields video_id; where game={rom["id"]};',
)
video_id = pydash.get(video_ids, "[0].video_id", None)
return IGDBRom(
igdb_id=rom["id"],
slug=rom["slug"],
@@ -488,7 +526,7 @@ class IGDBBaseHandler(MetadataHandler):
self._normalize_cover_url(s.get("url", "")).replace("t_thumb", "t_720p")
for s in rom.get("screenshots", [])
],
igdb_metadata=extract_metadata_from_igdb_rom(rom, video_id),
igdb_metadata=extract_metadata_from_igdb_rom(rom),
)
@check_twitch_token
@@ -505,13 +543,6 @@ class IGDBBaseHandler(MetadataHandler):
if not rom:
return IGDBRom(igdb_id=None)
# Get the video ID for the game
video_ids = await self._request(
self.video_endpoint,
f'fields video_id; where game={rom["id"]};',
)
video_id = pydash.get(video_ids, "[0].video_id", None)
return IGDBRom(
igdb_id=rom["id"],
slug=rom["slug"],
@@ -524,7 +555,7 @@ class IGDBBaseHandler(MetadataHandler):
self._normalize_cover_url(s.get("url", "")).replace("t_thumb", "t_720p")
for s in rom.get("screenshots", [])
],
igdb_metadata=extract_metadata_from_igdb_rom(rom, video_id),
igdb_metadata=extract_metadata_from_igdb_rom(rom),
)
@check_twitch_token
@@ -694,7 +725,16 @@ class TwitchAuth(MetadataHandler):
return token
PLATFORMS_FIELDS = ["id", "name"]
PLATFORMS_FIELDS = [
"id",
"name",
"category",
"generation",
"url",
"platform_family.name",
"platform_family.slug",
"platform_logo.url",
]
GAMES_FIELDS = [
"id",
@@ -745,6 +785,7 @@ GAMES_FIELDS = [
"similar_games.name",
"similar_games.cover.url",
"age_ratings.rating",
"videos.video_id",
]
SEARCH_FIELDS = ["game.id", "name"]
@@ -974,6 +1015,16 @@ IGDB_PLATFORM_LIST = [
{"slug": "airconsole", "name": "AirConsole"},
]
IGDB_PLATFORM_CATEGORIES: dict[int, str] = {
0: "Unknown",
1: "Console",
2: "Arcade",
3: "Platform",
4: "Operative System",
5: "Portable Console",
6: "Computer",
}
IGDB_AGE_RATINGS: dict[int, IGDBAgeRating] = {
1: {
"rating": "Three",

View File

@@ -11,6 +11,9 @@ if TYPE_CHECKING:
from models.firmware import Firmware
DEFAULT_COVER_ASPECT_RATIO = "2 / 3"
class Platform(BaseModel):
__tablename__ = "platforms"
@@ -21,6 +24,12 @@ class Platform(BaseModel):
slug: Mapped[str] = mapped_column(String(length=50))
fs_slug: Mapped[str] = mapped_column(String(length=50))
name: Mapped[str] = mapped_column(String(length=400))
category: Mapped[str | None] = mapped_column(String(length=50), default="")
generation: Mapped[int | None]
family_name: Mapped[str | None] = mapped_column(String(length=1000), default="")
family_slug: Mapped[str | None] = mapped_column(String(length=1000), default="")
url: Mapped[str | None] = mapped_column(String(length=1000), default="")
url_logo: Mapped[str | None] = mapped_column(String(length=1000), default="")
logo_path: Mapped[str | None] = mapped_column(String(length=1000), default="")
roms: Mapped[list[Rom]] = relationship(back_populates="platform")
@@ -28,6 +37,10 @@ class Platform(BaseModel):
lazy="selectin", back_populates="platform"
)
aspect_ratio: Mapped[str] = mapped_column(
String, server_default=DEFAULT_COVER_ASPECT_RATIO
)
# This runs a subquery to get the count of roms for the platform
rom_count = column_property(
select(func.count(Rom.id)).where(Rom.platform_id == id).scalar_subquery()

View File

@@ -29,7 +29,7 @@
"socket.io-client": "^4.7.5",
"vue": "^3.4.27",
"vue-router": "^4.3.2",
"vuetify": "^3.6.5",
"vuetify": "^3.7.4",
"webfontloader": "^1.6.28"
},
"devDependencies": {
@@ -56,7 +56,7 @@
"vite-plugin-pwa": "^0.14.7",
"vite-plugin-static-copy": "0.17.1",
"vite-plugin-vuetify": "^1.0.2",
"vue-tsc": "^1.8.27"
"vue-tsc": "^2.1.10"
},
"engines": {
"node": "18"
@@ -434,17 +434,17 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.24.1",
"dev": true,
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.24.5",
"dev": true,
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"engines": {
"node": ">=6.9.0"
}
@@ -562,8 +562,12 @@
}
},
"node_modules/@babel/parser": {
"version": "7.24.5",
"license": "MIT",
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz",
"integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==",
"dependencies": {
"@babel/types": "^7.26.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -1763,13 +1767,12 @@
}
},
"node_modules/@babel/types": {
"version": "7.24.5",
"dev": true,
"license": "MIT",
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz",
"integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==",
"dependencies": {
"@babel/helper-string-parser": "^7.24.1",
"@babel/helper-validator-identifier": "^7.24.5",
"to-fast-properties": "^2.0.0"
"@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2934,28 +2937,29 @@
}
},
"node_modules/@volar/language-core": {
"version": "1.11.1",
"version": "2.4.10",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.10.tgz",
"integrity": "sha512-hG3Z13+nJmGaT+fnQzAkS0hjJRa2FCeqZt6Bd+oGNhUkQ+mTFsDETg5rqUTxyzIh5pSOGY7FHCWUS8G82AzLCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/source-map": "1.11.1"
"@volar/source-map": "2.4.10"
}
},
"node_modules/@volar/source-map": {
"version": "1.11.1",
"dev": true,
"license": "MIT",
"dependencies": {
"muggle-string": "^0.3.1"
}
"version": "2.4.10",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.10.tgz",
"integrity": "sha512-OCV+b5ihV0RF3A7vEvNyHPi4G4kFa6ukPmyVocmqm5QzOd8r5yAtiNvaPEjl8dNvgC/lj4JPryeeHLdXd62rWA==",
"dev": true
},
"node_modules/@volar/typescript": {
"version": "1.11.1",
"version": "2.4.10",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.10.tgz",
"integrity": "sha512-F8ZtBMhSXyYKuBfGpYwqA5rsONnOwAVvjyE7KPYJ7wgZqo2roASqNWUnianOomJX5u1cxeRooHV59N0PhvEOgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "1.11.1",
"path-browserify": "^1.0.1"
"@volar/language-core": "2.4.10",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vue/compiler-core": {
@@ -3000,24 +3004,34 @@
"@vue/shared": "3.4.27"
}
},
"node_modules/@vue/compiler-vue2": {
"version": "2.7.16",
"resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
"integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
"dev": true,
"dependencies": {
"de-indent": "^1.0.2",
"he": "^1.2.0"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.1",
"license": "MIT"
},
"node_modules/@vue/language-core": {
"version": "1.8.27",
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.1.10.tgz",
"integrity": "sha512-DAI289d0K3AB5TUG3xDp9OuQ71CnrujQwJrQnfuZDwo6eGNf0UoRlPuaVNO+Zrn65PC3j0oB2i7mNmVPggeGeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "~1.11.1",
"@volar/source-map": "~1.11.1",
"@vue/compiler-dom": "^3.3.0",
"@vue/shared": "^3.3.0",
"computeds": "^0.0.1",
"@volar/language-core": "~2.4.8",
"@vue/compiler-dom": "^3.5.0",
"@vue/compiler-vue2": "^2.7.16",
"@vue/shared": "^3.5.0",
"alien-signals": "^0.2.0",
"minimatch": "^9.0.3",
"muggle-string": "^0.3.1",
"path-browserify": "^1.0.1",
"vue-template-compiler": "^2.7.14"
"muggle-string": "^0.4.1",
"path-browserify": "^1.0.1"
},
"peerDependencies": {
"typescript": "*"
@@ -3028,18 +3042,49 @@
}
}
},
"node_modules/@vue/language-core/node_modules/@vue/compiler-core": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz",
"integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==",
"dev": true,
"dependencies": {
"@babel/parser": "^7.25.3",
"@vue/shared": "3.5.13",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/language-core/node_modules/@vue/compiler-dom": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz",
"integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==",
"dev": true,
"dependencies": {
"@vue/compiler-core": "3.5.13",
"@vue/shared": "3.5.13"
}
},
"node_modules/@vue/language-core/node_modules/@vue/shared": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz",
"integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==",
"dev": true
},
"node_modules/@vue/language-core/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@vue/language-core/node_modules/minimatch": {
"version": "9.0.4",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -3141,6 +3186,12 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/alien-signals": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-0.2.2.tgz",
"integrity": "sha512-cZIRkbERILsBOXTQmMrxc9hgpxglstn69zm+F1ARf4aPAzdAFYd6sBq87ErO0Fj3DV94tglcyHG5kQz9nDC/8A==",
"dev": true
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"dev": true,
@@ -3577,11 +3628,6 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/computeds": {
"version": "0.0.1",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"dev": true,
@@ -3644,9 +3690,10 @@
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -3733,8 +3780,9 @@
},
"node_modules/de-indent": {
"version": "1.0.2",
"dev": true,
"license": "MIT"
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
"dev": true
},
"node_modules/debug": {
"version": "4.3.4",
@@ -5502,11 +5550,12 @@
}
},
"node_modules/micromatch": {
"version": "4.0.5",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.2",
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
@@ -5579,9 +5628,10 @@
"license": "MIT"
},
"node_modules/muggle-string": {
"version": "0.3.1",
"dev": true,
"license": "MIT"
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"dev": true
},
"node_modules/nanoid": {
"version": "5.0.7",
@@ -5765,8 +5815,9 @@
},
"node_modules/path-browserify": {
"version": "1.0.1",
"dev": true,
"license": "MIT"
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true
},
"node_modules/path-exists": {
"version": "4.0.0",
@@ -6265,9 +6316,10 @@
"license": "Apache-2.0"
},
"node_modules/rollup": {
"version": "2.79.1",
"version": "2.79.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"devOptional": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -6740,14 +6792,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/to-fast-properties": {
"version": "2.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -7079,7 +7123,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz",
"integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.15.9",
"postcss": "^8.4.18",
@@ -7147,9 +7190,10 @@
}
},
"node_modules/vite-plugin-pwa/node_modules/rollup": {
"version": "3.29.4",
"version": "3.29.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz",
"integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==",
"dev": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -7231,6 +7275,12 @@
"esbuild-windows-arm64": "0.15.18"
}
},
"node_modules/vscode-uri": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz",
"integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
"dev": true
},
"node_modules/vue": {
"version": "3.4.27",
"license": "MIT",
@@ -7286,34 +7336,27 @@
"vue": "^3.2.0"
}
},
"node_modules/vue-template-compiler": {
"version": "2.7.16",
"dev": true,
"license": "MIT",
"dependencies": {
"de-indent": "^1.0.2",
"he": "^1.2.0"
}
},
"node_modules/vue-tsc": {
"version": "1.8.27",
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.1.10.tgz",
"integrity": "sha512-RBNSfaaRHcN5uqVqJSZh++Gy/YUzryuv9u1aFWhsammDJXNtUiJMNoJ747lZcQ68wUQFx6E73y4FY3D8E7FGMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/typescript": "~1.11.1",
"@vue/language-core": "1.8.27",
"@volar/typescript": "~2.4.8",
"@vue/language-core": "2.1.10",
"semver": "^7.5.4"
},
"bin": {
"vue-tsc": "bin/vue-tsc.js"
},
"peerDependencies": {
"typescript": "*"
"typescript": ">=5.0.0"
}
},
"node_modules/vuetify": {
"version": "3.6.5",
"license": "MIT",
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.4.tgz",
"integrity": "sha512-Y8UU5wUDQXC3oz2uumPb8IOdvB4XMCxtxnmqdOc+LihNuPlkSgxIwf92ndRzbOtJFKHsggFUxpyLqpQp+A+5kg==",
"engines": {
"node": "^12.20 || >=14.13"
},
@@ -7325,7 +7368,6 @@
"typescript": ">=4.7",
"vite-plugin-vuetify": ">=1.0.0",
"vue": "^3.3.0",
"vue-i18n": "^9.0.0",
"webpack-plugin-vuetify": ">=2.0.0"
},
"peerDependenciesMeta": {
@@ -7335,9 +7377,6 @@
"vite-plugin-vuetify": {
"optional": true
},
"vue-i18n": {
"optional": true
},
"webpack-plugin-vuetify": {
"optional": true
}

View File

@@ -47,7 +47,7 @@
"socket.io-client": "^4.7.5",
"vue": "^3.4.27",
"vue-router": "^4.3.2",
"vuetify": "^3.6.5",
"vuetify": "^3.7.4",
"webfontloader": "^1.6.28"
},
"devDependencies": {
@@ -74,7 +74,7 @@
"vite-plugin-pwa": "^0.14.7",
"vite-plugin-static-copy": "0.17.1",
"vite-plugin-vuetify": "^1.0.2",
"vue-tsc": "^1.8.27"
"vue-tsc": "^2.1.10"
},
"engines": {
"node": "18"

View File

@@ -12,8 +12,5 @@ export type ConfigResponse = {
EXCLUDED_MULTI_PARTS_FILES: Array<string>;
PLATFORMS_BINDING: Record<string, string>;
PLATFORMS_VERSIONS: Record<string, string>;
ROMS_FOLDER_NAME: string;
FIRMWARE_FOLDER_NAME: string;
HIGH_PRIO_STRUCTURE_PATH: string;
};

View File

@@ -14,8 +14,15 @@ export type PlatformSchema = {
igdb_id?: (number | null);
sgdb_id?: (number | null);
moby_id?: (number | null);
category?: (string | null);
generation?: (number | null);
family_name?: (string | null);
family_slug?: (string | null);
url?: (string | null);
url_logo?: (string | null);
logo_path?: (string | null);
firmware?: Array<FirmwareSchema>;
aspect_ratio?: string;
created_at: string;
updated_at: string;
};

View File

@@ -11,5 +11,6 @@ export type SearchRomSchema = {
summary: string;
igdb_url_cover?: string;
moby_url_cover?: string;
platform_id: number;
};

View File

@@ -5,13 +5,12 @@ import romApi from "@/services/api/rom";
import storeAuth from "@/stores/auth";
import type { Collection } from "@/stores/collections";
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";
// Props
const props = defineProps<{ rom: DetailedRom; platform: Platform }>();
const props = defineProps<{ rom: DetailedRom }>();
const downloadStore = storeDownload();
const auth = storeAuth();
const romUser = ref(props.rom.rom_user);
@@ -47,7 +46,7 @@ watch(
</v-col>
<v-col>
<v-row class="align-center" no-gutters>
<version-switcher :rom="rom" :platform="platform" />
<version-switcher :rom="rom" />
<v-tooltip
location="top"
class="tooltip"
@@ -111,15 +110,15 @@ watch(
<v-col cols="3" xl="2">
<span>Info</span>
</v-col>
<v-col>
<v-chip size="small" label class="mx-1 my-1">
<v-col class="my-1">
<v-chip size="small" label>
Size: {{ formatBytes(rom.file_size_bytes) }}
</v-chip>
<v-chip
v-if="!rom.multi && rom.sha1_hash"
size="small"
label
class="mx-1 my-1"
class="ml-1"
>
SHA-1: {{ rom.sha1_hash }}
</v-chip>
@@ -127,7 +126,7 @@ watch(
v-if="!rom.multi && rom.md5_hash"
size="small"
label
class="mx-1 my-1"
class="ml-1"
>
MD5: {{ rom.md5_hash }}
</v-chip>
@@ -135,7 +134,7 @@ watch(
v-if="!rom.multi && rom.crc_hash"
size="small"
label
class="mx-1 my-1"
class="ml-1"
>
CRC: {{ rom.crc_hash }}
</v-chip>

View File

@@ -1,17 +1,23 @@
<script setup lang="ts">
import storeGalleryFilter, { type FilterType } from "@/stores/galleryFilter";
import { type FilterType } from "@/stores/galleryFilter";
import storeGalleryView from "@/stores/galleryView";
import type { DetailedRom } from "@/stores/roms";
import { storeToRefs } from "pinia";
import { ref } from "vue";
import { useDisplay } from "vuetify";
import { useRouter } from "vue-router";
import { useDisplay } from "vuetify";
// Props
const props = defineProps<{ rom: DetailedRom }>();
const { xs } = useDisplay();
const show = ref(false);
const carousel = ref(0);
const router = useRouter();
const filters = ["genres", "franchises", "collections", "companies"] as const;
const galleryViewStore = storeGalleryView();
const { defaultAspectRatioScreenshot } = storeToRefs(galleryViewStore);
// Functions
function onFilterClick(filter: FilterType, value: string) {
router.push({
name: "platform",
@@ -115,7 +121,7 @@ function onFilterClick(filter: FilterType, value: string) {
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
style="aspect-ratio: 16 / 9"
:style="`aspect-ratio: ${defaultAspectRatioScreenshot}`"
allowfullscreen
></iframe>
</v-carousel-item>
@@ -172,7 +178,7 @@ function onFilterClick(filter: FilterType, value: string) {
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
style="aspect-ratio: 16 / 9"
:style="`aspect-ratio: ${defaultAspectRatioScreenshot}`"
allowfullscreen
></iframe>
</v-carousel-item>

View File

@@ -8,7 +8,7 @@ import { identity } from "lodash";
import { useDisplay } from "vuetify";
// Props
const props = defineProps<{ rom: DetailedRom; platform: Platform }>();
const props = defineProps<{ rom: DetailedRom }>();
const { smAndDown } = useDisplay();
const releaseDate = new Date(
Number(props.rom.first_release_date) * 1000,
@@ -39,12 +39,12 @@ const hasReleaseDate = Number(props.rom.first_release_date) > 0;
no-gutters
>
<v-col>
<v-chip :to="{ name: 'platform', params: { platform: platform.id } }">
{{ platform.name }}
<v-chip :to="{ name: 'platform', params: { platform: rom.platform_id } }">
{{ rom.platform_name }}
<platform-icon
:key="platform.slug"
:slug="platform.slug"
:name="platform.name"
:key="rom.platform_slug"
:slug="rom.platform_slug"
:name="rom.platform_name"
:size="30"
class="ml-2"
/>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import type { Platform } from "@/stores/platforms";
import type { DetailedRom } from "@/stores/roms";
import { languageToEmoji, regionToEmoji } from "@/utils";
import type { RomSchema } from "@/__generated__";
@@ -7,7 +6,7 @@ import { ref } from "vue";
import { useRouter } from "vue-router";
// Props
const props = defineProps<{ rom: DetailedRom; platform: Platform }>();
const props = defineProps<{ rom: DetailedRom }>();
const router = useRouter();
const version = ref(props.rom.id);

View File

@@ -14,7 +14,15 @@ const auth = storeAuth();
</script>
<template>
<v-app-bar id="gallery-app-bar" elevation="0" density="compact">
<v-app-bar
id="gallery-app-bar"
elevation="0"
density="compact"
mode="shift"
app
fixed
top
>
<filter-btn />
<filter-text-field v-if="!xs" />
<div v-if="xs" class="flex-grow-1" />

View File

@@ -1,57 +1,55 @@
<script setup lang="ts">
import AdminMenu from "@/components/Gallery/AppBar/Platform/AdminMenu.vue";
import FirmwareBtn from "@/components/Gallery/AppBar/Platform/FirmwareBtn.vue";
import FirmwareDrawer from "@/components/Gallery/AppBar/Platform/FirmwareDrawer.vue";
import PlatformInfoDrawer from "@/components/Gallery/AppBar/Platform/PlatformInfoDrawer.vue";
import FilterBtn from "@/components/Gallery/AppBar/common/FilterBtn.vue";
import FilterDrawer from "@/components/Gallery/AppBar/common/FilterDrawer/Base.vue";
import FilterTextField from "@/components/Gallery/AppBar/common/FilterTextField.vue";
import GalleryViewBtn from "@/components/Gallery/AppBar/common/GalleryViewBtn.vue";
import SelectingBtn from "@/components/Gallery/AppBar/common/SelectingBtn.vue";
import PlatformIcon from "@/components/common/Platform/Icon.vue";
import storeAuth from "@/stores/auth";
import storeNavigation from "@/stores/navigation";
import storeRoms from "@/stores/roms";
import { storeToRefs } from "pinia";
import { useDisplay } from "vuetify";
// Props
const { xs } = useDisplay();
const romsStore = storeRoms();
const { currentPlatform, currentCollection } = storeToRefs(romsStore);
// Props
const auth = storeAuth();
const { currentPlatform } = storeToRefs(romsStore);
const navigationStore = storeNavigation();
const { activePlatformInfoDrawer } = storeToRefs(navigationStore);
</script>
<template>
<v-app-bar id="gallery-app-bar" elevation="0" density="compact">
<v-app-bar
id="gallery-app-bar"
elevation="0"
density="compact"
mode="shift"
app
fixed
top
>
<platform-icon
v-if="currentPlatform"
:slug="currentPlatform.slug"
:name="currentPlatform.name"
:size="36"
class="ml-3 mr-2 platform-icon"
class="mx-3 platform-icon"
:class="{ active: activePlatformInfoDrawer }"
@click="navigationStore.switchActivePlatformInfoDrawer"
/>
<firmware-btn />
<filter-btn />
<filter-text-field v-if="!xs" />
<div v-if="xs" class="flex-grow-1" />
<selecting-btn />
<gallery-view-btn />
<v-menu location="bottom">
<template #activator="{ props }">
<v-btn
v-if="auth.scopes.includes('roms.write')"
v-bind="props"
rounded="0"
variant="text"
class="mr-0"
icon="mdi-dots-vertical"
@click.stop
/>
</template>
<admin-menu />
</v-menu>
<template #append>
<selecting-btn />
<gallery-view-btn />
</template>
</v-app-bar>
<platform-info-drawer />
<filter-drawer />
<firmware-drawer />
</template>
@@ -61,6 +59,16 @@ const auth = storeAuth();
z-index: 999 !important;
}
.platform-icon {
filter: drop-shadow(0px 0px 2px #a452fe);
cursor: pointer;
transition: filter 0.15s ease-in-out;
transition: transform 0.15s ease-in-out;
}
.platform-icon {
filter: drop-shadow(0px 0px 1px rgba(var(--v-theme-romm-accent-1)));
}
.platform-icon:hover,
.platform-icon.active {
filter: drop-shadow(0px 0px 3px rgba(var(--v-theme-romm-accent-1)));
transform: scale(1.1);
}
</style>

View File

@@ -52,9 +52,11 @@ function deleteSelectedFirmware() {
}
function updateDataTablePages() {
pageCount.value = Math.ceil(
Number(currentPlatform.value?.firmware?.length) / itemsPerPage.value,
);
if (currentPlatform.value?.firmware) {
pageCount.value = Math.ceil(
Number(currentPlatform.value.firmware.length) / itemsPerPage.value,
);
}
}
watch(itemsPerPage, async () => {
@@ -67,7 +69,12 @@ onMounted(() => {
</script>
<template>
<v-navigation-drawer v-model="activeFirmwareDrawer" mobile location="bottom">
<v-navigation-drawer
v-model="activeFirmwareDrawer"
mobile
floating
location="bottom"
>
<v-data-table
:items="currentPlatform?.firmware ?? []"
:width="mdAndUp ? '60vw' : '95vw'"

View File

@@ -0,0 +1,300 @@
<script setup lang="ts">
import PlatformIcon from "@/components/common/Platform/Icon.vue";
import RSection from "@/components/common/RSection.vue";
import platformApi from "@/services/api/platform";
import socket from "@/services/socket";
import storeAuth from "@/stores/auth";
import storeHeartbeat from "@/stores/heartbeat";
import storeNavigation from "@/stores/navigation";
import storeRoms from "@/stores/roms";
import storeScanning from "@/stores/scanning";
import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { storeToRefs } from "pinia";
import { computed, inject, ref, watch } from "vue";
import { useDisplay } from "vuetify";
// Props
const emitter = inject<Emitter<Events>>("emitter");
const { xs } = useDisplay();
const viewportWidth = ref(window.innerWidth);
const heartbeat = storeHeartbeat();
const romsStore = storeRoms();
const scanningStore = storeScanning();
const { scanning } = storeToRefs(scanningStore);
const { currentPlatform } = storeToRefs(romsStore);
const auth = storeAuth();
const navigationStore = storeNavigation();
const { activePlatformInfoDrawer } = storeToRefs(navigationStore);
const selectedAspectRatio = ref(0);
const aspectRatioOptions = computed(() => [
{
name: "2 / 3",
size: 2 / 3,
source: "SteamGridDB",
},
{
name: "3 / 4",
size: 3 / 4,
source: "IGDB / MobyGames",
},
{
name: "1 / 1",
size: 1 / 1,
source: "Old squared cases",
},
]);
const platformInfoFields = [
{ key: "name", label: "Name" },
{ key: "slug", label: "Slug" },
{ key: "fs_slug", label: "Filesystem folder name" },
{ key: "category", label: "Category" },
{ key: "generation", label: "Generation" },
{ key: "family_name", label: "Family" },
];
watch(
() => currentPlatform.value?.aspect_ratio,
(aspectRatio) => {
if (aspectRatio) {
// Find the index of the aspect ratio option that matches the current aspect ratio
const defaultAspectRatio = aspectRatioOptions.value.findIndex(
(option) => option.name == aspectRatio,
);
// If a matching aspect ratio option is found, update the selectedAspectRatio
if (defaultAspectRatio !== -1) {
selectedAspectRatio.value = defaultAspectRatio;
}
}
},
{ immediate: true }, // Execute the callback immediately with the current value
);
// Functions
async function scan() {
scanningStore.set(true);
if (!socket.connected) socket.connect();
socket.emit("scan", {
platforms: [romsStore.currentPlatform?.id],
type: "quick",
apis: heartbeat.getMetadataOptions().map((s) => s.value),
});
}
async function setAspectRatio() {
if (currentPlatform.value) {
const selectedOption = aspectRatioOptions.value[selectedAspectRatio.value];
platformApi
.updatePlatform({
platform: {
...currentPlatform.value,
aspect_ratio: selectedOption.name,
},
})
.then(({ data }) => {
emitter?.emit("snackbarShow", {
msg: data.msg,
icon: "mdi-check-bold",
color: "green",
});
if (currentPlatform.value) {
currentPlatform.value.aspect_ratio = selectedOption.name;
}
})
.catch((error) => {
emitter?.emit("snackbarShow", {
msg: `Failed to update aspect ratio: ${
error.response?.data?.msg || error.message
}`,
icon: "mdi-close-circle",
color: "red",
});
});
}
}
</script>
<template>
<v-navigation-drawer
v-model="activePlatformInfoDrawer"
floating
mobile
:width="xs ? viewportWidth : '500'"
v-if="currentPlatform"
>
<v-row no-gutters class="justify-center align-center">
<v-col cols="12">
<div class="text-center">
<platform-icon
:slug="currentPlatform.slug"
:name="currentPlatform.name"
class="platform-icon"
:size="160"
/>
</div>
<div class="text-center">
<v-btn
class="bg-terciary"
@click="emitter?.emit('showUploadRomDialog', currentPlatform)"
>
<v-icon class="text-romm-green mr-2">mdi-upload</v-icon>
Upload roms
</v-btn>
<v-btn
:disabled="scanning"
rounded="4"
:loading="scanning"
@click="scan"
class="ml-2 bg-terciary"
>
<template #prepend>
<v-icon :color="scanning ? '' : 'romm-accent-1'"
>mdi-magnify-scan</v-icon
>
</template>
Scan platform
<template #loader>
<v-progress-circular
color="romm-accent-1"
:width="2"
:size="20"
indeterminate
/>
</template>
</v-btn>
</div>
<div class="mt-4 text-center">
<a
v-if="currentPlatform.igdb_id"
style="text-decoration: none; color: inherit"
:href="currentPlatform.url ? currentPlatform.url : ''"
target="_blank"
>
<v-chip size="x-small" @click.stop>
<span>IGDB</span>
<v-divider class="mx-2 border-opacity-25" vertical />
<span>ID: {{ currentPlatform.igdb_id }}</span>
</v-chip>
</a>
<v-chip
size="x-small"
class="ml-1"
@click.stop
:class="{ 'ml-1': currentPlatform.igdb_id }"
v-if="currentPlatform.moby_id"
>
<span>Mobygames</span>
<v-divider class="mx-2 border-opacity-25" vertical />
<span>ID: {{ currentPlatform.moby_id }}</span>
</v-chip>
</div>
<v-card class="mt-4 mx-4 bg-terciary fill-width" elevation="0">
<v-card-text class="pa-4">
<template
v-for="(field, index) in platformInfoFields"
:key="field.key"
>
<div
v-if="
currentPlatform[field.key as keyof typeof currentPlatform]
"
:class="{ 'mt-4': index !== 0 }"
>
<p class="text-subtitle-1 text-decoration-underline">
{{ field.label }}
</p>
<p class="text-subtitle-2">
{{
currentPlatform[field.key as keyof typeof currentPlatform]
}}
</p>
</div>
</template>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row class="mt-4" no-gutters>
<v-col cols="12">
<r-section
v-if="auth.scopes.includes('platforms.write')"
icon="mdi-aspect-ratio"
title="UI Settings"
elevation="0"
>
<template #content>
<v-item-group
v-model="selectedAspectRatio"
mandatory
@update:model-value="setAspectRatio"
>
<v-row no-gutters class="text-center justify-center align-center">
<v-col class="ma-2" v-for="aspectRatio in aspectRatioOptions">
<v-item v-slot="{ isSelected, toggle }">
<v-card
:color="isSelected ? 'romm-accent-1' : 'romm-gray'"
variant="outlined"
@click="toggle"
>
<v-card-text
class="pa-0 text-center align-center justify-center"
>
<v-img
:aspect-ratio="aspectRatio.size"
cover
src="/assets/login_bg.png"
:class="{ greyscale: !isSelected }"
class="d-flex align-center justify-center"
>
<p class="text-h5 text-romm-white">
{{ aspectRatio.name }}
</p>
</v-img>
<p class="text-center mx-2 text-caption">
{{ aspectRatio.source }}
</p>
</v-card-text>
</v-card>
</v-item>
</v-col>
</v-row>
</v-item-group>
</template>
</r-section>
<r-section
v-if="auth.scopes.includes('platforms.write')"
icon="mdi-alert"
icon-color="red"
title="Danger zone"
elevation="0"
>
<template #content>
<div class="text-center my-2">
<v-btn
class="text-romm-red bg-terciary"
variant="flat"
@click="
emitter?.emit('showDeletePlatformDialog', currentPlatform)
"
>
<v-icon class="text-romm-red mr-2">mdi-delete</v-icon>
Delete platform
</v-btn>
</div>
</template>
</r-section>
</v-col>
</v-row>
</v-navigation-drawer>
</template>
<style scoped>
.platform-icon {
filter: drop-shadow(0px 0px 1px rgba(var(--v-theme-romm-accent-1)));
}
.greyscale {
filter: grayscale(100%);
}
</style>

View File

@@ -78,11 +78,11 @@ function resetFilters() {
<template>
<v-navigation-drawer
@update:model-value="galleryFilterStore.switchActiveFilterDrawer()"
floating
width="300"
v-model="activeFilterDrawer"
mobile
@update:model-value="galleryFilterStore.switchActiveFilterDrawer()"
v-model="activeFilterDrawer"
>
<v-list>
<v-list-item v-if="xs">

View File

@@ -38,3 +38,14 @@ const emit = defineEmits(["update:modelValue"]);
></v-list-item>
</v-card>
</template>
<style scoped>
.text-romm-accent-1 {
transition: color 0.3s ease-in-out;
}
.v-list-item-title,
.v-icon {
transition: color 0.3s ease-in-out;
}
</style>

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import type { Collection } from "@/stores/collections";
import storeGalleryView from "@/stores/galleryView";
import { useTheme } from "vuetify";
// Props
withDefaults(
defineProps<{
collection: Collection;
@@ -20,6 +22,7 @@ withDefaults(
},
);
const theme = useTheme();
const galleryViewStore = storeGalleryView();
</script>
<template>
@@ -65,7 +68,7 @@ const theme = useTheme();
? `/assets/default/cover/small_${theme.global.name.value}_fav.png`
: `/assets/default/cover/small_${theme.global.name.value}_collection.png`
"
:aspect-ratio="2 / 3"
:aspect-ratio="galleryViewStore.defaultAspectRatioCollection"
>
<div class="position-absolute append-inner">
<slot name="append-inner"></slot>
@@ -75,7 +78,7 @@ const theme = useTheme();
<v-img
:src="`/assets/default/cover/big_${theme.global.name.value}_missing_cover.png`"
cover
:aspect-ratio="2 / 3"
:aspect-ratio="galleryViewStore.defaultAspectRatioCollection"
></v-img>
</template>
<template #placeholder>

View File

@@ -3,19 +3,21 @@ import type { SearchRomSchema } from "@/__generated__";
import ActionBar from "@/components/common/Game/Card/ActionBar.vue";
import GameCardFlags from "@/components/common/Game/Card/Flags.vue";
import Sources from "@/components/common/Game/Card/Sources.vue";
import storePlatforms from "@/stores/platforms";
import PlatformIcon from "@/components/common/Platform/Icon.vue";
import storeCollections from "@/stores/collections";
import storeDownload from "@/stores/download";
import storeGalleryView from "@/stores/galleryView";
import storeRoms from "@/stores/roms";
import { type SimpleRom } from "@/stores/roms.js";
import { onMounted, ref } from "vue";
import { onMounted, ref, computed } from "vue";
import { useTheme } from "vuetify";
// Props
const props = withDefaults(
defineProps<{
rom: SimpleRom | SearchRomSchema;
aspectRatio?: string | number;
transformScale?: boolean;
titleOnHover?: boolean;
showFlags?: boolean;
@@ -29,6 +31,7 @@ const props = withDefaults(
src?: string;
}>(),
{
aspectRatio: undefined,
transformScale: false,
titleOnHover: false,
showFlags: false,
@@ -42,6 +45,7 @@ const props = withDefaults(
src: "",
},
);
const platfotmsStore = storePlatforms();
const romsStore = storeRoms();
const emit = defineEmits(["click", "touchstart", "touchend"]);
const handleClick = (event: MouseEvent) => {
@@ -58,7 +62,15 @@ const card = ref();
const theme = useTheme();
const galleryViewStore = storeGalleryView();
const collectionsStore = storeCollections();
const computedAspectRatio = computed(() => {
const ratio =
props.aspectRatio ||
platfotmsStore.getAspectRatio(props.rom.platform_id) ||
galleryViewStore.defaultAspectRatioCover;
return parseFloat(ratio.toString());
});
// Functions
onMounted(() => {
card.value.$el.addEventListener("contextmenu", (event: Event) => {
event.preventDefault();
@@ -123,7 +135,7 @@ onMounted(() => {
? rom.igdb_url_cover
: rom.moby_url_cover
"
:aspect-ratio="2 / 3"
:aspect-ratio="computedAspectRatio"
>
<div v-bind="props" style="position: absolute; top: 0; width: 100%">
<template v-if="titleOnHover">
@@ -198,7 +210,7 @@ onMounted(() => {
<v-img
:src="`/assets/default/cover/big_${theme.global.name.value}_missing_cover.png`"
cover
:aspect-ratio="2 / 3"
:aspect-ratio="computedAspectRatio"
></v-img>
</template>
<template #placeholder>

View File

@@ -19,6 +19,9 @@ const showLanguages = isNull(localStorage.getItem("settings.showLanguages"))
const showSiblings = isNull(localStorage.getItem("settings.showSiblings"))
? true
: localStorage.getItem("settings.showSiblings") === "true";
const showStatus = isNull(localStorage.getItem("settings.showStatus"))
? true
: localStorage.getItem("settings.showStatus") === "true";
const playingStatus = computed(() => {
if (props.rom.rom_user.now_playing) return "now_playing";
@@ -51,7 +54,7 @@ const playingStatus = computed(() => {
</span>
</v-chip>
<v-chip
v-if="playingStatus"
v-if="playingStatus && showStatus"
class="translucent-dark mr-1 mt-1"
density="compact"
:title="getTextForStatus(playingStatus)"

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { IGDBRelatedGame } from "@/__generated__";
import storeGalleryView from "@/stores/galleryView";
import { useTheme } from "vuetify";
// Props
@@ -7,6 +8,7 @@ const props = defineProps<{
game: IGDBRelatedGame;
}>();
const theme = useTheme();
const galleryViewStore = storeGalleryView();
const handleClick = () => {
if (props.game.slug) {
window.open(
@@ -28,6 +30,7 @@ const handleClick = () => {
open-delay="1000"
>{{ game.name }}</v-tooltip
>
<!-- TODO: fix aspect ratio -->
<v-img
v-bind="props"
:src="
@@ -35,7 +38,7 @@ const handleClick = () => {
? `https:${game.cover_url.replace('t_thumb', 't_cover_big')}`
: `/assets/default/cover/big_${theme.global.name.value}_missing_cover.png`
"
:aspect-ratio="2 / 3"
:aspect-ratio="galleryViewStore.defaultAspectRatioCover"
cover
lazy
><v-chip

View File

@@ -6,13 +6,13 @@ import storeHeartbeat from "@/stores/heartbeat";
import storeRoms, { type SimpleRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { inject, ref, computed } from "vue";
import { computed, inject, ref } from "vue";
import { useRoute } from "vue-router";
import { useDisplay, useTheme } from "vuetify";
// Props
const theme = useTheme();
const { lgAndUp, mdAndUp, smAndUp, smAndDown } = useDisplay();
const { lgAndUp, mdAndUp, smAndDown } = useDisplay();
const heartbeat = storeHeartbeat();
const route = useRoute();
const show = ref(false);
@@ -147,7 +147,7 @@ function closeDialog() {
>
<template #content>
<v-row class="align-center pa-2" no-gutters>
<v-col cols="12" md="8" lg="8" xl="9">
<v-col cols="12" md="8" xl="9">
<v-row class="px-2" no-gutters>
<v-col>
<v-text-field
@@ -170,21 +170,18 @@ function closeDialog() {
label="Filename"
variant="outlined"
required
hide-details
@keyup.enter="updateRom()"
>
<v-label
v-if="smAndUp"
id="file-name-label"
class="text-caption"
>
<v-icon size="small" class="mr-1">
mdi-folder-file-outline
</v-icon>
<span>
/romm/library/{{ rom.file_path }}/{{ rom.file_name }}
</span>
</v-label>
<template #details>
<v-label class="text-caption text-wrap">
<v-icon size="small" class="mr-2 text-romm-accent-1">
mdi-folder-file-outline
</v-icon>
<span>
/romm/library/{{ rom.file_path }}/{{ rom.file_name }}
</span>
</v-label>
</template>
</v-text-field>
</v-col>
</v-row>
@@ -226,9 +223,9 @@ function closeDialog() {
</v-btn-group>
</v-row>
</v-col>
<v-col>
<v-row class="justify-center">
<v-col :class="{ 'mobile-cover': smAndDown, 'pa-8': !smAndDown }">
<v-col cols="12" md="4" xl="3">
<v-row class="justify-center" no-gutters>
<v-col style="max-width: 240px" :class="{ 'my-4': smAndDown }">
<game-card :rom="rom" :src="imagePreviewUrl">
<template #append-inner-right>
<v-btn-group rounded="0" divided density="compact">
@@ -241,7 +238,7 @@ function closeDialog() {
@click="
emitter?.emit(
'showSearchCoverDialog',
rom?.name as string,
rom.name as string,
)
"
>
@@ -276,7 +273,7 @@ function closeDialog() {
</game-card>
</v-col>
</v-row>
<v-row v-if="smAndDown" class="justify-space-between pa-4">
<v-row v-if="smAndDown" class="justify-space-between pa-2" no-gutters>
<v-btn-group divided density="compact" class="my-1">
<v-btn
:disabled="noMetadataMatch"
@@ -301,18 +298,3 @@ function closeDialog() {
</template>
</r-dialog>
</template>
<style scoped>
.mobile-cover {
min-width: 240px;
min-height: 330px;
max-width: 240px;
max-height: 330px;
}
</style>
<style>
#file-name-label {
position: absolute;
right: 1rem;
}
</style>

View File

@@ -3,6 +3,7 @@ import type { SearchRomSchema } from "@/__generated__";
import GameCard from "@/components/common/Game/Card/Base.vue";
import RDialog from "@/components/common/RDialog.vue";
import romApi from "@/services/api/rom";
import storeGalleryView from "@/stores/galleryView";
import storeHeartbeat from "@/stores/heartbeat";
import storeRoms, { type SimpleRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
@@ -22,6 +23,7 @@ const { xs, lgAndUp } = useDisplay();
const show = ref(false);
const rom = ref<SimpleRom | null>(null);
const romsStore = storeRoms();
const galleryViewStore = storeGalleryView();
const searching = ref(false);
const route = useRoute();
const searchTerm = ref("");
@@ -330,6 +332,7 @@ onBeforeUnmount(() => {
v-for="matchedRom in filteredMatchedRoms"
>
<game-card
v-if="rom"
@click="showSources(matchedRom)"
:rom="matchedRom"
title-on-footer
@@ -394,7 +397,7 @@ onBeforeUnmount(() => {
? `/assets/default/cover/big_${theme.global.name.value}_missing_cover.png`
: source.url_cover
"
:aspect-ratio="2 / 3"
:aspect-ratio="galleryViewStore.defaultAspectRatioCover"
cover
lazy
>

View File

@@ -184,7 +184,7 @@ onBeforeUnmount(() => {
</v-row>
</template>
<template #content>
<v-row no-gutters>
<v-row no-gutters class="align-content-start align-center">
<v-col
class="pa-1"
cols="4"
@@ -194,13 +194,15 @@ onBeforeUnmount(() => {
v-for="rom in filteredRoms"
>
<game-card
:key="rom.updated_at"
:rom="rom"
@click="onGameClick({ rom, event: $event })"
title-on-hover
show-flags
transform-scale
show-platform-icon
show-fav
transform-scale
show-action-bar
show-platform-icon
/>
</v-col>
</v-row>

View File

@@ -28,7 +28,7 @@ function clear() {
<v-navigation-drawer
:location="smAndDown ? 'top' : 'left'"
mobile
width="400"
width="500"
v-model="activeCollectionsDrawer"
class="bg-terciary"
>

View File

@@ -9,10 +9,13 @@ import UserBtn from "@/components/common/Navigation/UserBtn.vue";
</script>
<template>
<v-app-bar
:elevation="0"
elevation="0"
class="bg-primary justify-center px-1"
mode="shift"
height="45"
app
fixed
left
>
<template #prepend>
<home-btn />

View File

@@ -21,7 +21,7 @@ function clear() {
:location="smAndDown ? 'top' : 'left'"
mobile
@update:model-value="clear"
width="400"
width="500"
v-model="activePlatformsDrawer"
class="bg-terciary"
>

View File

@@ -43,7 +43,7 @@ async function logout() {
<v-navigation-drawer
:location="smAndDown ? 'top' : 'left'"
mobile
width="400"
width="500"
v-model="activeSettingsDrawer"
class="bg-terciary"
>

View File

@@ -10,21 +10,27 @@ const auth = storeAuth();
const { user } = storeToRefs(auth);
</script>
<template>
<v-hover v-slot="{ isHovering, props: hoverProps }">
<v-avatar
@click="navigationStore.switchActiveSettingsDrawer"
class="pointer"
size="35"
v-bind="hoverProps"
:class="{ 'border-romm-accent-1': isHovering }"
>
<v-img
:src="
user?.avatar_path
? `/assets/romm/assets/${user?.avatar_path}?ts=${user?.updated_at}`
: defaultAvatarPath
"
/>
</v-avatar>
</v-hover>
<v-avatar
@click="navigationStore.switchActiveSettingsDrawer"
class="pointer"
size="35"
:class="{ active: navigationStore.activeSettingsDrawer }"
>
<v-img
:src="
user?.avatar_path
? `/assets/romm/assets/${user?.avatar_path}?ts=${user?.updated_at}`
: defaultAvatarPath
"
/>
</v-avatar>
</template>
<style scoped>
.v-avatar {
transition: filter 0.15s ease-in-out;
}
.v-avatar:hover,
.v-avatar.active {
filter: drop-shadow(0px 0px 2px rgba(var(--v-theme-romm-accent-1)));
}
</style>

View File

@@ -16,8 +16,8 @@ withDefaults(
scrollContent?: boolean;
showRommIcon?: boolean;
icon?: string | null;
width?: string;
height?: string;
width?: number | string;
height?: number | string;
}>(),
{
loadingCondition: false,
@@ -27,8 +27,8 @@ withDefaults(
scrollContent: false,
showRommIcon: false,
icon: null,
width: "",
height: "",
width: "auto",
height: "auto",
},
);
const emit = defineEmits(["update:modelValue", "close"]);
@@ -62,7 +62,7 @@ onMounted(() => {
no-click-animation
persistent
>
<v-card rounded="0" :height="height">
<v-card rounded="0" :min-height="height" :max-height="height">
<v-toolbar density="compact" class="bg-terciary">
<v-icon v-if="icon" :icon="icon" class="ml-5" />
<romm-iso :size="30" class="mx-4" v-if="showRommIcon" />
@@ -90,12 +90,12 @@ onMounted(() => {
<v-card-text
id="r-dialog-content"
class="pa-1"
class="pa-1 d-flex flex-column"
:class="{ scroll: scrollContent }"
>
<v-row
v-if="loadingCondition"
class="justify-center align-center h-100"
class="justify-center align-center flex-grow-1"
no-gutters
>
<v-progress-circular
@@ -108,7 +108,7 @@ onMounted(() => {
<v-row
v-if="!loadingCondition && emptyStateCondition"
class="justify-center align-center h-100"
class="justify-center align-center flex-grow-1"
no-gutters
>
<empty-game v-if="emptyStateType == 'game'" />
@@ -117,7 +117,10 @@ onMounted(() => {
<slot v-else name="emptyState"></slot>
</v-row>
<slot name="content"></slot>
<slot
v-if="!loadingCondition && !emptyStateCondition"
name="content"
></slot>
</v-card-text>
<v-card-text v-if="hasAppendSlot" class="pa-1">
<slot name="append"></slot>

View File

@@ -3,15 +3,16 @@ withDefaults(
defineProps<{
title: string;
icon: string;
iconColor?: string;
}>(),
{ title: "", icon: "" },
{ title: "", icon: "", iconColor: "" },
);
</script>
<template>
<v-card rounded="0">
<v-toolbar class="bg-terciary" density="compact">
<v-toolbar-title class="text-button">
<v-icon class="mr-3">{{ icon }}</v-icon>
<v-icon :color="iconColor" class="mr-3">{{ icon }}</v-icon>
{{ title }}
</v-toolbar-title>
<slot name="toolbar-append"></slot>

View File

@@ -6,6 +6,7 @@ import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { inject, onBeforeUnmount, ref } from "vue";
import { useDisplay } from "vuetify";
import storeGalleryView from "@/stores/galleryView";
// Props
const { lgAndUp } = useDisplay();
@@ -15,6 +16,7 @@ const searchTerm = ref("");
const coverType = ref("all");
const covers = ref<SearchCoverSchema[]>([]);
const filteredCovers = ref<SearchCoverSchema[]>();
const galleryViewStore = storeGalleryView();
const panels = ref([0]);
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("showSearchCoverDialog", (term) => {
@@ -23,6 +25,7 @@ emitter?.on("showSearchCoverDialog", (term) => {
if (searchTerm.value) searchCovers();
});
// Functions
async function searchCovers() {
covers.value = [];
@@ -175,20 +178,22 @@ onBeforeUnmount(() => {
v-for="resource in game.resources"
>
<v-hover v-slot="{ isHovering, props: hoverProps }">
<!-- TODO: fix aspect ratio -->
<v-img
v-bind="hoverProps"
:class="{ 'on-hover': isHovering }"
class="transform-scale pointer"
@click="selectCover(resource.url)"
:aspect-ratio="2 / 3"
:aspect-ratio="galleryViewStore.defaultAspectRatioCover"
:src="resource.thumb"
cover
>
<template #error>
<!-- TODO: fix aspect ratio -->
<v-img
:src="resource.url"
cover
:aspect-ratio="2 / 3"
:aspect-ratio="galleryViewStore.defaultAspectRatioCover"
></v-img>
</template>
<template #placeholder>

View File

@@ -27,7 +27,7 @@ function onGameClick(emitData: { rom: SimpleRom; event: MouseEvent }) {
<template>
<r-section icon="mdi-shimmer" title="Recently added">
<template #content>
<v-row class="flex-nowrap overflow-x-auto" no-gutters>
<v-row class="flex-nowrap overflow-x-auto align-center" no-gutters>
<v-col
v-for="rom in recentRoms"
:key="rom.id"
@@ -40,13 +40,14 @@ function onGameClick(emitData: { rom: SimpleRom; event: MouseEvent }) {
>
<game-card
:key="rom.updated_at"
@click="onGameClick"
:rom="rom"
@click="onGameClick"
title-on-hover
show-flags
show-fav
transform-scale
show-action-bar
show-platform-icon
/>
</v-col>
</v-row>

View File

@@ -23,6 +23,9 @@ const languagesRef = ref(
isNull(storedLanguages) ? true : storedLanguages === "true",
);
const storedStatus = localStorage.getItem("settings.showStatus");
const statusRef = ref(isNull(storedStatus) ? true : storedStatus === "true");
// Functions to update localStorage
const toggleGroupRoms = (value: boolean) => {
groupRomsRef.value = value;
@@ -44,6 +47,11 @@ const toggleLanguages = (value: boolean) => {
localStorage.setItem("settings.showLanguages", value.toString());
};
const toggleStatus = (value: boolean) => {
statusRef.value = value;
localStorage.setItem("settings.showStatus", value.toString());
};
const options = computed(() => [
{
title: "Group roms",
@@ -79,6 +87,15 @@ const options = computed(() => [
model: languagesRef,
modelTrigger: toggleLanguages,
},
{
title: "Show status",
description:
"Show status icons in the gallery (backlogged, playing, completed, etc)",
iconEnabled: "mdi-check-circle-outline",
iconDisabled: "mdi-close-circle-outline",
model: statusRef,
modelTrigger: toggleStatus,
},
]);
</script>

View File

@@ -31,7 +31,7 @@ async function updatePlatform({
}: {
platform: Platform;
}): Promise<{ data: MessageResponse }> {
return api.delete(`/platforms/${platform.id}`);
return api.put(`/platforms/${platform.id}`, platform);
}
async function deletePlatform({

View File

@@ -22,9 +22,6 @@ export default defineStore("config", {
EXCLUDED_MULTI_PARTS_FILES: [] as string[],
PLATFORMS_BINDING: {} as Record<string, string>,
PLATFORMS_VERSIONS: {} as Record<string, string>,
ROMS_FOLDER_NAME: "",
FIRMWARE_FOLDER_NAME: "",
HIGH_PRIO_STRUCTURE_PATH: "",
} as ConfigResponse,
};
},

View File

@@ -5,6 +5,9 @@ export default defineStore("galleryView", {
currentView: JSON.parse(
localStorage.getItem("currentView") ?? "0",
) as number,
defaultAspectRatioCover: 2 / 3,
defaultAspectRatioCollection: 2 / 3,
defaultAspectRatioScreenshot: 16 / 9,
activeFirmwareDrawer: false,
scrolledToTop: false,
scroll: 0,

View File

@@ -5,24 +5,26 @@ export default defineStore("navigation", {
activePlatformsDrawer: false,
activeCollectionsDrawer: false,
activeSettingsDrawer: false,
activePlatformInfoDrawer: false,
}),
actions: {
switchActivePlatformsDrawer() {
this.activeCollectionsDrawer = false;
this.activeSettingsDrawer = false;
this.resetDrawersExcept("activePlatformsDrawer");
this.activePlatformsDrawer = !this.activePlatformsDrawer;
},
switchActiveCollectionsDrawer() {
this.activePlatformsDrawer = false;
this.activeSettingsDrawer = false;
this.resetDrawersExcept("activeCollectionsDrawer");
this.activeCollectionsDrawer = !this.activeCollectionsDrawer;
},
switchActiveSettingsDrawer() {
this.activePlatformsDrawer = false;
this.activeCollectionsDrawer = false;
this.resetDrawersExcept("activeSettingsDrawer");
this.activeSettingsDrawer = !this.activeSettingsDrawer;
},
switchActivePlatformInfoDrawer() {
this.resetDrawersExcept("activePlatformInfoDrawer");
this.activePlatformInfoDrawer = !this.activePlatformInfoDrawer;
},
goHome() {
this.resetDrawers();
this.$router.push({ name: "dashboard" });
@@ -35,6 +37,21 @@ export default defineStore("navigation", {
this.activePlatformsDrawer = false;
this.activeCollectionsDrawer = false;
this.activeSettingsDrawer = false;
this.activePlatformInfoDrawer = false;
},
resetDrawersExcept(drawer: string) {
this.activePlatformsDrawer =
drawer === "activePlatformsDrawer" ? this.activePlatformsDrawer : false;
this.activeCollectionsDrawer =
drawer === "activeCollectionsDrawer"
? this.activeCollectionsDrawer
: false;
this.activeSettingsDrawer =
drawer === "activeSettingsDrawer" ? this.activeSettingsDrawer : false;
this.activePlatformInfoDrawer =
drawer === "activePlatformInfoDrawer"
? this.activePlatformInfoDrawer
: false;
},
},
});

View File

@@ -1,21 +1,22 @@
import { defineStore } from "pinia";
import type { PlatformSchema } from "@/__generated__";
import { uniqBy } from "lodash";
import { defineStore } from "pinia";
export type Platform = PlatformSchema;
export default defineStore("platforms", {
state: () => {
return {
all: [] as Platform[],
allPlatforms: [] as Platform[],
searchText: "" as string,
};
},
getters: {
totalGames: ({ all: value }) =>
totalGames: ({ allPlatforms: value }) =>
value.reduce((count, p) => count + p.rom_count, 0),
filledPlatforms: ({ all }) => all.filter((p) => p.rom_count > 0),
filteredPlatforms: ({ all, searchText }) =>
filledPlatforms: ({ allPlatforms: all }) =>
all.filter((p) => p.rom_count > 0),
filteredPlatforms: ({ allPlatforms: all, searchText }) =>
all.filter(
(p) =>
p.rom_count > 0 &&
@@ -24,28 +25,37 @@ export default defineStore("platforms", {
},
actions: {
_reorder() {
this.all = this.all.sort((a, b) => {
this.allPlatforms = this.allPlatforms.sort((a, b) => {
return a.name.localeCompare(b.name);
});
this.all = uniqBy(this.all, "id");
this.allPlatforms = uniqBy(this.allPlatforms, "id");
},
set(platforms: Platform[]) {
this.all = platforms;
this.allPlatforms = platforms;
},
add(platform: Platform) {
this.all.push(platform);
this.allPlatforms.push(platform);
this._reorder();
},
exists(platform: Platform) {
return this.all.filter((p) => p.fs_slug == platform.fs_slug).length > 0;
return (
this.allPlatforms.filter((p) => p.fs_slug == platform.fs_slug).length >
0
);
},
remove(platform: Platform) {
this.all = this.all.filter((p) => {
this.allPlatforms = this.allPlatforms.filter((p) => {
return p.slug !== platform.slug;
});
},
get(platformId: number) {
return this.all.find((p) => p.id === platformId);
return this.allPlatforms.find((p) => p.id === platformId);
},
getAspectRatio(platformId: number): number {
const platform = this.allPlatforms.find((p) => p.id === platformId);
return platform && platform.aspect_ratio
? parseFloat(eval(platform.aspect_ratio as string))
: 2 / 3;
},
},
});

View File

@@ -1,18 +1,21 @@
<script setup lang="ts">
import { isNull } from "lodash";
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import type { FirmwareSchema, SaveSchema, StateSchema } from "@/__generated__";
import RAvatar from "@/components/common/Game/RAvatar.vue";
import firmwareApi from "@/services/api/firmware";
import romApi from "@/services/api/rom";
import storeGalleryView from "@/stores/galleryView";
import type { DetailedRom } from "@/stores/roms";
import { formatBytes, formatTimestamp, getSupportedEJSCores } from "@/utils";
import Player from "@/views/EmulatorJS/Player.vue";
import { isNull } from "lodash";
import { storeToRefs } from "pinia";
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
// Props
const route = useRoute();
const galleryViewStore = storeGalleryView();
const { defaultAspectRatioScreenshot } = storeToRefs(galleryViewStore);
const rom = ref<DetailedRom | null>(null);
const firmwareOptions = ref<FirmwareSchema[]>([]);
const biosRef = ref<FirmwareSchema | null>(null);
@@ -27,6 +30,7 @@ const script = document.createElement("script");
script.src = "/assets/emulatorjs/loader.js";
script.async = true;
// Functions
function onPlay() {
window.EJS_fullscreenOnLoaded = fullScreenOnPlay.value;
document.body.appendChild(script);
@@ -100,6 +104,7 @@ onMounted(async () => {
md="8"
xl="10"
id="game-wrapper"
:style="`aspect-ratio: ${defaultAspectRatioScreenshot}`"
class="bg-primary"
rounded
>
@@ -376,9 +381,3 @@ onMounted(async () => {
</v-col>
</v-row>
</template>
<style>
#game-wrapper {
aspect-ratio: 16 / 9;
}
</style>

View File

@@ -270,7 +270,7 @@ onBeforeUnmount(() => {
<template v-if="filteredRoms.length > 0">
<v-row
no-gutters
class="overflow-hidden"
class="overflow-hidden align-center"
:class="{ 'pa-1': currentView != 2 }"
>
<!-- Gallery cards view -->

View File

@@ -5,7 +5,6 @@ import EmptyGame from "@/components/common/EmptyGame.vue";
import EmptyPlatform from "@/components/common/EmptyPlatform.vue";
import GameCard from "@/components/common/Game/Card/Base.vue";
import GameDataTable from "@/components/common/Game/Table.vue";
import platformApi from "@/services/api/platform";
import romApi from "@/services/api/rom";
import storeGalleryFilter, { type FilterType } from "@/stores/galleryFilter";
import storeGalleryView from "@/stores/galleryView";
@@ -15,7 +14,7 @@ import type { Events } from "@/types/emitter";
import { normalizeString, views } from "@/utils";
import type { Emitter } from "mitt";
import { storeToRefs } from "pinia";
import { inject, onBeforeUnmount, onMounted, ref } from "vue";
import { inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { onBeforeRouteUpdate, useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
@@ -25,7 +24,8 @@ const route = useRoute();
const galleryViewStore = storeGalleryView();
const galleryFilterStore = storeGalleryFilter();
const { scrolledToTop, currentView } = storeToRefs(galleryViewStore);
const platforms = storePlatforms();
const platformsStore = storePlatforms();
const { allPlatforms } = storeToRefs(platformsStore);
const romsStore = storeRoms();
const {
allRoms,
@@ -218,66 +218,80 @@ const filterToSetFilter: Record<FilterType, Function> = {
onMounted(async () => {
const routePlatformId = Number(route.params.platform);
const routePlatform = platforms.get(routePlatformId);
if (!routePlatform) {
await platformApi
.getPlatform(routePlatformId)
.then((data) => {
platforms.add(data.data);
romsStore.setCurrentPlatform(data.data);
})
.catch((error) => {
console.log(error);
noPlatformError.value = true;
});
} else {
romsStore.setCurrentPlatform(routePlatform);
}
watch(
() => allPlatforms.value,
(platforms) => {
if (
platforms.length > 0 &&
platforms.some((platform) => platform.id === routePlatformId)
) {
const platform = platforms.find(
(platform) => platform.id === routePlatformId,
);
if (!noPlatformError.value) {
resetGallery();
await fetchRoms();
setFilters();
// Check if the current platform is different or no ROMs have been loaded
if (
(currentPlatform.value?.id !== routePlatformId ||
allRoms.value.length === 0) &&
platform
) {
romsStore.setCurrentPlatform(platform);
resetGallery();
fetchRoms();
setFilters();
}
// Check if there are query params to set filters
if (route.query.filter && route.query.value) {
const filter = route.query.filter as FilterType;
const value = route.query.value as string;
filterToSetFilter[filter](value);
onFilterChange(); // Update the UI
router.replace({ query: {} }); // Clear query params
}
// Check for query params to set filters
if (route.query.filter && route.query.value) {
const filter = route.query.filter as FilterType;
const value = route.query.value as string;
filterToSetFilter[filter](value);
onFilterChange(); // Update the UI
router.replace({ query: {} }); // Clear query params
}
window.addEventListener("wheel", onScroll);
window.addEventListener("scroll", onScroll);
}
window.addEventListener("wheel", onScroll);
window.addEventListener("scroll", onScroll);
}
},
{ immediate: true }, // Ensure watcher is triggered immediately
);
});
onBeforeRouteUpdate(async (to, from) => {
// Triggers when change param of the same route
// Reset store if switching to another platform
if (to.path === from.path) return true;
// Avoid unnecessary actions if navigating within the same path
if (to.path === from.path) return;
resetGallery();
const routePlatformId = Number(to.params.platform);
const routePlatform = platforms.get(routePlatformId);
if (!routePlatform) {
const { data } = await platformApi.getPlatform(routePlatformId);
platforms.add(data);
} else {
romsStore.setCurrentPlatform(routePlatform);
}
await fetchRoms();
setFilters();
watch(
() => allPlatforms.value,
(platforms) => {
if (platforms.length > 0) {
const platform = platforms.find(
(platform) => platform.id === routePlatformId,
);
return true;
// Only trigger fetchRoms if switching platforms or ROMs are not loaded
if (
(currentPlatform.value?.id !== routePlatformId ||
allRoms.value.length === 0) &&
platform
) {
romsStore.setCurrentPlatform(platform);
fetchRoms();
setFilters();
}
}
},
{ immediate: true }, // Ensure watcher is triggered immediately
);
});
onBeforeUnmount(() => {
romsStore.setCurrentPlatform(null);
window.removeEventListener("wheel", onScroll);
window.removeEventListener("scroll", onScroll);
});
@@ -306,6 +320,7 @@ onBeforeUnmount(() => {
:xl="views[currentView]['size-xl']"
>
<game-card
v-if="currentPlatform"
:key="rom.updated_at"
:rom="rom"
title-on-hover

View File

@@ -7,25 +7,23 @@ import GameInfo from "@/components/Details/Info/GameInfo.vue";
import Personal from "@/components/Details/Personal.vue";
import RelatedGames from "@/components/Details/RelatedGames.vue";
import Saves from "@/components/Details/Saves.vue";
import storePlatforms from "@/stores/platforms";
import States from "@/components/Details/States.vue";
import TitleInfo from "@/components/Details/Title.vue";
import EmptyGame from "@/components/common/EmptyGame.vue";
import Cover from "@/components/common/Game/Card/Base.vue";
import platformApi from "@/services/api/platform";
import GameCard from "@/components/common/Game/Card/Base.vue";
import romApi from "@/services/api/rom";
import storeDownload from "@/stores/download";
import type { Platform } from "@/stores/platforms";
import storeRoms from "@/stores/roms";
import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { storeToRefs } from "pinia";
import { inject, onBeforeMount, ref, watch } from "vue";
import { onBeforeRouteLeave, useRoute } from "vue-router";
import { useRoute } from "vue-router";
import { useDisplay } from "vuetify";
// Props
const route = useRoute();
const platform = ref<Platform>();
const tab = ref<
| "details"
| "saves"
@@ -39,9 +37,11 @@ const { smAndDown, mdAndDown, mdAndUp, lgAndUp } = useDisplay();
const emitter = inject<Emitter<Events>>("emitter");
const noRomError = ref(false);
const romsStore = storeRoms();
const { currentRom } = storeToRefs(romsStore);
const platfotmsStore = storePlatforms();
const { currentRom, gettingRoms } = storeToRefs(romsStore);
async function fetchDetails() {
gettingRoms.value = true;
await romApi
.getRom({ romId: parseInt(route.params.rom as string) })
.then(({ data }) => {
@@ -53,50 +53,40 @@ async function fetchDetails() {
})
.finally(() => {
emitter?.emit("showLoadingDialog", { loading: false, scrim: false });
gettingRoms.value = false;
});
if (!noRomError.value) {
await platformApi
.getPlatform(currentRom.value?.platform_id)
.then((response) => {
platform.value = response.data;
})
.catch((error) => {
console.log(error);
})
.finally(() => {
emitter?.emit("showLoadingDialog", { loading: false, scrim: false });
});
}
}
onBeforeMount(async () => {
emitter?.emit("showLoadingDialog", { loading: true, scrim: false });
if (currentRom.value?.id == parseInt(route.params.rom as string)) {
emitter?.emit("showLoadingDialog", { loading: false, scrim: false });
} else {
const romId = parseInt(route.params.rom as string);
// Only fetch details if the currentRom ID differs
if (currentRom.value?.id !== romId) {
emitter?.emit("showLoadingDialog", { loading: true, scrim: false });
await fetchDetails();
} else {
emitter?.emit("showLoadingDialog", { loading: false, scrim: false });
}
const downloadStore = storeDownload();
downloadStore.clear();
});
onBeforeRouteLeave(() => {
currentRom.value = null;
return true;
});
watch(
() => route.fullPath,
async () => {
await fetchDetails();
const romId = parseInt(route.params.rom as string);
// Only fetch details if the currentRom ID differs
if (currentRom.value?.id !== romId) {
await fetchDetails();
}
},
);
</script>
<template>
<!-- TODO: review layout on certain roms - ej: mortal kombat 2 for gb -->
<template v-if="currentRom && platform">
<template v-if="currentRom && !gettingRoms">
<background-header />
<v-row
@@ -111,10 +101,16 @@ watch(
class="cover"
:class="{
'cover-desktop': mdAndUp,
'cover-mobile': smAndDown,
}"
:style="
smAndDown
? platfotmsStore.getAspectRatio(currentRom.platform_id) == 1 / 1
? 'margin-top: -220px;'
: 'margin-top: -280px;'
: ''
"
>
<cover
<game-card
:key="currentRom.updated_at"
:pointerOnHover="false"
:rom="currentRom"
@@ -129,8 +125,14 @@ watch(
class="px-5"
:class="{
'info-lg': mdAndUp,
'info-mobile': smAndDown,
}"
:style="
smAndDown
? platfotmsStore.getAspectRatio(currentRom.platform_id) == 1 / 1
? 'margin-top: -40px;'
: 'margin-top: 100px;'
: ''
"
>
<div
class="px-3 pb-3"
@@ -139,7 +141,7 @@ watch(
'justify-center': smAndDown,
}"
>
<title-info :rom="currentRom" :platform="platform" />
<title-info :rom="currentRom" />
</div>
<v-row
:class="{
@@ -185,7 +187,7 @@ watch(
<v-window-item value="details">
<v-row no-gutters :class="{ 'mx-2': mdAndUp }">
<v-col>
<file-info :rom="currentRom" :platform="platform" />
<file-info :rom="currentRom" />
<game-info :rom="currentRom" />
</v-col>
</v-row>
@@ -254,9 +256,6 @@ watch(
margin-top: -190px;
margin-left: -20px;
}
.cover-mobile {
margin-top: -280px;
}
.info-mobile {
margin-top: 100px;
}

View File

@@ -30,7 +30,6 @@ import DeletePlatformDialog from "@/components/common/Platform/Dialog/DeletePlat
import SearchCoverDialog from "@/components/common/SearchCover.vue";
import collectionApi from "@/services/api/collection";
import platformApi from "@/services/api/platform";
import userApi from "@/services/api/user";
import storeAuth from "@/stores/auth";
import storeCollections from "@/stores/collections";
import storeNavigation from "@/stores/navigation";
@@ -74,14 +73,6 @@ onBeforeMount(async () => {
.catch((error) => {
console.error(error);
});
await userApi
.fetchCurrentUser()
.then(({ data: user }) => {
auth.setUser(user);
})
.catch((error) => {
console.error(error);
});
navigationStore.resetDrawers();
});
</script>

View File

@@ -1,14 +1,17 @@
<script setup lang="ts">
import { isNull } from "lodash";
import { onMounted, ref, nextTick } from "vue";
import { useRoute } from "vue-router";
import RAvatar from "@/components/common/Game/RAvatar.vue";
import romApi from "@/services/api/rom";
import storeGalleryView from "@/stores/galleryView";
import type { DetailedRom } from "@/stores/roms";
import { isNull } from "lodash";
import { storeToRefs } from "pinia";
import { nextTick, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
// Props
const route = useRoute();
const galleryViewStore = storeGalleryView();
const { defaultAspectRatioScreenshot } = storeToRefs(galleryViewStore);
const rom = ref<DetailedRom | null>(null);
const gameRunning = ref(false);
const storedFSOP = localStorage.getItem("fullScreenOnPlay");
@@ -25,6 +28,7 @@ const script = document.createElement("script");
script.src = "/assets/ruffle/ruffle.js";
document.body.appendChild(script);
// Functions
function onPlay() {
gameRunning.value = true;
@@ -71,6 +75,7 @@ onMounted(async () => {
md="8"
xl="10"
id="game-wrapper"
:style="`aspect-ratio: ${defaultAspectRatioScreenshot}`"
class="bg-secondary"
rounded
>
@@ -191,12 +196,6 @@ onMounted(async () => {
</v-row>
</template>
<style>
#game-wrapper {
aspect-ratio: 16 / 9;
}
</style>
<style scoped>
#game {
max-height: 100dvh;

View File

@@ -105,7 +105,7 @@ async function stopScan() {
v-model="platformsToScan"
label="Platforms"
item-title="name"
:items="platforms.all"
:items="platforms.allPlatforms"
variant="outlined"
density="comfortable"
multiple
@@ -231,12 +231,7 @@ async function stopScan() {
>mdi-magnify-scan</v-icon
>
</template>
<span
:class="{
'text-romm-accent-1': !scanning,
}"
>Scan</span
>
Scan
<template #loader>
<v-progress-circular
color="romm-accent-1"
@@ -256,7 +251,7 @@ async function stopScan() {
<template #prepend>
<v-icon :color="scanning ? 'red' : ''">mdi-alert-octagon</v-icon>
</template>
<span :class="{ 'text-romm-red': scanning }">Abort</span>
Abort
</v-btn>
<v-btn
prepend-icon="mdi-table-cog"