mirror of
https://github.com/rommapp/romm.git
synced 2026-02-18 23:42:07 +01:00
Merge branch 'master' into scheduled-tasks
This commit is contained in:
@@ -298,4 +298,5 @@ Games can be tagged with region, revision or other tags using parenthesis in the
|
||||
|
||||
# 🎖 Credits
|
||||
|
||||
* Pc icon support - <a href="https://www.flaticon.com/free-icons/keyboard-and-mouse" title="Keyboard and mouse icons">Keyboard and mouse icons created by Flat Icons - Flaticon</a>
|
||||
* PC icon support - <a href="https://www.flaticon.com/free-icons/keyboard-and-mouse" title="Keyboard and mouse icons">Keyboard and mouse icons created by Flat Icons - Flaticon</a>
|
||||
* Default user icon - <a target="_blank" href="https://icons8.com/icon/tZuAOUGm9AuS/user-default">User Default</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
|
||||
|
||||
47
backend/alembic/versions/1.9.2.py
Normal file
47
backend/alembic/versions/1.9.2.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 583453a8a07f
|
||||
Revises: 1.8.3
|
||||
Create Date: 2023-08-10 22:18:24.012779
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "1.9.2"
|
||||
down_revision = "1.8.3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("username", sa.String(length=255), nullable=True),
|
||||
sa.Column("hashed_password", sa.String(length=255), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=True),
|
||||
sa.Column(
|
||||
"role", sa.Enum("VIEWER", "EDITOR", "ADMIN", name="role"), nullable=True
|
||||
),
|
||||
sa.Column("avatar_path", sa.String(length=255), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
with op.batch_alter_table("users", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_users_username"), ["username"], unique=True
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("users", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_users_username"))
|
||||
|
||||
op.drop_table("users")
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,49 +1,60 @@
|
||||
import os
|
||||
import secrets
|
||||
from dotenv import load_dotenv
|
||||
from typing import Final
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Uvicorn
|
||||
DEV_PORT: int = int(os.environ.get("VITE_BACKEND_DEV_PORT", "5000"))
|
||||
DEV_HOST: str = "0.0.0.0"
|
||||
# UVICORN
|
||||
DEV_PORT: Final = int(os.environ.get("VITE_BACKEND_DEV_PORT", "5000"))
|
||||
DEV_HOST: Final = "0.0.0.0"
|
||||
|
||||
# PATHS
|
||||
ROMM_BASE_PATH: str = os.environ.get("ROMM_BASE_PATH", "/romm")
|
||||
LIBRARY_BASE_PATH: str = f"{ROMM_BASE_PATH}/library"
|
||||
FRONT_LIBRARY_PATH: str = "/assets/romm/library"
|
||||
ROMM_USER_CONFIG_PATH: str = f"{ROMM_BASE_PATH}/config.yml"
|
||||
SQLITE_DB_BASE_PATH: str = f"{ROMM_BASE_PATH}/database"
|
||||
RESOURCES_BASE_PATH: str = f"{ROMM_BASE_PATH}/resources"
|
||||
LOGS_BASE_PATH: str = f"{ROMM_BASE_PATH}/logs"
|
||||
HIGH_PRIO_STRUCTURE_PATH: str = f"{LIBRARY_BASE_PATH}/roms"
|
||||
ROMM_BASE_PATH: Final = os.environ.get("ROMM_BASE_PATH", "/romm")
|
||||
LIBRARY_BASE_PATH: Final = f"{ROMM_BASE_PATH}/library"
|
||||
FRONT_LIBRARY_PATH: Final = "/assets/romm/library"
|
||||
ROMM_USER_CONFIG_PATH: Final = f"{ROMM_BASE_PATH}/config.yml"
|
||||
SQLITE_DB_BASE_PATH: Final = f"{ROMM_BASE_PATH}/database"
|
||||
RESOURCES_BASE_PATH: Final = f"{ROMM_BASE_PATH}/resources"
|
||||
LOGS_BASE_PATH: Final = f"{ROMM_BASE_PATH}/logs"
|
||||
HIGH_PRIO_STRUCTURE_PATH: Final = f"{LIBRARY_BASE_PATH}/roms"
|
||||
|
||||
# DEFAULT RESOURCES
|
||||
DEFAULT_URL_COVER_L: str = (
|
||||
DEFAULT_URL_COVER_L: Final = (
|
||||
"https://images.igdb.com/igdb/image/upload/t_cover_big/nocover.png"
|
||||
)
|
||||
DEFAULT_PATH_COVER_L: str = "default/default/cover/big.png"
|
||||
DEFAULT_URL_COVER_S: str = (
|
||||
DEFAULT_PATH_COVER_L: Final = "default/default/cover/big.png"
|
||||
DEFAULT_URL_COVER_S: Final = (
|
||||
"https://images.igdb.com/igdb/image/upload/t_cover_small/nocover.png"
|
||||
)
|
||||
DEFAULT_PATH_COVER_S: str = "default/default/cover/small.png"
|
||||
DEFAULT_PATH_COVER_S: Final = "default/default/cover/small.png"
|
||||
|
||||
# MARIADB
|
||||
DB_HOST = os.environ.get("DB_HOST", "127.0.0.1")
|
||||
DB_PORT: int = int(os.environ.get("DB_PORT", 3306))
|
||||
DB_USER = os.environ.get("DB_USER")
|
||||
DB_PASSWD = os.environ.get("DB_PASSWD")
|
||||
DB_NAME = os.environ.get("DB_NAME", "romm")
|
||||
DB_HOST: Final = os.environ.get("DB_HOST", "127.0.0.1")
|
||||
DB_PORT: Final = int(os.environ.get("DB_PORT", 3306))
|
||||
DB_USER: Final = os.environ.get("DB_USER")
|
||||
DB_PASSWD: Final = os.environ.get("DB_PASSWD")
|
||||
DB_NAME: Final = os.environ.get("DB_NAME", "romm")
|
||||
|
||||
# REDIS
|
||||
REDIS_HOST = os.environ.get('REDIS_HOST', 'localhost')
|
||||
REDIS_PORT = os.environ.get('REDIS_PORT', '6379')
|
||||
ENABLE_EXPERIMENTAL_REDIS: Final = os.environ.get("ENABLE_EXPERIMENTAL_REDIS", "false") == "true"
|
||||
REDIS_HOST: Final = os.environ.get("REDIS_HOST", "localhost")
|
||||
REDIS_PORT: Final = os.environ.get("REDIS_PORT", "6379")
|
||||
|
||||
# IGDB
|
||||
CLIENT_ID = os.environ.get("CLIENT_ID", "")
|
||||
CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "")
|
||||
CLIENT_ID: Final = os.environ.get("CLIENT_ID", "")
|
||||
CLIENT_SECRET: Final = os.environ.get("CLIENT_SECRET", "")
|
||||
|
||||
# STEAMGRIDDB
|
||||
STEAMGRIDDB_API_KEY = os.environ.get("STEAMGRIDDB_API_KEY", "")
|
||||
STEAMGRIDDB_API_KEY: Final = os.environ.get("STEAMGRIDDB_API_KEY", "")
|
||||
|
||||
# DB DRIVERS
|
||||
ROMM_DB_DRIVER = os.environ.get("ROMM_DB_DRIVER", "sqlite")
|
||||
ROMM_DB_DRIVER: Final = os.environ.get("ROMM_DB_DRIVER", "sqlite")
|
||||
|
||||
# AUTH
|
||||
ROMM_AUTH_ENABLED: Final = os.environ.get("ROMM_AUTH_ENABLED", "false") == "true"
|
||||
ROMM_AUTH_USERNAME: Final = os.environ.get("ROMM_AUTH_USERNAME", "admin")
|
||||
ROMM_AUTH_PASSWORD: Final = os.environ.get("ROMM_AUTH_PASSWORD", "admin")
|
||||
ROMM_AUTH_SECRET_KEY: Final = os.environ.get(
|
||||
"ROMM_AUTH_SECRET_KEY", secrets.token_hex(32)
|
||||
)
|
||||
|
||||
210
backend/endpoints/identity.py
Normal file
210
backend/endpoints/identity.py
Normal file
@@ -0,0 +1,210 @@
|
||||
import secrets
|
||||
from typing import Optional, Annotated
|
||||
from fastapi import APIRouter, HTTPException, status, Request, Depends, File, UploadFile
|
||||
from fastapi.security.http import HTTPBasic
|
||||
from pydantic import BaseModel, BaseConfig
|
||||
|
||||
from handler import dbh
|
||||
from models.user import User, Role
|
||||
from utils.cache import cache
|
||||
from utils.auth import authenticate_user, get_password_hash, clear_session
|
||||
from utils.oauth import protected_route
|
||||
from utils.fs import build_avatar_path
|
||||
from config import ROMM_AUTH_ENABLED
|
||||
from exceptions.credentials_exceptions import credentials_exception, disabled_exception
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UserSchema(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
enabled: bool
|
||||
role: Role
|
||||
oauth_scopes: list[str]
|
||||
avatar_path: str
|
||||
|
||||
class Config(BaseConfig):
|
||||
orm_mode = True
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(request: Request, credentials=Depends(HTTPBasic())):
|
||||
"""Session login endpoint"""
|
||||
user = authenticate_user(credentials.username, credentials.password)
|
||||
if not user:
|
||||
raise credentials_exception
|
||||
|
||||
if not user.enabled:
|
||||
raise disabled_exception
|
||||
|
||||
# Generate unique session key and store in cache
|
||||
request.session["session_id"] = secrets.token_hex(16)
|
||||
cache.set(f'romm:{request.session["session_id"]}', user.username) # type: ignore[attr-defined]
|
||||
|
||||
return {"message": "Successfully logged in"}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request):
|
||||
"""Session logout endpoint"""
|
||||
# Check if session key already stored in cache
|
||||
session_id = request.session.get("session_id")
|
||||
if not session_id:
|
||||
return {"message": "Already logged out"}
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return {"message": "Already logged out"}
|
||||
|
||||
clear_session(request)
|
||||
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
@protected_route(router.get, "/users", ["users.read"])
|
||||
def users(request: Request) -> list[UserSchema]:
|
||||
"""Get all users"""
|
||||
return dbh.get_users()
|
||||
|
||||
|
||||
@protected_route(router.get, "/users/me", ["me.read"])
|
||||
def current_user(request: Request) -> UserSchema | None:
|
||||
"""Get current user"""
|
||||
return request.user
|
||||
|
||||
|
||||
@protected_route(router.get, "/users/{user_id}", ["users.read"])
|
||||
def get_user(request: Request, user_id: int) -> UserSchema:
|
||||
"""Get a specific user"""
|
||||
user = dbh.get_user(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.post,
|
||||
"/users",
|
||||
["users.write"],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_user(
|
||||
request: Request, username: str, password: str, role: str
|
||||
) -> UserSchema:
|
||||
"""Create a new user"""
|
||||
if not ROMM_AUTH_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot create user: ROMM_AUTH_ENABLED is set to False",
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
hashed_password=get_password_hash(password),
|
||||
role=Role[role.upper()],
|
||||
)
|
||||
|
||||
return dbh.add_user(user)
|
||||
|
||||
|
||||
class UserUpdateForm:
|
||||
def __init__(
|
||||
self,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
role: Optional[str] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
avatar: Optional[UploadFile] = File(None),
|
||||
):
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.role = role
|
||||
self.enabled = enabled
|
||||
self.avatar = avatar
|
||||
|
||||
|
||||
@protected_route(router.put, "/users/{user_id}", ["users.write"])
|
||||
def update_user(
|
||||
request: Request, user_id: int, form_data: Annotated[UserUpdateForm, Depends()]
|
||||
) -> UserSchema:
|
||||
"""Update a specific user"""
|
||||
if not ROMM_AUTH_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot update user: ROMM_AUTH_ENABLED is set to False",
|
||||
)
|
||||
|
||||
user = dbh.get_user(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
cleaned_data = {}
|
||||
|
||||
if form_data.username and form_data.username != user.username:
|
||||
existing_user = dbh.get_user_by_username(form_data.username.lower())
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Username already in use by another user"
|
||||
)
|
||||
|
||||
cleaned_data["username"] = form_data.username.lower()
|
||||
|
||||
if form_data.password:
|
||||
cleaned_data["hashed_password"] = get_password_hash(form_data.password)
|
||||
|
||||
# You can't change your own role
|
||||
if form_data.role and request.user.id != user_id:
|
||||
cleaned_data["role"] = Role[form_data.role.upper()] # type: ignore[assignment]
|
||||
|
||||
# You can't disable yourself
|
||||
if form_data.enabled is not None and request.user.id != user_id:
|
||||
cleaned_data["enabled"] = form_data.enabled # type: ignore[assignment]
|
||||
|
||||
if form_data.avatar is not None:
|
||||
cleaned_data["avatar_path"], avatar_user_path = build_avatar_path(
|
||||
form_data.avatar.filename, form_data.username
|
||||
)
|
||||
file_location = f"{avatar_user_path}/{form_data.avatar.filename}"
|
||||
with open(file_location, "wb+") as file_object:
|
||||
file_object.write(form_data.avatar.file.read())
|
||||
|
||||
if cleaned_data:
|
||||
dbh.update_user(user_id, cleaned_data)
|
||||
|
||||
# Log out the current user if username or password changed
|
||||
creds_updated = cleaned_data.get("username") or cleaned_data.get(
|
||||
"hashed_password"
|
||||
)
|
||||
if request.user.id == user_id and creds_updated:
|
||||
clear_session(request)
|
||||
|
||||
return dbh.get_user(user_id)
|
||||
|
||||
|
||||
@protected_route(router.delete, "/users/{user_id}", ["users.write"])
|
||||
def delete_user(request: Request, user_id: int):
|
||||
"""Delete a specific user"""
|
||||
if not ROMM_AUTH_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete user: ROMM_AUTH_ENABLED is set to False",
|
||||
)
|
||||
|
||||
user = dbh.get_user(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# You can't delete the user you're logged in as
|
||||
if request.user.id == user_id:
|
||||
raise HTTPException(status_code=400, detail="You cannot delete yourself")
|
||||
|
||||
# You can't delete the last admin user
|
||||
if user.role == Role.ADMIN and len(dbh.get_admin_users()) == 1:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="You cannot delete the last admin user"
|
||||
)
|
||||
|
||||
dbh.delete_user(user_id)
|
||||
|
||||
return {"message": "User successfully deleted"}
|
||||
112
backend/endpoints/oauth.py
Normal file
112
backend/endpoints/oauth.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from typing import Annotated, Final
|
||||
from datetime import timedelta
|
||||
from fastapi import Depends, APIRouter, HTTPException, status
|
||||
|
||||
|
||||
from utils.auth import authenticate_user
|
||||
from utils.oauth import (
|
||||
OAuth2RequestForm,
|
||||
create_oauth_token,
|
||||
get_current_active_user_from_bearer_token,
|
||||
)
|
||||
|
||||
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: Final = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: Final = 7
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def token(form_data: Annotated[OAuth2RequestForm, Depends()]):
|
||||
"""OAuth2 token endpoint"""
|
||||
|
||||
# Suppport refreshing access tokens
|
||||
if form_data.grant_type == "refresh_token":
|
||||
token = form_data.refresh_token
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token"
|
||||
)
|
||||
|
||||
user, payload = await get_current_active_user_from_bearer_token(token)
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
access_token = create_oauth_token(
|
||||
data={
|
||||
"sub": user.username,
|
||||
"scopes": payload.get("scopes"),
|
||||
"type": "access",
|
||||
},
|
||||
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
}
|
||||
|
||||
# Authentication via username/password
|
||||
elif form_data.grant_type == "password":
|
||||
if not form_data.username or not form_data.password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing username or password",
|
||||
)
|
||||
|
||||
user = authenticate_user(form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
)
|
||||
|
||||
# TODO: Authentication via client_id/client_secret
|
||||
elif form_data.grant_type == "client_credentials":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Client credentials are not yet supported",
|
||||
)
|
||||
|
||||
else:
|
||||
# All other grant types are unsupported
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or unsupported grant type",
|
||||
)
|
||||
|
||||
# Check if user has access to requested scopes
|
||||
if not set(form_data.scopes).issubset(user.oauth_scopes):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient scope",
|
||||
)
|
||||
|
||||
access_token = create_oauth_token(
|
||||
data={
|
||||
"sub": user.username,
|
||||
"scopes": " ".join(form_data.scopes),
|
||||
"type": "access",
|
||||
},
|
||||
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
|
||||
refresh_token = create_oauth_token(
|
||||
data={
|
||||
"sub": user.username,
|
||||
"scopes": " ".join(form_data.scopes),
|
||||
"type": "refresh",
|
||||
},
|
||||
expires_delta=timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel, BaseConfig
|
||||
|
||||
from handler import dbh
|
||||
from utils.oauth import protected_route
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -22,7 +23,7 @@ class PlatformSchema(BaseModel):
|
||||
orm_mode = True
|
||||
|
||||
|
||||
@router.get("/platforms", status_code=200)
|
||||
def platforms() -> list[PlatformSchema]:
|
||||
@protected_route(router.get, "/platforms", ["platforms.read"])
|
||||
def platforms(request: Request) -> list[PlatformSchema]:
|
||||
"""Returns platforms data"""
|
||||
return dbh.get_platforms()
|
||||
|
||||
@@ -6,14 +6,14 @@ from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, BaseConfig
|
||||
|
||||
from stat import S_IFREG
|
||||
from stream_zip import ZIP_64, stream_zip
|
||||
from stream_zip import ZIP_64, stream_zip # type: ignore[import]
|
||||
|
||||
from logger.logger import log
|
||||
from handler import dbh
|
||||
from utils import fs, get_file_name_with_no_tags
|
||||
from utils.exceptions import RomNotFoundError, RomAlreadyExistsException
|
||||
from models.rom import Rom
|
||||
from models.platform import Platform
|
||||
from exceptions.fs_exceptions import RomNotFoundError, RomAlreadyExistsException
|
||||
from utils.oauth import protected_route
|
||||
from models import Rom, Platform
|
||||
from config import LIBRARY_BASE_PATH
|
||||
|
||||
from .utils import CustomStreamingResponse
|
||||
@@ -66,15 +66,16 @@ class RomSchema(BaseModel):
|
||||
orm_mode = True
|
||||
|
||||
|
||||
@router.get("/platforms/{p_slug}/roms/{id}", status_code=200)
|
||||
def rom(id: int) -> RomSchema:
|
||||
@protected_route(router.get, "/platforms/{p_slug}/roms/{id}", ["roms.read"])
|
||||
def rom(request: Request, id: int) -> RomSchema:
|
||||
"""Returns one rom data of the desired platform"""
|
||||
|
||||
return dbh.get_rom(id)
|
||||
|
||||
|
||||
@router.get("/platforms/{p_slug}/roms/{id}/download", status_code=200)
|
||||
def download_rom(id: int, files: str):
|
||||
@protected_route(router.get, "/platforms/{p_slug}/roms/{id}/download", ["roms.read"])
|
||||
def download_rom(request: Request, id: int, files: str):
|
||||
"""Downloads a rom or a zip file with multiple roms"""
|
||||
rom = dbh.get_rom(id)
|
||||
rom_path = f"{LIBRARY_BASE_PATH}/{rom.full_path}"
|
||||
|
||||
@@ -104,9 +105,13 @@ def download_rom(id: int, files: str):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/platforms/{p_slug}/roms", status_code=200)
|
||||
@protected_route(router.get, "/platforms/{p_slug}/roms", ["roms.read"])
|
||||
def roms(
|
||||
p_slug: str, size: int = 60, cursor: str = "", search_term: str = ""
|
||||
request: Request,
|
||||
p_slug: str,
|
||||
size: int = 60,
|
||||
cursor: str = "",
|
||||
search_term: str = "",
|
||||
) -> CursorPage[RomSchema]:
|
||||
"""Returns all roms of the desired platform"""
|
||||
with dbh.session.begin() as session:
|
||||
@@ -123,11 +128,11 @@ def roms(
|
||||
return paginate(session, qq, cursor_params)
|
||||
|
||||
|
||||
@router.patch("/platforms/{p_slug}/roms/{id}", status_code=200)
|
||||
async def updateRom(req: Request, p_slug: str, id: int) -> dict:
|
||||
@protected_route(router.patch, "/platforms/{p_slug}/roms/{id}", ["roms.write"])
|
||||
async def update_rom(request: Request, p_slug: str, id: int) -> dict:
|
||||
"""Updates rom details"""
|
||||
|
||||
data: dict = await req.json()
|
||||
data: dict = await request.json()
|
||||
updated_rom: dict = data["updatedRom"]
|
||||
db_rom: Rom = dbh.get_rom(id)
|
||||
platform: Platform = dbh.get_platform(p_slug)
|
||||
@@ -149,14 +154,14 @@ async def updateRom(req: Request, p_slug: str, id: int) -> dict:
|
||||
overwrite=True,
|
||||
p_slug=platform.slug,
|
||||
r_name=updated_rom["file_name_no_tags"],
|
||||
url_cover=updated_rom["url_cover"],
|
||||
url_cover=updated_rom.get("url_cover", ""),
|
||||
)
|
||||
)
|
||||
updated_rom.update(
|
||||
fs.get_screenshots(
|
||||
p_slug=platform.slug,
|
||||
r_name=updated_rom["file_name_no_tags"],
|
||||
url_screenshots=updated_rom["url_screenshots"],
|
||||
url_screenshots=updated_rom.get("url_screenshots", ""),
|
||||
),
|
||||
)
|
||||
dbh.update_rom(id, updated_rom)
|
||||
@@ -167,14 +172,15 @@ async def updateRom(req: Request, p_slug: str, id: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/platforms/{p_slug}/roms/{id}", status_code=200)
|
||||
def delete_rom(p_slug: str, id: int, filesystem: bool = False) -> dict:
|
||||
"""Detele rom from database [and filesystem]"""
|
||||
def _delete_single_rom(rom_id: int, p_slug: str, filesystem: bool = False):
|
||||
rom = dbh.get_rom(rom_id)
|
||||
if not rom:
|
||||
error = f"Rom with id {rom_id} not found"
|
||||
log.error(error)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
|
||||
|
||||
rom: Rom = dbh.get_rom(id)
|
||||
log.info(f"Deleting {rom.file_name} from database")
|
||||
dbh.delete_rom(id)
|
||||
dbh.update_n_roms(p_slug)
|
||||
dbh.delete_rom(rom_id)
|
||||
|
||||
if filesystem:
|
||||
log.info(f"Deleting {rom.file_name} from filesystem")
|
||||
@@ -186,4 +192,35 @@ def delete_rom(p_slug: str, id: int, filesystem: bool = False) -> dict:
|
||||
log.error(error)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
|
||||
|
||||
return rom
|
||||
|
||||
|
||||
@protected_route(router.delete, "/platforms/{p_slug}/roms/{id}", ["roms.write"])
|
||||
def delete_rom(
|
||||
request: Request, p_slug: str, id: int, filesystem: bool = False
|
||||
) -> dict:
|
||||
"""Detele rom from database [and filesystem]"""
|
||||
|
||||
rom = _delete_single_rom(id, p_slug, filesystem)
|
||||
dbh.update_n_roms(p_slug)
|
||||
|
||||
return {"msg": f"{rom.file_name} deleted successfully!"}
|
||||
|
||||
|
||||
@protected_route(router.post, "/platforms/{p_slug}/roms/delete", ["roms.write"])
|
||||
async def mass_delete_roms(
|
||||
request: Request,
|
||||
p_slug: str,
|
||||
filesystem: bool = False,
|
||||
) -> dict:
|
||||
"""Detele multiple roms from database [and filesystem]"""
|
||||
|
||||
data: dict = await request.json()
|
||||
roms_ids: list = data["roms"]
|
||||
|
||||
for rom_id in roms_ids:
|
||||
_delete_single_rom(rom_id, p_slug, filesystem)
|
||||
|
||||
dbh.update_n_roms(p_slug)
|
||||
|
||||
return {"msg": f"{len(roms_ids)} roms deleted successfully!"}
|
||||
|
||||
@@ -2,14 +2,19 @@ import emoji
|
||||
import socketio # type: ignore
|
||||
|
||||
from logger.logger import log
|
||||
from config import ENABLE_EXPERIMENTAL_REDIS
|
||||
from utils import fs, fastapi
|
||||
from utils.exceptions import PlatformsNotFoundException, RomsNotFoundException
|
||||
from exceptions.fs_exceptions import PlatformsNotFoundException, RomsNotFoundException
|
||||
from handler import dbh
|
||||
from utils.socket import socket_server
|
||||
from utils.redis import high_prio_queue, redis_url, redis_connectable
|
||||
from endpoints.platform import PlatformSchema
|
||||
from endpoints.rom import RomSchema
|
||||
|
||||
|
||||
async def scan_platforms(paltforms: str, complete_rescan: bool):
|
||||
async def scan_platforms(
|
||||
platform_slugs: list[str], complete_rescan: bool, selected_roms: list[str]
|
||||
):
|
||||
# Connect to external socketio server
|
||||
sm = (
|
||||
socketio.AsyncRedisManager(redis_url, write_only=True)
|
||||
@@ -25,7 +30,8 @@ async def scan_platforms(paltforms: str, complete_rescan: bool):
|
||||
await sm.emit("scan:done_ko", e.message)
|
||||
return
|
||||
|
||||
platform_list = paltforms.split(",") if paltforms else fs_platforms
|
||||
platform_list = [dbh.get_platform(s).fs_slug for s in platform_slugs]
|
||||
platform_list = platform_list or fs_platforms
|
||||
for p_slug in platform_list:
|
||||
try:
|
||||
# Verify that platform exists
|
||||
@@ -34,36 +40,33 @@ async def scan_platforms(paltforms: str, complete_rescan: bool):
|
||||
log.error(e)
|
||||
continue
|
||||
|
||||
new_platform = dbh.add_platform(scanned_platform)
|
||||
await sm.emit(
|
||||
"scan:scanning_platform",
|
||||
{"p_name": scanned_platform.name, "p_slug": scanned_platform.slug},
|
||||
"scan:scanning_platform", PlatformSchema.from_orm(new_platform).dict()
|
||||
)
|
||||
|
||||
dbh.add_platform(scanned_platform)
|
||||
|
||||
# Scanning roms
|
||||
fs_roms = fs.get_roms(scanned_platform.fs_slug)
|
||||
for rom in fs_roms:
|
||||
rom_id = dbh.rom_exists(scanned_platform.slug, rom["file_name"])
|
||||
if rom_id and not complete_rescan:
|
||||
for fs_rom in fs_roms:
|
||||
rom_id = dbh.rom_exists(scanned_platform.slug, fs_rom["file_name"])
|
||||
if rom_id and rom_id not in selected_roms and not complete_rescan:
|
||||
continue
|
||||
|
||||
scanned_rom = fastapi.scan_rom(scanned_platform, rom)
|
||||
await sm.emit(
|
||||
"scan:scanning_rom",
|
||||
{
|
||||
"p_slug": scanned_platform.slug,
|
||||
"p_name": scanned_platform.name,
|
||||
"file_name": scanned_rom.file_name,
|
||||
"r_name": scanned_rom.r_name,
|
||||
"r_igdb_id": scanned_rom.r_igdb_id,
|
||||
},
|
||||
)
|
||||
|
||||
scanned_rom = fastapi.scan_rom(scanned_platform, fs_rom)
|
||||
if rom_id:
|
||||
scanned_rom.id = rom_id
|
||||
|
||||
dbh.add_rom(scanned_rom)
|
||||
rom = dbh.add_rom(scanned_rom)
|
||||
await sm.emit(
|
||||
"scan:scanning_rom",
|
||||
{
|
||||
"p_name": scanned_platform.name,
|
||||
**RomSchema.from_orm(rom).dict(),
|
||||
},
|
||||
)
|
||||
|
||||
dbh.purge_roms(scanned_platform.slug, [rom["file_name"] for rom in fs_roms])
|
||||
dbh.update_n_roms(scanned_platform.slug)
|
||||
dbh.purge_platforms(fs_platforms)
|
||||
@@ -72,14 +75,20 @@ async def scan_platforms(paltforms: str, complete_rescan: bool):
|
||||
|
||||
|
||||
@socket_server.on("scan")
|
||||
async def scan_handler(_sid: str, platforms: str, complete_rescan: bool = True):
|
||||
async def scan_handler(_sid: str, options: dict):
|
||||
"""Scan platforms and roms and write them in database."""
|
||||
|
||||
log.info(emoji.emojize(":magnifying_glass_tilted_right: Scanning "))
|
||||
fs.store_default_resources()
|
||||
|
||||
platform_slugs = options.get("platforms", [])
|
||||
complete_rescan = options.get("rescan", False)
|
||||
selected_roms = options.get("roms", [])
|
||||
|
||||
# Run in worker if redis is available
|
||||
if redis_connectable:
|
||||
return high_prio_queue.enqueue(scan_platforms, platforms, complete_rescan)
|
||||
if redis_connectable and ENABLE_EXPERIMENTAL_REDIS:
|
||||
return high_prio_queue.enqueue(
|
||||
scan_platforms, platform_slugs, complete_rescan, selected_roms
|
||||
)
|
||||
else:
|
||||
await scan_platforms(platforms, complete_rescan)
|
||||
await scan_platforms(platform_slugs, complete_rescan, selected_roms)
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
from fastapi import APIRouter, Request
|
||||
import emoji
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from logger.logger import log
|
||||
from handler import igdbh
|
||||
from utils.oauth import protected_route
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.put("/search/roms/igdb", status_code=200)
|
||||
@protected_route(router.put, "/search/roms/igdb", ["roms.read"])
|
||||
async def search_rom_igdb(
|
||||
req: Request, search_term: str = "", search_by: str = ""
|
||||
request: Request, search_term: str = "", search_by: str = ""
|
||||
) -> dict:
|
||||
"""Get all the roms matched from igdb."""
|
||||
"""Search IGDB for ROMs"""
|
||||
|
||||
data: dict = await req.json()
|
||||
data: dict = await request.json()
|
||||
rom: dict = data["rom"]
|
||||
log.info(emoji.emojize(":magnifying_glass_tilted_right: IGDB Searching"))
|
||||
matched_roms: list = []
|
||||
|
||||
0
backend/endpoints/tests/__init__.py
Normal file
0
backend/endpoints/tests/__init__.py
Normal file
32
backend/endpoints/tests/conftest.py
Normal file
32
backend/endpoints/tests/conftest.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import pytest
|
||||
|
||||
from utils.oauth import create_oauth_token
|
||||
from datetime import timedelta
|
||||
from handler.tests.conftest import setup_database, clear_database, admin_user, editor_user, viewer_user, platform, rom # noqa
|
||||
from ..oauth import ACCESS_TOKEN_EXPIRE_MINUTES, REFRESH_TOKEN_EXPIRE_DAYS
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def access_token(admin_user): # noqa
|
||||
data = {
|
||||
"sub": admin_user.username,
|
||||
"scopes": " ".join(admin_user.oauth_scopes),
|
||||
"type": "access",
|
||||
}
|
||||
|
||||
return create_oauth_token(
|
||||
data=data, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def refresh_token(admin_user): # noqa
|
||||
data = {
|
||||
"sub": admin_user.username,
|
||||
"scopes": " ".join(admin_user.oauth_scopes),
|
||||
"type": "refresh",
|
||||
}
|
||||
|
||||
return create_oauth_token(
|
||||
data=data, expires_delta=timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
)
|
||||
11
backend/endpoints/tests/test_heartbeat.py
Normal file
11
backend/endpoints/tests/test_heartbeat.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_heartbeat():
|
||||
response = client.get("/heartbeat")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ROMM_AUTH_ENABLED": True}
|
||||
105
backend/endpoints/tests/test_identity.py
Normal file
105
backend/endpoints/tests/test_identity.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import base64
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from utils.cache import cache
|
||||
from models.user import Role
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
yield
|
||||
cache.flushall()
|
||||
|
||||
|
||||
def test_login_logout(admin_user):
|
||||
response = client.get("/login")
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
basic_auth = base64.b64encode(
|
||||
"test_admin:test_admin_password".encode("ascii")
|
||||
).decode("ascii")
|
||||
response = client.post("/login", headers={"Authorization": f"Basic {basic_auth}"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.cookies.get("session")
|
||||
assert response.json()["message"] == "Successfully logged in"
|
||||
|
||||
response = client.post("/logout")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Successfully logged out"
|
||||
|
||||
|
||||
def test_get_all_users(access_token):
|
||||
response = client.get("/users", headers={"Authorization": f"Bearer {access_token}"})
|
||||
assert response.status_code == 200
|
||||
|
||||
users = response.json()
|
||||
assert len(users) == 1
|
||||
assert users[0]["username"] == "test_admin"
|
||||
|
||||
|
||||
def test_get_current_user(access_token):
|
||||
response = client.get(
|
||||
"/users/me", headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
user = response.json()
|
||||
assert user["username"] == "test_admin"
|
||||
|
||||
|
||||
def test_get_user(access_token, editor_user):
|
||||
response = client.get(
|
||||
f"/users/{editor_user.id}", headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
user = response.json()
|
||||
assert user["username"] == "test_editor"
|
||||
|
||||
|
||||
def test_create_user(access_token):
|
||||
response = client.post(
|
||||
"/users",
|
||||
params={
|
||||
"username": "new_user",
|
||||
"password": "new_user_password",
|
||||
"role": "viewer",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
user = response.json()
|
||||
assert user["username"] == "new_user"
|
||||
assert user["role"] == "viewer"
|
||||
|
||||
|
||||
def test_update_user(access_token, editor_user):
|
||||
assert editor_user.role == Role.EDITOR
|
||||
|
||||
response = client.put(
|
||||
f"/users/{editor_user.id}",
|
||||
params={"username": "editor_user_new_username", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
user = response.json()
|
||||
assert user["role"] == "viewer"
|
||||
|
||||
|
||||
def test_delete_user(access_token, editor_user):
|
||||
response = client.delete(
|
||||
f"/users/{editor_user.id}", headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
assert body["message"] == "User successfully deleted"
|
||||
113
backend/endpoints/tests/test_oauth.py
Normal file
113
backend/endpoints/tests/test_oauth.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi.exceptions import HTTPException
|
||||
|
||||
from main import app
|
||||
from endpoints.oauth import ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
from utils.oauth import WRITE_SCOPES
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_refreshing_oauth_token(refresh_token):
|
||||
response = client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
assert body["access_token"]
|
||||
assert body["token_type"] == "bearer"
|
||||
assert body["expires"] == ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
|
||||
|
||||
def test_refreshing_oauth_token_without_refresh_token():
|
||||
try:
|
||||
client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
},
|
||||
)
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 400
|
||||
assert e.detail == "Missing refresh token"
|
||||
|
||||
|
||||
def test_refreshing_oauth_token_with_invalid_refresh_token():
|
||||
try:
|
||||
client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "invalid_token",
|
||||
},
|
||||
)
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 400
|
||||
assert e.detail == "Invalid refresh token"
|
||||
|
||||
|
||||
def test_auth_via_upass(admin_user):
|
||||
response = client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"username": "test_admin",
|
||||
"password": "test_admin_password",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
assert body["access_token"]
|
||||
assert body["refresh_token"]
|
||||
assert body["token_type"] == "bearer"
|
||||
assert body["expires"] == ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
|
||||
|
||||
def test_auth_via_upass_with_invalid_credentials(admin_user):
|
||||
try:
|
||||
client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"username": "test_admin",
|
||||
"password": "a_bad_password",
|
||||
},
|
||||
)
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 401
|
||||
assert e.detail == "Invalid username or password"
|
||||
|
||||
|
||||
def test_auth_via_upass_with_excess_scopes(viewer_user):
|
||||
try:
|
||||
client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"username": "test_viewer",
|
||||
"password": "test_viewer_password",
|
||||
"scopes": WRITE_SCOPES
|
||||
},
|
||||
)
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 403
|
||||
assert e.detail == "Insufficient scope"
|
||||
|
||||
|
||||
def test_auth_with_invalid_grant_type():
|
||||
try:
|
||||
client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "invalid_type",
|
||||
},
|
||||
)
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 400
|
||||
assert e.detail == "Invalid or unsupported grant type"
|
||||
18
backend/endpoints/tests/test_platform.py
Normal file
18
backend/endpoints/tests/test_platform.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_get_platforms(access_token, platform):
|
||||
response = client.get("/platforms")
|
||||
assert response.status_code == 403
|
||||
|
||||
response = client.get(
|
||||
"/platforms", headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
platforms = response.json()
|
||||
assert len(platforms) == 1
|
||||
55
backend/endpoints/tests/test_rom.py
Normal file
55
backend/endpoints/tests/test_rom.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch
|
||||
|
||||
from main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_get_rom(access_token, rom):
|
||||
response = client.get(
|
||||
f"/platforms/{rom.p_slug}/roms/{rom.id}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
assert body["id"] == rom.id
|
||||
|
||||
|
||||
def test_get_all_roms(access_token, rom):
|
||||
response = client.get(
|
||||
f"/platforms/{rom.p_slug}/roms",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
assert len(body["items"]) == 1
|
||||
assert body["items"][0]["id"] == rom.id
|
||||
|
||||
|
||||
@patch("utils.fs.rename_rom")
|
||||
def test_update_rom(rename_rom, access_token, rom):
|
||||
response = client.patch(
|
||||
f"/platforms/{rom.p_slug}/roms/{rom.id}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
json={"updatedRom": {"file_name": "new_file_name"}},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
assert body["rom"]["file_name"] == "new_file_name"
|
||||
|
||||
assert rename_rom.called
|
||||
|
||||
|
||||
def test_delete_roms(access_token, rom):
|
||||
response = client.delete(
|
||||
f"/platforms/{rom.p_slug}/roms/{rom.id}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
assert body["msg"] == f"{rom.file_name} deleted successfully!"
|
||||
0
backend/exceptions/__init__.py
Normal file
0
backend/exceptions/__init__.py
Normal file
17
backend/exceptions/credentials_exceptions.py
Normal file
17
backend/exceptions/credentials_exceptions.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
)
|
||||
|
||||
authentication_scheme_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication scheme",
|
||||
)
|
||||
|
||||
disabled_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Disabled user",
|
||||
)
|
||||
@@ -7,8 +7,7 @@ from sqlalchemy.exc import ProgrammingError
|
||||
|
||||
from logger.logger import log
|
||||
from config.config_loader import ConfigLoader
|
||||
from models.platform import Platform
|
||||
from models.rom import Rom
|
||||
from models import Platform, Rom, User, Role
|
||||
|
||||
|
||||
class DBHandler:
|
||||
@@ -35,7 +34,7 @@ class DBHandler:
|
||||
def add_platform(self, platform: Platform):
|
||||
try:
|
||||
with self.session.begin() as session:
|
||||
session.merge(platform)
|
||||
return session.merge(platform)
|
||||
except ProgrammingError as e:
|
||||
self.raise_error(e)
|
||||
|
||||
@@ -68,7 +67,7 @@ class DBHandler:
|
||||
def add_rom(self, rom: Rom):
|
||||
try:
|
||||
with self.session.begin() as session:
|
||||
session.merge(rom)
|
||||
return session.merge(rom)
|
||||
except ProgrammingError as e:
|
||||
self.raise_error(e)
|
||||
|
||||
@@ -136,3 +135,59 @@ class DBHandler:
|
||||
return rom.id if rom else 0
|
||||
except ProgrammingError as e:
|
||||
self.raise_error(e)
|
||||
|
||||
# ========= Users =========
|
||||
def add_user(self, user: User):
|
||||
try:
|
||||
with self.session.begin() as session:
|
||||
return session.merge(user)
|
||||
except ProgrammingError as e:
|
||||
self.raise_error(e)
|
||||
|
||||
def get_user_by_username(self, username: str):
|
||||
try:
|
||||
with self.session.begin() as session:
|
||||
return session.scalars(
|
||||
select(User).filter_by(username=username)
|
||||
).first()
|
||||
except ProgrammingError as e:
|
||||
self.raise_error(e)
|
||||
|
||||
def get_user(self, id: int):
|
||||
try:
|
||||
with self.session.begin() as session:
|
||||
return session.get(User, id)
|
||||
except ProgrammingError as e:
|
||||
self.raise_error(e)
|
||||
|
||||
def update_user(self, id: int, data: dict):
|
||||
try:
|
||||
with self.session.begin() as session:
|
||||
session.query(User).filter(User.id == id).update(
|
||||
data, synchronize_session="evaluate"
|
||||
)
|
||||
except ProgrammingError as e:
|
||||
self.raise_error(e)
|
||||
|
||||
def delete_user(self, id: int):
|
||||
try:
|
||||
with self.session.begin() as session:
|
||||
session.query(User).filter(User.id == id).delete(
|
||||
synchronize_session="evaluate"
|
||||
)
|
||||
except ProgrammingError as e:
|
||||
self.raise_error(e)
|
||||
|
||||
def get_users(self):
|
||||
try:
|
||||
with self.session.begin() as session:
|
||||
return session.scalars(select(User)).all()
|
||||
except ProgrammingError as e:
|
||||
self.raise_error(e)
|
||||
|
||||
def get_admin_users(self):
|
||||
try:
|
||||
with self.session.begin() as session:
|
||||
return session.scalars(select(User).filter_by(role=Role.ADMIN)).all()
|
||||
except ProgrammingError as e:
|
||||
self.raise_error(e)
|
||||
|
||||
@@ -8,25 +8,26 @@ import os
|
||||
import json
|
||||
from unidecode import unidecode as uc
|
||||
from requests.exceptions import HTTPError, Timeout
|
||||
from typing import Final
|
||||
|
||||
from config import CLIENT_ID, CLIENT_SECRET
|
||||
from utils import get_file_name_with_no_tags as get_search_term
|
||||
from logger.logger import log
|
||||
from utils.cache import cache
|
||||
|
||||
MAIN_GAME_CATEGORY = 0
|
||||
EXPANDED_GAME_CATEGORY = 10
|
||||
N_SCREENSHOTS = 5
|
||||
PS2_IGDB_ID = 8
|
||||
SWITCH_IGDB_ID = 130
|
||||
MAIN_GAME_CATEGORY: Final = 0
|
||||
EXPANDED_GAME_CATEGORY: Final = 10
|
||||
N_SCREENSHOTS: Final = 5
|
||||
PS2_IGDB_ID: Final = 8
|
||||
SWITCH_IGDB_ID: Final = 130
|
||||
|
||||
ps2_opl_regex = r"^([A-Z]{4}_\d{3}\.\d{2})\..*$"
|
||||
ps2_opl_index_file = os.path.join(
|
||||
PS2_OPL_REGEX: Final = r"^([A-Z]{4}_\d{3}\.\d{2})\..*$"
|
||||
PS2_OPL_INDEX_FILE: Final = os.path.join(
|
||||
os.path.dirname(__file__), "fixtures", "ps2_opl_index.json"
|
||||
)
|
||||
|
||||
switch_titledb_regex = r"^(70[0-9]{12})$"
|
||||
switch_titledb_index_file = os.path.join(
|
||||
SWITCH_TITLEDB_REGEX: Final = r"^(70[0-9]{12})$"
|
||||
SWITCH_TITLEDB_INDEX_FILE: Final = os.path.join(
|
||||
os.path.dirname(__file__), "fixtures", "switch_titledb.json"
|
||||
)
|
||||
|
||||
@@ -129,23 +130,23 @@ class IGDBHandler:
|
||||
def get_rom(self, file_name: str, p_igdb_id: int):
|
||||
search_term = get_search_term(file_name)
|
||||
|
||||
# Patch support for PS2 OPL filename format
|
||||
match = re.match(ps2_opl_regex, search_term)
|
||||
# Patch support for PS2 OPL flename format
|
||||
match = re.match(PS2_OPL_REGEX, search_term)
|
||||
if p_igdb_id == PS2_IGDB_ID and match:
|
||||
serial_code = match.group(1)
|
||||
|
||||
with open(ps2_opl_index_file, "r") as index_json:
|
||||
with open(PS2_OPL_INDEX_FILE, "r") as index_json:
|
||||
opl_index = json.loads(index_json.read())
|
||||
index_entry = opl_index.get(serial_code, None)
|
||||
if index_entry:
|
||||
search_term = index_entry["Name"] # type: ignore
|
||||
|
||||
# Patch support for switch titleID filename format
|
||||
match = re.match(switch_titledb_regex, search_term)
|
||||
match = re.match(SWITCH_TITLEDB_REGEX, search_term)
|
||||
if p_igdb_id == SWITCH_IGDB_ID and match:
|
||||
title_id = match.group(1)
|
||||
|
||||
with open(switch_titledb_index_file, "r") as index_json:
|
||||
with open(SWITCH_TITLEDB_INDEX_FILE, "r") as index_json:
|
||||
titledb_index = json.loads(index_json.read())
|
||||
index_entry = titledb_index.get(title_id, None)
|
||||
if index_entry:
|
||||
@@ -246,8 +247,8 @@ class TwitchAuth:
|
||||
sys.exit(2)
|
||||
|
||||
# Set token in redis to expire in <expires_in> seconds
|
||||
cache.set("twitch_token", token, ex=expires_in - 10) # type: ignore
|
||||
cache.set("twitch_token_expires_at", time.time() + expires_in - 10) # type: ignore
|
||||
cache.set("romm:twitch_token", token, ex=expires_in - 10) # type: ignore[attr-defined]
|
||||
cache.set("romm:twitch_token_expires_at", time.time() + expires_in - 10) # type: ignore[attr-defined]
|
||||
|
||||
log.info("Twitch token fetched!")
|
||||
|
||||
@@ -259,8 +260,8 @@ class TwitchAuth:
|
||||
return "test_token"
|
||||
|
||||
# Fetch the token cache
|
||||
token = cache.get("twitch_token") # type: ignore
|
||||
token_expires_at = cache.get("twitch_token_expires_at") # type: ignore
|
||||
token = cache.get("romm:twitch_token") # type: ignore[attr-defined]
|
||||
token_expires_at = cache.get("romm:twitch_token_expires_at") # type: ignore[attr-defined]
|
||||
|
||||
if not token or time.time() > float(token_expires_at or 0):
|
||||
log.warning("Twitch token invalid: fetching a new one...")
|
||||
|
||||
@@ -4,8 +4,10 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.config_loader import ConfigLoader
|
||||
from models.platform import Platform
|
||||
from models.rom import Rom
|
||||
from models import Platform, Rom, User
|
||||
from models.user import Role
|
||||
from utils.auth import get_password_hash
|
||||
from .. import dbh
|
||||
|
||||
engine = create_engine(ConfigLoader.get_db_engine(), pool_pre_ping=True)
|
||||
session = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
@@ -21,6 +23,7 @@ def clear_database():
|
||||
with session.begin() as s:
|
||||
s.query(Platform).delete(synchronize_session="evaluate")
|
||||
s.query(Rom).delete(synchronize_session="evaluate")
|
||||
s.query(User).delete(synchronize_session="evaluate")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -28,9 +31,7 @@ def platform():
|
||||
platform = Platform(
|
||||
name="test_platform", slug="test_platform_slug", fs_slug="test_platform"
|
||||
)
|
||||
with session.begin() as s:
|
||||
s.merge(platform)
|
||||
return platform
|
||||
return dbh.add_platform(platform)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -44,6 +45,34 @@ def rom(platform):
|
||||
file_name_no_tags="test_rom",
|
||||
file_path="test_platform_slug/roms",
|
||||
)
|
||||
with session.begin() as s:
|
||||
s.merge(rom)
|
||||
return rom
|
||||
return dbh.add_rom(rom)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user():
|
||||
user = User(
|
||||
username="test_admin",
|
||||
hashed_password=get_password_hash("test_admin_password"),
|
||||
role=Role.ADMIN,
|
||||
)
|
||||
return dbh.add_user(user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editor_user():
|
||||
user = User(
|
||||
username="test_editor",
|
||||
hashed_password=get_password_hash("test_editor_password"),
|
||||
role=Role.EDITOR,
|
||||
)
|
||||
return dbh.add_user(user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def viewer_user():
|
||||
user = User(
|
||||
username="test_viewer",
|
||||
hashed_password=get_password_hash("test_viewer_password"),
|
||||
role=Role.VIEWER,
|
||||
)
|
||||
return dbh.add_user(user)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from handler.db_handler import DBHandler
|
||||
from models.platform import Platform
|
||||
from models.rom import Rom
|
||||
from models import Platform, Rom, User
|
||||
from models.user import Role
|
||||
from utils.auth import get_password_hash
|
||||
|
||||
dbh = DBHandler()
|
||||
|
||||
@@ -62,3 +65,41 @@ def test_utils(rom):
|
||||
with dbh.session.begin() as session:
|
||||
roms = session.scalars(dbh.get_roms("test_platform_slug")).all()
|
||||
assert dbh.rom_exists("test_platform_slug", "test_rom") == roms[0].id
|
||||
|
||||
|
||||
def test_users(admin_user):
|
||||
dbh.add_user(
|
||||
User(
|
||||
username="new_user",
|
||||
hashed_password=get_password_hash("new_password"),
|
||||
)
|
||||
)
|
||||
|
||||
all_users = dbh.get_users()
|
||||
assert len(all_users) == 2
|
||||
|
||||
new_user = dbh.get_user_by_username("new_user")
|
||||
assert new_user.username == "new_user"
|
||||
assert new_user.role == Role.VIEWER
|
||||
assert new_user.enabled
|
||||
|
||||
dbh.update_user(new_user.id, {"role": Role.EDITOR})
|
||||
|
||||
new_user = dbh.get_user(new_user.id)
|
||||
assert new_user.role == Role.EDITOR
|
||||
|
||||
dbh.delete_user(new_user.id)
|
||||
|
||||
all_users = dbh.get_users()
|
||||
assert len(all_users) == 1
|
||||
|
||||
try:
|
||||
new_user = dbh.add_user(
|
||||
User(
|
||||
username="test_admin",
|
||||
hashed_password=get_password_hash("new_password"),
|
||||
role=Role.ADMIN,
|
||||
)
|
||||
)
|
||||
except IntegrityError as e:
|
||||
assert "Duplicate entry 'test_admin' for key" in str(e)
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import uvicorn
|
||||
import alembic.config
|
||||
import re
|
||||
import sys
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi_pagination import add_pagination
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from config import DEV_PORT, DEV_HOST
|
||||
from endpoints import search, platform, rom, scan # noqa
|
||||
from config import DEV_PORT, DEV_HOST, ROMM_AUTH_SECRET_KEY, ROMM_AUTH_ENABLED
|
||||
from endpoints import search, platform, rom, identity, oauth, scan # noqa
|
||||
from handler import dbh
|
||||
from utils.socket import socket_app
|
||||
from utils.auth import (
|
||||
HybridAuthBackend,
|
||||
CustomCSRFMiddleware,
|
||||
create_default_admin_user,
|
||||
)
|
||||
|
||||
app = FastAPI(title="RomM API", version="0.1.0")
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -16,18 +27,52 @@ app.add_middleware(
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(search.router)
|
||||
|
||||
if ROMM_AUTH_ENABLED and "pytest" not in sys.modules:
|
||||
# CSRF protection (except endpoints listed in exempt_urls)
|
||||
app.add_middleware(
|
||||
CustomCSRFMiddleware,
|
||||
secret=ROMM_AUTH_SECRET_KEY,
|
||||
exempt_urls=[re.compile(r"^/token.*"), re.compile(r"^/ws")],
|
||||
)
|
||||
|
||||
# Handles both basic and oauth authentication
|
||||
app.add_middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=HybridAuthBackend(),
|
||||
)
|
||||
|
||||
# Enables support for sessions on requests
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=ROMM_AUTH_SECRET_KEY,
|
||||
same_site="strict",
|
||||
https_only=False,
|
||||
)
|
||||
|
||||
app.include_router(oauth.router)
|
||||
app.include_router(identity.router)
|
||||
app.include_router(platform.router)
|
||||
app.include_router(rom.router)
|
||||
app.include_router(search.router)
|
||||
|
||||
add_pagination(app)
|
||||
app.mount("/ws", socket_app)
|
||||
|
||||
|
||||
# Endpoint to set the CSRF token in cache
|
||||
@app.get("/heartbeat")
|
||||
def heartbeat():
|
||||
return {"ROMM_AUTH_ENABLED": ROMM_AUTH_ENABLED}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
"""Startup application."""
|
||||
pass
|
||||
|
||||
# Create default admin user if no admin user exists
|
||||
if len(dbh.get_admin_users()) == 0:
|
||||
create_default_admin_user()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .platform import Platform # noqa[401]
|
||||
from .rom import Rom # noqa[401]
|
||||
from .user import User, Role # noqa[401]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from sqlalchemy import Column, String, Integer
|
||||
|
||||
from config import DEFAULT_PATH_COVER_S
|
||||
from models.base import BaseModel
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class Platform(BaseModel):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from sqlalchemy import Integer, Column, String, Text, Boolean, Float, JSON
|
||||
|
||||
from config import DEFAULT_PATH_COVER_S, DEFAULT_PATH_COVER_L, FRONT_LIBRARY_PATH
|
||||
from models.base import BaseModel
|
||||
from .base import BaseModel
|
||||
|
||||
class Rom(BaseModel):
|
||||
__tablename__ = "roms"
|
||||
|
||||
@@ -1 +1 @@
|
||||
from handler.tests.conftest import rom, platform
|
||||
from handler.tests.conftest import setup_database, clear_database, rom, platform, admin_user, editor_user, viewer_user # noqa
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from models.rom import Rom
|
||||
|
||||
def test_rom(rom):
|
||||
assert rom.file_path == "test_platform_slug/roms"
|
||||
assert rom.full_path == "test_platform_slug/roms/test_rom"
|
||||
|
||||
10
backend/models/tests/test_user.py
Normal file
10
backend/models/tests/test_user.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from utils.oauth import DEFAULT_SCOPES, WRITE_SCOPES, FULL_SCOPES
|
||||
|
||||
def test_admin(admin_user):
|
||||
admin_user.oauth_scopes == FULL_SCOPES
|
||||
|
||||
def test_editor(editor_user):
|
||||
editor_user.oauth_scopes == WRITE_SCOPES
|
||||
|
||||
def test_user(viewer_user):
|
||||
viewer_user.oauth_scopes == DEFAULT_SCOPES
|
||||
32
backend/models/user.py
Normal file
32
backend/models/user.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import enum
|
||||
from sqlalchemy import Column, String, Boolean, Integer, Enum
|
||||
from starlette.authentication import SimpleUser
|
||||
|
||||
from .base import BaseModel
|
||||
from utils.oauth import DEFAULT_SCOPES, WRITE_SCOPES, FULL_SCOPES
|
||||
|
||||
|
||||
class Role(enum.Enum):
|
||||
VIEWER = "viewer"
|
||||
EDITOR = "editor"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
class User(BaseModel, SimpleUser):
|
||||
__tablename__ = "users"
|
||||
id = Column(Integer(), primary_key=True, autoincrement=True)
|
||||
username: str = Column(String(length=255), unique=True, index=True)
|
||||
hashed_password: str = Column(String(length=255))
|
||||
enabled: bool = Column(Boolean(), default=True)
|
||||
role: Role = Column(Enum(Role), default=Role.VIEWER)
|
||||
avatar_path: str = Column(String(length=255), default="")
|
||||
|
||||
@property
|
||||
def oauth_scopes(self):
|
||||
if self.role == Role.ADMIN:
|
||||
return FULL_SCOPES
|
||||
|
||||
if self.role == Role.EDITOR:
|
||||
return WRITE_SCOPES
|
||||
|
||||
return DEFAULT_SCOPES
|
||||
154
backend/utils/auth.py
Normal file
154
backend/utils/auth.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import HTTPException, status, Request
|
||||
from fastapi.security.http import HTTPBasic
|
||||
from passlib.context import CryptContext
|
||||
from starlette.requests import HTTPConnection
|
||||
from starlette_csrf.middleware import CSRFMiddleware
|
||||
from starlette.types import Receive, Scope, Send
|
||||
from starlette.authentication import (
|
||||
AuthCredentials,
|
||||
AuthenticationBackend,
|
||||
)
|
||||
|
||||
from handler import dbh
|
||||
from utils.cache import cache
|
||||
from models.user import User, Role
|
||||
from config import (
|
||||
ROMM_AUTH_ENABLED,
|
||||
ROMM_AUTH_USERNAME,
|
||||
ROMM_AUTH_PASSWORD,
|
||||
)
|
||||
|
||||
from .oauth import (
|
||||
FULL_SCOPES,
|
||||
get_current_active_user_from_bearer_token,
|
||||
)
|
||||
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def authenticate_user(username: str, password: str):
|
||||
user = dbh.get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def clear_session(req: HTTPConnection | Request):
|
||||
session_id = req.session.get("session_id")
|
||||
if session_id:
|
||||
cache.delete(f"romm:{session_id}") # type: ignore[attr-defined]
|
||||
req.session["session_id"] = None
|
||||
|
||||
|
||||
async def get_current_active_user_from_session(conn: HTTPConnection):
|
||||
# Check if session key already stored in cache
|
||||
session_id = conn.session.get("session_id")
|
||||
if not session_id:
|
||||
return None
|
||||
|
||||
username = cache.get(f"romm:{session_id}") # type: ignore[attr-defined]
|
||||
if not username:
|
||||
return None
|
||||
|
||||
# Key exists therefore user is probably authenticated
|
||||
user = dbh.get_user_by_username(username)
|
||||
if user is None:
|
||||
clear_session(conn)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
if not user.enabled:
|
||||
clear_session(conn)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def create_default_admin_user():
|
||||
if not ROMM_AUTH_ENABLED:
|
||||
return
|
||||
|
||||
try:
|
||||
dbh.add_user(
|
||||
User(
|
||||
username=ROMM_AUTH_USERNAME,
|
||||
hashed_password=get_password_hash(ROMM_AUTH_PASSWORD),
|
||||
role=Role.ADMIN,
|
||||
)
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
|
||||
class HybridAuthBackend(AuthenticationBackend):
|
||||
async def authenticate(self, conn: HTTPConnection):
|
||||
if not ROMM_AUTH_ENABLED:
|
||||
return (AuthCredentials(FULL_SCOPES), None)
|
||||
|
||||
# Check if session key already stored in cache
|
||||
user = await get_current_active_user_from_session(conn)
|
||||
if user:
|
||||
return (AuthCredentials(user.oauth_scopes), user)
|
||||
|
||||
# Check if Authorization header exists
|
||||
if "Authorization" not in conn.headers:
|
||||
return (AuthCredentials([]), None)
|
||||
|
||||
scheme, token = conn.headers["Authorization"].split()
|
||||
|
||||
# Check if basic auth header is valid
|
||||
if scheme.lower() == "basic":
|
||||
credentials = await HTTPBasic().__call__(conn) # type: ignore[arg-type]
|
||||
if not credentials:
|
||||
return (AuthCredentials([]), None)
|
||||
|
||||
user = authenticate_user(credentials.username, credentials.password)
|
||||
if user is None:
|
||||
return (AuthCredentials([]), None)
|
||||
|
||||
return (AuthCredentials(user.oauth_scopes), user)
|
||||
|
||||
# Check if bearer auth header is valid
|
||||
if scheme.lower() == "bearer":
|
||||
user, payload = await get_current_active_user_from_bearer_token(token)
|
||||
|
||||
# Only access tokens can request resources
|
||||
if payload.get("type") != "access":
|
||||
return (AuthCredentials([]), None)
|
||||
|
||||
# Only grant access to resources with overlapping scopes
|
||||
token_scopes = set(list(payload.get("scopes").split(" ")))
|
||||
overlapping_scopes = list(token_scopes & set(user.oauth_scopes))
|
||||
|
||||
return (AuthCredentials(overlapping_scopes), user)
|
||||
|
||||
return (AuthCredentials([]), None)
|
||||
|
||||
|
||||
class CustomCSRFMiddleware(CSRFMiddleware):
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
await super().__call__(scope, receive, send)
|
||||
@@ -4,8 +4,7 @@ from typing import Any
|
||||
from handler import igdbh
|
||||
from utils import fs, parse_tags, get_file_extension, get_file_name_with_no_tags
|
||||
from config.config_loader import config
|
||||
from models.platform import Platform
|
||||
from models.rom import Rom
|
||||
from models import Platform, Rom
|
||||
from logger.logger import log
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from config import (
|
||||
DEFAULT_PATH_COVER_S,
|
||||
)
|
||||
from config.config_loader import config
|
||||
from utils.exceptions import (
|
||||
from exceptions.fs_exceptions import (
|
||||
PlatformsNotFoundException,
|
||||
RomsNotFoundException,
|
||||
RomNotFoundError,
|
||||
@@ -300,3 +300,10 @@ def remove_rom(p_slug: str, file_name: str):
|
||||
shutil.rmtree(f"{LIBRARY_BASE_PATH}/{rom_path}/{file_name}")
|
||||
except FileNotFoundError as exc:
|
||||
raise RomNotFoundError(file_name, p_slug) from exc
|
||||
|
||||
|
||||
# ========= Users utils =========
|
||||
def build_avatar_path(avatar_path, username):
|
||||
avatar_user_path = f"{RESOURCES_BASE_PATH}/users/{username}"
|
||||
Path(avatar_user_path).mkdir(parents=True, exist_ok=True)
|
||||
return f"users/{username}/{avatar_path}", avatar_user_path
|
||||
|
||||
132
backend/utils/oauth.py
Normal file
132
backend/utils/oauth.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Final, Any
|
||||
from jose import JWTError, jwt
|
||||
from fastapi import HTTPException, status, Security
|
||||
from fastapi.param_functions import Form
|
||||
from fastapi.security.oauth2 import OAuth2PasswordBearer
|
||||
from fastapi.security.http import HTTPBasic
|
||||
from fastapi.types import DecoratedCallable
|
||||
from starlette.authentication import requires
|
||||
|
||||
from config import ROMM_AUTH_SECRET_KEY
|
||||
|
||||
ALGORITHM: Final = "HS256"
|
||||
DEFAULT_OAUTH_TOKEN_EXPIRY: Final = 15
|
||||
|
||||
DEFAULT_SCOPES_MAP: Final = {
|
||||
"me.read": "View your profile",
|
||||
"me.write": "Modify your profile",
|
||||
"roms.read": "View ROMs",
|
||||
"platforms.read": "View platforms",
|
||||
}
|
||||
|
||||
WRITE_SCOPES_MAP: Final = {
|
||||
"roms.write": "Modify ROMs",
|
||||
"platforms.write": "Modify platforms",
|
||||
}
|
||||
|
||||
FULL_SCOPES_MAP: Final = {
|
||||
"users.read": "View users",
|
||||
"users.write": "Modify users",
|
||||
}
|
||||
|
||||
DEFAULT_SCOPES: Final = list(DEFAULT_SCOPES_MAP.keys())
|
||||
WRITE_SCOPES: Final = DEFAULT_SCOPES + list(WRITE_SCOPES_MAP.keys())
|
||||
FULL_SCOPES: Final = WRITE_SCOPES + list(FULL_SCOPES_MAP.keys())
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def create_oauth_token(data: dict, expires_delta: timedelta | None = None):
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=DEFAULT_OAUTH_TOKEN_EXPIRY)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
|
||||
return jwt.encode(to_encode, ROMM_AUTH_SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
async def get_current_active_user_from_bearer_token(token: str):
|
||||
from handler import dbh
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, ROMM_AUTH_SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
username = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
|
||||
user = dbh.get_user_by_username(username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
if not user.enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user"
|
||||
)
|
||||
|
||||
return user, payload
|
||||
|
||||
|
||||
class OAuth2RequestForm:
|
||||
def __init__(
|
||||
self,
|
||||
grant_type: str = Form(default="password"),
|
||||
scope: str = Form(default=""),
|
||||
username: Optional[str] = Form(default=None),
|
||||
password: Optional[str] = Form(default=None),
|
||||
client_id: Optional[str] = Form(default=None),
|
||||
client_secret: Optional[str] = Form(default=None),
|
||||
refresh_token: Optional[str] = Form(default=None),
|
||||
):
|
||||
self.grant_type = grant_type
|
||||
self.scopes = scope.split()
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.refresh_token = refresh_token
|
||||
|
||||
|
||||
oauth2_password_bearer = OAuth2PasswordBearer(
|
||||
tokenUrl="/token",
|
||||
auto_error=False,
|
||||
scopes={
|
||||
**DEFAULT_SCOPES_MAP,
|
||||
**WRITE_SCOPES_MAP,
|
||||
**FULL_SCOPES_MAP,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def protected_route(
|
||||
method: Any,
|
||||
path: str,
|
||||
scopes: list[str] = [],
|
||||
**kwargs,
|
||||
):
|
||||
def decorator(func: DecoratedCallable):
|
||||
fn = requires(scopes)(func)
|
||||
return method(
|
||||
path,
|
||||
dependencies=[
|
||||
Security(
|
||||
dependency=oauth2_password_bearer,
|
||||
scopes=scopes,
|
||||
),
|
||||
Security(dependency=HTTPBasic(auto_error=False)),
|
||||
],
|
||||
**kwargs,
|
||||
)(fn)
|
||||
|
||||
return decorator
|
||||
1
backend/utils/tests/conftest.py
Normal file
1
backend/utils/tests/conftest.py
Normal file
@@ -0,0 +1 @@
|
||||
from handler.tests.conftest import setup_database, clear_database, admin_user, editor_user, viewer_user, clear_database # noqa
|
||||
275
backend/utils/tests/test_auth.py
Normal file
275
backend/utils/tests/test_auth.py
Normal file
@@ -0,0 +1,275 @@
|
||||
import pytest
|
||||
from base64 import b64encode
|
||||
from fastapi.exceptions import HTTPException
|
||||
|
||||
from models import User
|
||||
from handler import dbh
|
||||
from ..auth import (
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
authenticate_user,
|
||||
get_current_active_user_from_session,
|
||||
create_default_admin_user,
|
||||
HybridAuthBackend,
|
||||
)
|
||||
from ..oauth import WRITE_SCOPES, create_oauth_token
|
||||
from ..cache import cache
|
||||
|
||||
|
||||
def test_verify_password():
|
||||
assert verify_password("password", get_password_hash("password"))
|
||||
assert not verify_password("password", get_password_hash("notpassword"))
|
||||
|
||||
|
||||
def test_authenticate_user(admin_user):
|
||||
current_user = authenticate_user("test_admin", "test_admin_password")
|
||||
|
||||
assert current_user
|
||||
assert current_user.id == admin_user.id
|
||||
|
||||
|
||||
async def test_get_current_active_user_from_session(editor_user):
|
||||
session_id = "test_session_id"
|
||||
cache.set(f"romm:{session_id}", editor_user.username)
|
||||
|
||||
class MockConnection:
|
||||
def __init__(self):
|
||||
self.session = {"session_id": session_id}
|
||||
|
||||
conn = MockConnection()
|
||||
current_user = await get_current_active_user_from_session(conn)
|
||||
|
||||
assert current_user
|
||||
assert isinstance(current_user, User)
|
||||
assert current_user.id == editor_user.id
|
||||
|
||||
|
||||
async def test_get_current_active_user_from_session_bad_session_key(editor_user):
|
||||
cache.set("romm:test_session_id", editor_user.username)
|
||||
|
||||
class MockConnection:
|
||||
def __init__(self):
|
||||
self.session = {"session_id": "not_real_test_session_id"}
|
||||
self.headers = {}
|
||||
|
||||
conn = MockConnection()
|
||||
current_user = await get_current_active_user_from_session(conn)
|
||||
|
||||
assert not current_user
|
||||
|
||||
|
||||
async def test_get_current_active_user_from_session_bad_username(editor_user):
|
||||
session_id = "test_session_id"
|
||||
cache.set(f"romm:{session_id}", "not_real_username")
|
||||
|
||||
class MockConnection:
|
||||
def __init__(self):
|
||||
self.session = {"session_id": session_id}
|
||||
self.headers = {}
|
||||
|
||||
conn = MockConnection()
|
||||
|
||||
try:
|
||||
await get_current_active_user_from_session(conn)
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 403
|
||||
assert e.detail == "User not found"
|
||||
|
||||
|
||||
async def test_get_current_active_user_from_session_disabled_user(editor_user):
|
||||
session_id = "test_session_id"
|
||||
cache.set(f"romm:{session_id}", editor_user.username)
|
||||
|
||||
class MockConnection:
|
||||
def __init__(self):
|
||||
self.session = {"session_id": session_id}
|
||||
self.headers = {}
|
||||
|
||||
conn = MockConnection()
|
||||
|
||||
dbh.update_user(editor_user.id, {"enabled": False})
|
||||
|
||||
try:
|
||||
await get_current_active_user_from_session(conn)
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 403
|
||||
assert e.detail == "Inactive user"
|
||||
|
||||
|
||||
def test_create_default_admin_user():
|
||||
create_default_admin_user()
|
||||
|
||||
user = dbh.get_user_by_username("test_admin")
|
||||
assert user.username == "test_admin"
|
||||
assert verify_password("test_admin_password", user.hashed_password)
|
||||
|
||||
users = dbh.get_users()
|
||||
assert len(users) == 1
|
||||
|
||||
create_default_admin_user()
|
||||
|
||||
users = dbh.get_users()
|
||||
assert len(users) == 1
|
||||
|
||||
|
||||
async def test_hybrid_auth_backend_session(editor_user):
|
||||
session_id = "test_session_id"
|
||||
cache.set(f"romm:{session_id}", editor_user.username)
|
||||
|
||||
class MockConnection:
|
||||
def __init__(self):
|
||||
self.session = {"session_id": session_id}
|
||||
|
||||
backend = HybridAuthBackend()
|
||||
conn = MockConnection()
|
||||
|
||||
creds, user = await backend.authenticate(conn)
|
||||
|
||||
assert user.id == editor_user.id
|
||||
assert creds.scopes == editor_user.oauth_scopes
|
||||
assert creds.scopes == WRITE_SCOPES
|
||||
|
||||
|
||||
async def test_hybrid_auth_backend_empty_session_and_headers(editor_user):
|
||||
class MockConnection:
|
||||
def __init__(self):
|
||||
self.session = {}
|
||||
self.headers = {}
|
||||
|
||||
backend = HybridAuthBackend()
|
||||
conn = MockConnection()
|
||||
|
||||
creds, user = await backend.authenticate(conn)
|
||||
|
||||
assert not user
|
||||
assert creds.scopes == []
|
||||
|
||||
|
||||
async def test_hybrid_auth_backend_bearer_auth_header(editor_user):
|
||||
access_token = create_oauth_token(
|
||||
data={
|
||||
"sub": editor_user.username,
|
||||
"scopes": " ".join(editor_user.oauth_scopes),
|
||||
"type": "access",
|
||||
},
|
||||
)
|
||||
|
||||
class MockConnection:
|
||||
def __init__(self):
|
||||
self.session = {}
|
||||
self.headers = {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
backend = HybridAuthBackend()
|
||||
conn = MockConnection()
|
||||
|
||||
creds, user = await backend.authenticate(conn)
|
||||
|
||||
assert user.id == editor_user.id
|
||||
assert set(creds.scopes).issubset(editor_user.oauth_scopes)
|
||||
|
||||
|
||||
async def test_hybrid_auth_backend_bearer_invalid_token(editor_user):
|
||||
class MockConnection:
|
||||
def __init__(self):
|
||||
self.session = {}
|
||||
self.headers = {"Authorization": "Bearer invalid_token"}
|
||||
|
||||
backend = HybridAuthBackend()
|
||||
conn = MockConnection()
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
await backend.authenticate(conn)
|
||||
|
||||
|
||||
async def test_hybrid_auth_backend_basic_auth_header(editor_user):
|
||||
token = b64encode("test_editor:test_editor_password".encode()).decode()
|
||||
|
||||
class MockConnection:
|
||||
def __init__(self):
|
||||
self.session = {}
|
||||
self.headers = {"Authorization": f"Basic {token}"}
|
||||
|
||||
backend = HybridAuthBackend()
|
||||
conn = MockConnection()
|
||||
|
||||
creds, user = await backend.authenticate(conn)
|
||||
|
||||
assert user.id == editor_user.id
|
||||
assert creds.scopes == WRITE_SCOPES
|
||||
assert set(creds.scopes).issubset(editor_user.oauth_scopes)
|
||||
|
||||
|
||||
async def test_hybrid_auth_backend_basic_auth_header_unencoded(editor_user):
|
||||
class MockConnection:
|
||||
def __init__(self):
|
||||
self.session = {}
|
||||
self.headers = {"Authorization": "Basic test_editor:test_editor_password"}
|
||||
|
||||
backend = HybridAuthBackend()
|
||||
conn = MockConnection()
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
await backend.authenticate(conn)
|
||||
|
||||
|
||||
async def test_hybrid_auth_backend_invalid_scheme():
|
||||
class MockConnection:
|
||||
def __init__(self):
|
||||
self.session = {}
|
||||
self.headers = {"Authorization": "Some invalid_scheme"}
|
||||
|
||||
backend = HybridAuthBackend()
|
||||
conn = MockConnection()
|
||||
|
||||
creds, user = await backend.authenticate(conn)
|
||||
|
||||
assert not user
|
||||
assert creds.scopes == []
|
||||
|
||||
|
||||
async def test_hybrid_auth_backend_with_refresh_token(editor_user):
|
||||
refresh_token = create_oauth_token(
|
||||
data={
|
||||
"sub": editor_user.username,
|
||||
"scopes": " ".join(editor_user.oauth_scopes),
|
||||
"type": "refresh",
|
||||
},
|
||||
)
|
||||
|
||||
class MockConnection:
|
||||
def __init__(self):
|
||||
self.session = {}
|
||||
self.headers = {"Authorization": f"Bearer {refresh_token}"}
|
||||
|
||||
backend = HybridAuthBackend()
|
||||
conn = MockConnection()
|
||||
|
||||
creds, user = await backend.authenticate(conn)
|
||||
|
||||
assert not user
|
||||
assert creds.scopes == []
|
||||
|
||||
|
||||
async def test_hybrid_auth_backend_scope_subset(editor_user):
|
||||
scopes = editor_user.oauth_scopes[:3]
|
||||
access_token = create_oauth_token(
|
||||
data={
|
||||
"sub": editor_user.username,
|
||||
"scopes": " ".join(scopes),
|
||||
"type": "access",
|
||||
},
|
||||
)
|
||||
|
||||
class MockConnection:
|
||||
def __init__(self):
|
||||
self.session = {}
|
||||
self.headers = {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
backend = HybridAuthBackend()
|
||||
conn = MockConnection()
|
||||
|
||||
creds, user = await backend.authenticate(conn)
|
||||
|
||||
assert user.id == editor_user.id
|
||||
assert set(creds.scopes).issubset(editor_user.oauth_scopes)
|
||||
assert set(creds.scopes).issubset(scopes)
|
||||
@@ -1,9 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from utils.fastapi import scan_platform, scan_rom
|
||||
from utils.exceptions import RomsNotFoundException
|
||||
from models.platform import Platform
|
||||
from models.rom import Rom
|
||||
from ..fastapi import scan_platform, scan_rom
|
||||
from exceptions.fs_exceptions import RomsNotFoundException
|
||||
from models import Platform, Rom
|
||||
|
||||
|
||||
@pytest.mark.vcr()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from utils.fs import (
|
||||
from ..fs import (
|
||||
get_cover,
|
||||
get_platforms,
|
||||
get_roms_structure,
|
||||
|
||||
74
backend/utils/tests/test_oauth.py
Normal file
74
backend/utils/tests/test_oauth.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import pytest
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
|
||||
from handler import dbh
|
||||
from ..oauth import (
|
||||
create_oauth_token,
|
||||
get_current_active_user_from_bearer_token,
|
||||
protected_route,
|
||||
)
|
||||
|
||||
|
||||
def test_create_oauth_token():
|
||||
token = create_oauth_token({"sub": "test_user"})
|
||||
|
||||
assert isinstance(token, str)
|
||||
|
||||
|
||||
async def test_get_current_active_user_from_bearer_token(admin_user):
|
||||
token = create_oauth_token(
|
||||
{
|
||||
"sub": admin_user.username,
|
||||
"scopes": " ".join(admin_user.oauth_scopes),
|
||||
"type": "access",
|
||||
},
|
||||
)
|
||||
user, payload = await get_current_active_user_from_bearer_token(token)
|
||||
|
||||
assert user.id == admin_user.id
|
||||
assert payload["sub"] == admin_user.username
|
||||
assert set(payload["scopes"].split()).issubset(admin_user.oauth_scopes)
|
||||
assert payload["type"] == "access"
|
||||
|
||||
|
||||
async def test_get_current_active_user_from_bearer_token_invalid_token():
|
||||
with pytest.raises(HTTPException):
|
||||
await get_current_active_user_from_bearer_token("invalid_token")
|
||||
|
||||
|
||||
async def test_get_current_active_user_from_bearer_token_invalid_user():
|
||||
token = create_oauth_token({"sub": "invalid_user"})
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
await get_current_active_user_from_bearer_token(token)
|
||||
|
||||
|
||||
async def test_get_current_active_user_from_bearer_token_disabled_user(admin_user):
|
||||
token = create_oauth_token(
|
||||
{
|
||||
"sub": admin_user.username,
|
||||
"scopes": " ".join(admin_user.oauth_scopes),
|
||||
"type": "access",
|
||||
},
|
||||
)
|
||||
|
||||
dbh.update_user(admin_user.id, {"enabled": False})
|
||||
|
||||
try:
|
||||
await get_current_active_user_from_bearer_token(token)
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 401
|
||||
assert e.detail == "Inactive user"
|
||||
|
||||
|
||||
def test_protected_route():
|
||||
router = APIRouter()
|
||||
|
||||
@protected_route(router.get, "/test")
|
||||
def test_route(request: Request):
|
||||
return {"test": "test"}
|
||||
|
||||
req = Request({"type": "http", "method": "GET", "url": "/test"})
|
||||
|
||||
assert test_route(req) == {"test": "test"}
|
||||
@@ -1,13 +1,14 @@
|
||||
import sys
|
||||
from rq import Worker, Queue, Connection
|
||||
|
||||
from config import ENABLE_EXPERIMENTAL_REDIS
|
||||
from utils.redis import redis_client, redis_connectable
|
||||
|
||||
listen = ["high", "default", "low"]
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Exit if Redis is not connectable
|
||||
if not redis_connectable:
|
||||
if not redis_connectable or not ENABLE_EXPERIMENTAL_REDIS:
|
||||
sys.exit(0)
|
||||
|
||||
with Connection(redis_client):
|
||||
|
||||
@@ -12,9 +12,9 @@ services:
|
||||
ports:
|
||||
- $DB_PORT:3306
|
||||
|
||||
# redis:
|
||||
# image: redis:alpine
|
||||
# container_name: redis
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - ${REDIS_PORT}:6379
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${REDIS_PORT}:6379
|
||||
|
||||
@@ -9,6 +9,7 @@ RUN npm run build
|
||||
FROM ubuntu/nginx:1.18-22.04_edge as production-stage
|
||||
ARG WEBSERVER_FOLDER=/var/www/html
|
||||
COPY --from=front-build-stage /front/dist ${WEBSERVER_FOLDER}
|
||||
COPY ./frontend/assets/default_avatar.png ${WEBSERVER_FOLDER}/assets/
|
||||
COPY ./frontend/assets/platforms ${WEBSERVER_FOLDER}/assets/platforms
|
||||
RUN mkdir -p ${WEBSERVER_FOLDER}/assets/romm && \
|
||||
ln -s /romm/library ${WEBSERVER_FOLDER}/assets/romm/library && \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
VERSION=$(cat .romm-version)
|
||||
VERSION="2.0.0"
|
||||
branch_name="$(git symbolic-ref HEAD 2>/dev/null)"
|
||||
branch_name=${branch_name##refs/heads/}
|
||||
docker build -t zurdi15/romm:local-${VERSION}-${branch_name} . --file ./docker/Dockerfile
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
/init_front &
|
||||
|
||||
# Start rq worker
|
||||
# /init_worker &
|
||||
/init_worker &
|
||||
|
||||
# Wait for any process to exit
|
||||
wait -n
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd /back
|
||||
nc -z ${REDIS_HOST:-localhost} ${REDIS_PORT:-6379} && rq worker high default low --logging_level WARN
|
||||
[[ ${ENABLE_EXPERIMENTAL_REDIS} == "true" ]] && rq worker high default low --logging_level WARN || sleep infinity
|
||||
|
||||
@@ -42,10 +42,15 @@ http {
|
||||
proxy_redirect off;
|
||||
}
|
||||
|
||||
# OpenAPI for swagger and redoc
|
||||
location /openapi.json {
|
||||
proxy_pass http://localhost:5000;
|
||||
}
|
||||
|
||||
# Backend api calls
|
||||
location /api {
|
||||
rewrite /api/(.*) /$1 break;
|
||||
proxy_pass http://localhost:5000/;
|
||||
proxy_pass http://localhost:5000;
|
||||
}
|
||||
location /ws {
|
||||
proxy_pass http://localhost:5000;
|
||||
@@ -54,4 +59,4 @@ http {
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
env.template
10
env.template
@@ -1,11 +1,11 @@
|
||||
ROMM_BASE_PATH=/path/to/romm_mock
|
||||
VITE_BACKEND_DEV_PORT=5000
|
||||
|
||||
# IGDB auth
|
||||
# IGDB credentials
|
||||
CLIENT_ID=
|
||||
CLIENT_SECRET=
|
||||
|
||||
# STEAMGRIDDB API KEY
|
||||
# STEAMGRIDDB API key
|
||||
STEAMGRIDDB_API_KEY=
|
||||
|
||||
# Database driver (mariadb, sqlite)
|
||||
@@ -22,3 +22,9 @@ DB_ROOT_PASSWD=
|
||||
# Redis config (optional)
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Authentication
|
||||
ROMM_AUTH_SECRET_KEY=
|
||||
ROMM_AUTH_ENABLED=true
|
||||
ROMM_AUTH_USERNAME=admin
|
||||
ROMM_AUTH_PASSWORD=admin
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: '3'
|
||||
version: "3"
|
||||
volumes:
|
||||
mysql_data:
|
||||
services:
|
||||
@@ -6,30 +6,40 @@ services:
|
||||
image: zurdi15/romm:latest
|
||||
container_name: romm
|
||||
environment:
|
||||
- ROMM_DB_DRIVER=mariadb # This variable can be set as: mariadb | sqlite. If it is not defined, sqlite will be the database by default
|
||||
- DB_HOST=mariadb # [Optional] Only needed if ROMM_DB_DRIVER=mariadb
|
||||
- DB_PORT=3306 # [Optional] Only needed if ROMM_DB_DRIVER=mariadb
|
||||
- DB_USER=romm-user # [Optional] Only needed if ROMM_DB_DRIVER=mariadb
|
||||
- DB_NAME=romm # [Optional] Only needed if ROMM_DB_DRIVER=mariadb. Can be optionally changed, and should match the MYSQL_DATABASE value in the mariadb container.
|
||||
- DB_PASSWD=<database password> # [Optional] Only needed if ROMM_DB_DRIVER=mariadb
|
||||
# - REDIS_HOST=redis # [Optional][Experimental] Redis enables workers to run long tasks, like full library rescans, without having to wait on the main thread. Can be used with an already existent redis container
|
||||
# - REDIS_PORT=6379 # [Optional][Experimental]
|
||||
- ROMM_DB_DRIVER=mariadb # mariadb | sqlite (default: sqlite)
|
||||
# [Optional] Only required if using MariaDB as the database
|
||||
- DB_HOST=mariadb
|
||||
- DB_PORT=3306
|
||||
- DB_USER=romm-user
|
||||
- DB_NAME=romm # Should match the MYSQL_DATABASE value in the mariadb container
|
||||
- DB_PASSWD=<database password>
|
||||
# [Optional] Used to fetch metadata from IGDB
|
||||
- CLIENT_ID=<IGDB client id>
|
||||
- CLIENT_SECRET=<IGDB client secret>
|
||||
- STEAMGRIDDB_API_KEY=<SteamGridDB api key> # [Optional]
|
||||
# [Optional] Use SteamGridDB as a source for covers
|
||||
- STEAMGRIDDB_API_KEY=<SteamGridDB api key>
|
||||
# [Optional] Will enable user management and require authentication to access the interface (default to false)
|
||||
- ROMM_AUTH_ENABLED=true # default: false
|
||||
- ROMM_AUTH_SECRET_KEY=<secret key> # Generate a key with `openssl rand -hex 32`
|
||||
- ROMM_AUTH_USERNAME=admin # default: admin
|
||||
- ROMM_AUTH_PASSWORD=<admin password> # default: admin
|
||||
# [Optional] Only required if authentication is enabled
|
||||
- ENABLE_EXPERIMENTAL_REDIS=true # default: false
|
||||
- REDIS_HOST=redis # default: localhost
|
||||
- REDIS_PORT=6379 # default: 6379
|
||||
volumes:
|
||||
- '/path/to/library:/romm/library'
|
||||
- '/path/to/resources:/romm/resources' # [Optional] Path where roms metadata (covers) are stored
|
||||
- '/path/to/config.yml:/romm/config.yml' # [Optional] Path where config is stored
|
||||
- '/path/to/database:/romm/database' # [Optional] Only needed if ROMM_DB_DRIVER=sqlite or ROMM_DB_DRIVER not exists
|
||||
- '/path/to/logs:/romm/logs' # [Optional] Path where RomM logs are stored
|
||||
- "/path/to/library:/romm/library"
|
||||
- "/path/to/resources:/romm/resources" # [Optional] Path where roms metadata (covers) are stored
|
||||
- "/path/to/config.yml:/romm/config.yml" # [Optional] Path where config is stored
|
||||
- "/path/to/database:/romm/database" # [Optional] Only needed if ROMM_DB_DRIVER=sqlite or not set
|
||||
- "/path/to/logs:/romm/logs" # [Optional] Path where logs are stored
|
||||
ports:
|
||||
- 80:80
|
||||
depends_on:
|
||||
- romm_db
|
||||
restart: "unless-stopped"
|
||||
|
||||
# [Optional] Only needed if ROMM_DB_DRIVER=mariadb
|
||||
# [Optional] Only required if using MariaDB as the database
|
||||
mariadb:
|
||||
image: mariadb:latest
|
||||
container_name: mariadb
|
||||
@@ -39,15 +49,15 @@ services:
|
||||
- MYSQL_USER=romm-user
|
||||
- MYSQL_PASSWORD=change-me
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql # Can also be mounted locally if preferred
|
||||
- mysql_data:/var/lib/mysql # Can also be mounted locally
|
||||
ports:
|
||||
- 3306:3306
|
||||
restart: "unless-stopped"
|
||||
|
||||
# [Optional][Experimental]
|
||||
# redis:
|
||||
# image: redis:alpine
|
||||
# container_name: redis
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - 6379:6379
|
||||
# [Optional] Only required if experimental Redis is enabled
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
BIN
frontend/assets/default_avatar.png
Normal file
BIN
frontend/assets/default_avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
BIN
frontend/assets/login_bg.png
Normal file
BIN
frontend/assets/login_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "romm",
|
||||
"version": "1.10",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "romm",
|
||||
"version": "1.9.2",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@mdi/font": "7.0.96",
|
||||
"axios": "^1.3.4",
|
||||
"core-js": "^3.8.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mitt": "^3.0.0",
|
||||
@@ -4464,6 +4465,14 @@
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/js-sdsl": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "romm",
|
||||
"version": "1.10",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
@@ -13,6 +13,7 @@
|
||||
"axios": "^1.3.4",
|
||||
"core-js": "^3.8.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mitt": "^3.0.0",
|
||||
|
||||
@@ -1,81 +1,35 @@
|
||||
<script setup>
|
||||
import { ref, inject, onMounted } from "vue";
|
||||
import { useTheme, useDisplay } from "vuetify";
|
||||
import { fetchPlatformsApi } from "@/services/api.js";
|
||||
import storePlatforms from "@/stores/platforms.js";
|
||||
import storeScanning from "@/stores/scanning.js";
|
||||
import Drawer from "@/components/Drawer/Base.vue";
|
||||
import AppBar from "@/components/AppBar/Base.vue";
|
||||
import { onBeforeMount } from "vue";
|
||||
import { useTheme } from "vuetify";
|
||||
import cookie from "js-cookie";
|
||||
import { themes } from "@/styles/themes";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import Notification from "@/components/Notification.vue";
|
||||
import { api } from "./services/api";
|
||||
|
||||
// Props
|
||||
const platforms = storePlatforms();
|
||||
const scanning = storeScanning();
|
||||
const refreshPlatforms = ref(false);
|
||||
const refreshGallery = ref(false);
|
||||
const { mdAndDown } = useDisplay();
|
||||
useTheme().global.name.value = localStorage.getItem("theme") || "rommDark";
|
||||
const auth = storeAuth();
|
||||
|
||||
// Event listeners bus
|
||||
const emitter = inject("emitter");
|
||||
emitter.on("refreshPlatforms", async () => {
|
||||
try {
|
||||
const { data } = await fetchPlatformsApi();
|
||||
platforms.set(data);
|
||||
} catch (error) {
|
||||
console.error("Couldn't fetch platforms:", error);
|
||||
} finally {
|
||||
refreshPlatforms.value = !refreshPlatforms.value;
|
||||
}
|
||||
onBeforeMount(async () => {
|
||||
// Set CSRF token for all requests
|
||||
const { data } = await api.get("/heartbeat");
|
||||
auth.setEnabled(data.ROMM_AUTH_ENABLED ?? false);
|
||||
api.defaults.headers.common["x-csrftoken"] = cookie.get("csrftoken");
|
||||
});
|
||||
|
||||
emitter.on("refreshGallery", () => {
|
||||
refreshGallery.value = !refreshGallery.value;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await fetchPlatformsApi();
|
||||
platforms.set(data);
|
||||
} catch (error) {
|
||||
console.error("Couldn't fetch platforms:", error);
|
||||
}
|
||||
});
|
||||
useTheme().global.name.value =
|
||||
themes[localStorage.getItem("theme")] || themes[0];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<notification class="mt-6" />
|
||||
|
||||
<v-progress-linear
|
||||
id="scan-progress-bar"
|
||||
color="rommAccent1"
|
||||
:active="scanning.value"
|
||||
:indeterminate="true"
|
||||
absolute
|
||||
fixed
|
||||
/>
|
||||
|
||||
<drawer :key="refreshPlatforms" />
|
||||
|
||||
<app-bar v-if="mdAndDown" />
|
||||
|
||||
<v-main>
|
||||
<v-container id="main-container" class="pa-1" fluid>
|
||||
<router-view :key="refreshGallery" />
|
||||
</v-container>
|
||||
<notification class="mt-6" />
|
||||
<router-view />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import "@/styles/scrollbar.css";
|
||||
|
||||
#scan-progress-bar {
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,8 +8,8 @@ const emitter = inject("emitter");
|
||||
<template>
|
||||
<v-list rounded="0" class="pa-0">
|
||||
<v-list-item
|
||||
@click="emitter.emit('showSearchDialog', rom)"
|
||||
class="pt-4 pb-4 pr-5"
|
||||
@click="emitter.emit('showSearchRomDialog', rom)"
|
||||
class="py-4 pr-5"
|
||||
>
|
||||
<v-list-item-title class="d-flex"
|
||||
><v-icon icon="mdi-search-web" class="mr-2" />Search
|
||||
@@ -18,8 +18,8 @@ const emitter = inject("emitter");
|
||||
</v-list-item>
|
||||
<v-divider class="border-opacity-25" />
|
||||
<v-list-item
|
||||
@click="emitter.emit('showEditDialog', rom)"
|
||||
class="pt-4 pb-4 pr-5"
|
||||
@click="emitter.emit('showEditRomDialog', { ...rom })"
|
||||
class="py-4 pr-5"
|
||||
>
|
||||
<v-list-item-title class="d-flex"
|
||||
><v-icon icon="mdi-pencil-box" class="mr-2" />Edit</v-list-item-title
|
||||
@@ -27,8 +27,8 @@ const emitter = inject("emitter");
|
||||
</v-list-item>
|
||||
<v-divider class="border-opacity-25" />
|
||||
<v-list-item
|
||||
@click="emitter.emit('showDeleteDialog', rom)"
|
||||
class="pt-4 pb-4 pr-5 text-red"
|
||||
@click="emitter.emit('showDeleteRomDialog', [rom])"
|
||||
class="py-4 pr-5 text-red"
|
||||
>
|
||||
<v-list-item-title class="d-flex"
|
||||
><v-icon icon="mdi-delete" class="mr-2" />Delete</v-list-item-title
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import storeScanning from "@/stores/scanning.js";
|
||||
import storeScanning from "@/stores/scanning";
|
||||
import DrawerToggle from "@/components/AppBar/DrawerToggle.vue";
|
||||
|
||||
// Props
|
||||
@@ -9,7 +9,7 @@ const scanning = storeScanning();
|
||||
<template>
|
||||
<v-app-bar elevation="1" density="comfortable">
|
||||
<v-progress-linear
|
||||
color="rommAccent1"
|
||||
color="romm-accent-1"
|
||||
:active="scanning.value"
|
||||
:indeterminate="true"
|
||||
absolute
|
||||
|
||||
@@ -14,16 +14,20 @@ emitter.on("showLoadingDialog", (args) => {
|
||||
<template>
|
||||
<v-dialog
|
||||
:modelValue="show"
|
||||
:scrim="false"
|
||||
scroll-strategy="none"
|
||||
width="auto"
|
||||
:scrim="false"
|
||||
persistent
|
||||
>
|
||||
<v-progress-circular
|
||||
:width="3"
|
||||
:size="70"
|
||||
color="rommAccent1"
|
||||
indeterminate
|
||||
/>
|
||||
<v-card outlined color="transparent">
|
||||
<v-card-text class="pa-4">
|
||||
<v-progress-circular
|
||||
:width="3"
|
||||
:size="70"
|
||||
color="romm-accent-1"
|
||||
indeterminate
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
@@ -2,28 +2,31 @@
|
||||
import { ref, inject } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useDisplay } from "vuetify";
|
||||
import { deleteRomApi } from "@/services/api.js";
|
||||
import { deleteRomsApi } from "@/services/api";
|
||||
import romsStore from "@/stores/roms";
|
||||
|
||||
const { xs, mdAndDown, lgAndUp } = useDisplay();
|
||||
const router = useRouter();
|
||||
const show = ref(false);
|
||||
const rom = ref();
|
||||
const storeRoms = romsStore();
|
||||
const roms = ref();
|
||||
const deleteFromFs = ref(false);
|
||||
|
||||
const emitter = inject("emitter");
|
||||
emitter.on("showDeleteDialog", (romToDelete) => {
|
||||
rom.value = romToDelete;
|
||||
emitter.on("showDeleteRomDialog", (romsToDelete) => {
|
||||
roms.value = romsToDelete;
|
||||
show.value = true;
|
||||
});
|
||||
|
||||
async function deleteRom() {
|
||||
await deleteRomApi(rom.value, deleteFromFs.value)
|
||||
async function deleteRoms() {
|
||||
await deleteRomsApi(roms.value, deleteFromFs.value)
|
||||
.then((response) => {
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: response.data.msg,
|
||||
icon: "mdi-check-bold",
|
||||
color: "green",
|
||||
});
|
||||
storeRoms.reset();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
@@ -34,9 +37,12 @@ async function deleteRom() {
|
||||
});
|
||||
return;
|
||||
});
|
||||
await router.push(`/platform/${rom.value.p_slug}`);
|
||||
emitter.emit("refreshGallery");
|
||||
emitter.emit("refreshPlatforms");
|
||||
await router.push({
|
||||
name: "platform",
|
||||
params: { platform: roms.value[0].p_slug },
|
||||
});
|
||||
emitter.emit("refreshView");
|
||||
emitter.emit("refreshDrawer");
|
||||
show.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -58,7 +64,7 @@ async function deleteRom() {
|
||||
'delete-content-mobile': xs,
|
||||
}"
|
||||
>
|
||||
<v-toolbar density="compact" class="bg-primary">
|
||||
<v-toolbar density="compact" class="bg-terciary">
|
||||
<v-row class="align-center" no-gutters>
|
||||
<v-col cols="9" xs="9" sm="10" md="10" lg="11">
|
||||
<v-icon icon="mdi-delete" class="ml-5" />
|
||||
@@ -66,7 +72,7 @@ async function deleteRom() {
|
||||
<v-col>
|
||||
<v-btn
|
||||
@click="show = false"
|
||||
class="bg-primary"
|
||||
class="bg-terciary"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
icon="mdi-close"
|
||||
@@ -76,19 +82,36 @@ async function deleteRom() {
|
||||
</v-row>
|
||||
</v-toolbar>
|
||||
<v-divider class="border-opacity-25" :thickness="1" />
|
||||
|
||||
<v-card-text class="bg-secondary">
|
||||
<v-card-text>
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<span>Deleting {{ rom.file_name }}. Do you confirm?</span>
|
||||
<span>Deleting the following</span>
|
||||
<span class="text-romm-accent-2 mx-1">{{ roms.length }}</span>
|
||||
<span>games. Do you confirm?</span>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-text class="scroll bg-terciary py-0">
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<v-btn @click="show = false">Cancel</v-btn>
|
||||
<v-btn @click="deleteRom()" class="text-red ml-5">Confirm</v-btn>
|
||||
<v-list class="bg-terciary py-0">
|
||||
<v-list-item v-for="rom in roms" class="justify-center bg-terciary"
|
||||
>{{ rom.r_name }} - [<span class="text-romm-accent-1">{{
|
||||
rom.file_name
|
||||
}}</span
|
||||
>]</v-list-item
|
||||
>
|
||||
</v-list>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-text>
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<v-btn @click="show = false" class="bg-terciary">Cancel</v-btn>
|
||||
<v-btn @click="deleteRoms()" class="text-romm-red bg-terciary ml-5"
|
||||
>Confirm</v-btn
|
||||
>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider class="border-opacity-25" :thickness="1" />
|
||||
<v-toolbar class="bg-primary" density="compact">
|
||||
<v-toolbar class="bg-terciary" density="compact">
|
||||
<v-checkbox
|
||||
v-model="deleteFromFs"
|
||||
label="Remove from filesystem"
|
||||
@@ -103,13 +126,19 @@ async function deleteRom() {
|
||||
<style scoped>
|
||||
.delete-content {
|
||||
width: 900px;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.delete-content-tablet {
|
||||
width: 570px;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.delete-content-mobile {
|
||||
width: 85vw;
|
||||
max-height: 600px;
|
||||
}
|
||||
.scroll {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, inject } from "vue";
|
||||
import { useDisplay } from "vuetify";
|
||||
import { updateRomApi } from "@/services/api.js";
|
||||
import { updateRomApi } from "@/services/api";
|
||||
|
||||
const { xs, mdAndDown, lgAndUp } = useDisplay();
|
||||
const show = ref(false);
|
||||
@@ -13,7 +13,7 @@ const fileNameInputRules = {
|
||||
};
|
||||
|
||||
const emitter = inject("emitter");
|
||||
emitter.on("showEditDialog", (romToEdit) => {
|
||||
emitter.on("showEditRomDialog", (romToEdit) => {
|
||||
show.value = true;
|
||||
rom.value = romToEdit;
|
||||
});
|
||||
@@ -45,7 +45,7 @@ async function updateRom(updatedData = { ...rom.value }) {
|
||||
icon: "mdi-check-bold",
|
||||
color: "green",
|
||||
});
|
||||
emitter.emit("refreshGallery");
|
||||
emitter.emit("refreshView");
|
||||
})
|
||||
.catch((error) => {
|
||||
emitter.emit("snackbarShow", {
|
||||
@@ -79,7 +79,7 @@ async function updateRom(updatedData = { ...rom.value }) {
|
||||
'edit-content-mobile': xs,
|
||||
}"
|
||||
>
|
||||
<v-toolbar density="compact" class="bg-primary">
|
||||
<v-toolbar density="compact" class="bg-terciary">
|
||||
<v-row class="align-center" no-gutters>
|
||||
<v-col cols="9" xs="9" sm="10" md="10" lg="11">
|
||||
<v-icon icon="mdi-pencil-box" class="ml-5" />
|
||||
@@ -87,7 +87,7 @@ async function updateRom(updatedData = { ...rom.value }) {
|
||||
<v-col>
|
||||
<v-btn
|
||||
@click="show = false"
|
||||
class="bg-primary"
|
||||
class="bg-terciary"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
icon="mdi-close"
|
||||
@@ -98,8 +98,8 @@ async function updateRom(updatedData = { ...rom.value }) {
|
||||
</v-toolbar>
|
||||
<v-divider class="border-opacity-25" :thickness="1" />
|
||||
|
||||
<v-card-text class="bg-secondary scroll">
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<v-card-text>
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-text-field
|
||||
@keyup.enter="updateRom()"
|
||||
v-model="rom.r_name"
|
||||
@@ -109,7 +109,7 @@ async function updateRom(updatedData = { ...rom.value }) {
|
||||
hide-details
|
||||
/>
|
||||
</v-row>
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-text-field
|
||||
@keyup.enter="updateRom()"
|
||||
v-model="rom.file_name"
|
||||
@@ -123,7 +123,7 @@ async function updateRom(updatedData = { ...rom.value }) {
|
||||
hide-details
|
||||
/>
|
||||
</v-row>
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-textarea
|
||||
@keyup.enter="updateRom()"
|
||||
v-model="rom.summary"
|
||||
@@ -133,7 +133,7 @@ async function updateRom(updatedData = { ...rom.value }) {
|
||||
hide-details
|
||||
/>
|
||||
</v-row>
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-file-input
|
||||
@keyup.enter="updateRom()"
|
||||
label="Custom cover [Coming soon]"
|
||||
@@ -145,8 +145,12 @@ async function updateRom(updatedData = { ...rom.value }) {
|
||||
/>
|
||||
</v-row>
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<v-btn @click="show = false">Cancel</v-btn>
|
||||
<v-btn @click="updateRom()" class="text-rommGreen ml-5">Apply</v-btn>
|
||||
<v-btn @click="show = false" class="bg-terciary">Cancel</v-btn>
|
||||
<v-btn
|
||||
@click="updateRom()"
|
||||
class="text-romm-green ml-5 bg-terciary"
|
||||
>Apply</v-btn
|
||||
>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, inject, onBeforeUnmount } from "vue";
|
||||
import { useDisplay } from "vuetify";
|
||||
import { updateRomApi, searchRomIGDBApi } from "@/services/api.js";
|
||||
import { updateRomApi, searchRomIGDBApi } from "@/services/api";
|
||||
|
||||
const { xs, mdAndDown, lgAndUp } = useDisplay();
|
||||
const show = ref(false);
|
||||
@@ -11,9 +11,10 @@ const searching = ref(false);
|
||||
const searchTerm = ref("");
|
||||
const searchBy = ref("Name");
|
||||
const matchedRoms = ref([]);
|
||||
const selectedScrapSource = ref(0);
|
||||
|
||||
const emitter = inject("emitter");
|
||||
emitter.on("showSearchDialog", (romToSearch) => {
|
||||
emitter.on("showSearchRomDialog", (romToSearch) => {
|
||||
rom.value = romToSearch;
|
||||
searchTerm.value = romToSearch.file_name_no_tags;
|
||||
show.value = true;
|
||||
@@ -47,7 +48,7 @@ async function updateRom(updatedData = { ...rom.value }) {
|
||||
icon: "mdi-check-bold",
|
||||
color: "green",
|
||||
});
|
||||
emitter.emit("refreshGallery");
|
||||
emitter.emit("refreshView");
|
||||
})
|
||||
.catch((error) => {
|
||||
emitter.emit("snackbarShow", {
|
||||
@@ -62,7 +63,7 @@ async function updateRom(updatedData = { ...rom.value }) {
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
emitter.off("showSearchDialog");
|
||||
emitter.off("showSearchRomDialog");
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -85,26 +86,57 @@ onBeforeUnmount(() => {
|
||||
}"
|
||||
rounded="0"
|
||||
>
|
||||
<v-toolbar density="compact" class="bg-primary">
|
||||
<v-toolbar density="compact" class="bg-terciary">
|
||||
<v-row class="align-center" no-gutters>
|
||||
<v-col cols="9" xs="9" sm="10" md="10" lg="11">
|
||||
|
||||
<v-col cols="2" xs="2" sm="1" md="1" lg="1">
|
||||
<v-icon icon="mdi-search-web" class="ml-5" />
|
||||
<v-chip class="ml-5 text-rommAccent1" variant="outlined" label
|
||||
>IGDB</v-chip
|
||||
>
|
||||
</v-col>
|
||||
<v-col>
|
||||
|
||||
<v-col cols="8" xs="8" sm="9" md="9" lg="10">
|
||||
<v-item-group mandatory v-model="selectedScrapSource">
|
||||
<v-item v-slot="{ isSelected, toggle }">
|
||||
<v-chip
|
||||
class="mx-1"
|
||||
:color="isSelected ? 'romm-accent-1' : 'romm-gray'"
|
||||
variant="outlined"
|
||||
label
|
||||
@click="toggle"
|
||||
>IGDB</v-chip
|
||||
>
|
||||
</v-item>
|
||||
<!-- TODO: Ready item group to scrape from different sources -->
|
||||
<!-- <v-item v-slot="{ isSelected, toggle }" disabled>
|
||||
<v-chip class="mx-1" :color="isSelected ? 'romm-accent-1' : 'romm-gray'" variant="outlined" label @click="toggle"
|
||||
>ScreenScraper</v-chip
|
||||
>
|
||||
</v-item>
|
||||
<v-item v-slot="{ isSelected, toggle }" disabled>
|
||||
<v-chip class="mx-1" :color="isSelected ? 'romm-accent-1' : 'romm-gray'" variant="outlined" label @click="toggle"
|
||||
>MobyGames</v-chip
|
||||
>
|
||||
</v-item>
|
||||
<v-item v-slot="{ isSelected, toggle }" disabled>
|
||||
<v-chip class="mx-1" :color="isSelected ? 'romm-accent-1' : 'romm-gray'" variant="outlined" label @click="toggle"
|
||||
>RAWG</v-chip
|
||||
>
|
||||
</v-item> -->
|
||||
</v-item-group>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="2" xs="2" sm="2" md="2" lg="1">
|
||||
<v-btn
|
||||
@click="show = false"
|
||||
class="bg-primary"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
icon="mdi-close"
|
||||
block
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
</v-toolbar>
|
||||
|
||||
<v-divider class="border-opacity-25" :thickness="1" />
|
||||
|
||||
<v-toolbar density="compact" class="bg-primary">
|
||||
@@ -113,6 +145,7 @@ onBeforeUnmount(() => {
|
||||
<v-text-field
|
||||
@keyup.enter="searchRomIGDB()"
|
||||
@click:clear="searchTerm = ''"
|
||||
class="bg-terciary"
|
||||
v-model="searchTerm"
|
||||
label="search"
|
||||
hide-details
|
||||
@@ -122,6 +155,7 @@ onBeforeUnmount(() => {
|
||||
<v-col cols="3" xs="3" sm="2" md="2" lg="2">
|
||||
<v-select
|
||||
label="by"
|
||||
class="bg-terciary"
|
||||
:items="['ID', 'Name']"
|
||||
v-model="searchBy"
|
||||
hide-details
|
||||
@@ -131,7 +165,7 @@ onBeforeUnmount(() => {
|
||||
<v-btn
|
||||
type="submit"
|
||||
@click="searchRomIGDB()"
|
||||
class="bg-primary"
|
||||
class="bg-terciary"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
icon="mdi-search-web"
|
||||
@@ -142,7 +176,7 @@ onBeforeUnmount(() => {
|
||||
</v-row>
|
||||
</v-toolbar>
|
||||
|
||||
<v-card-text class="pa-1 scroll bg-secondary">
|
||||
<v-card-text class="pa-1 scroll">
|
||||
<v-row
|
||||
class="justify-center loader-searching"
|
||||
v-show="searching"
|
||||
@@ -151,7 +185,7 @@ onBeforeUnmount(() => {
|
||||
<v-progress-circular
|
||||
:width="2"
|
||||
:size="40"
|
||||
color="rommAccent1"
|
||||
color="romm-accent-1"
|
||||
indeterminate
|
||||
/>
|
||||
</v-row>
|
||||
@@ -199,7 +233,8 @@ onBeforeUnmount(() => {
|
||||
</v-card-text>
|
||||
|
||||
<v-divider class="border-opacity-25" :thickness="1" />
|
||||
<v-toolbar class="bg-primary" density="compact">
|
||||
|
||||
<v-toolbar class="bg-terciary" density="compact">
|
||||
<v-checkbox
|
||||
v-model="renameAsIGDB"
|
||||
label="Rename rom"
|
||||
107
frontend/src/components/Dialog/User/CreateUser.vue
Normal file
107
frontend/src/components/Dialog/User/CreateUser.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup>
|
||||
import { ref, inject } from "vue";
|
||||
|
||||
import { createUserApi } from "@/services/api";
|
||||
|
||||
const user = ref({
|
||||
username: "",
|
||||
password: "",
|
||||
role: "viewer",
|
||||
});
|
||||
const show = ref(false);
|
||||
|
||||
const emitter = inject("emitter");
|
||||
emitter.on("showCreateUserDialog", () => {
|
||||
show.value = true;
|
||||
});
|
||||
|
||||
async function createUser() {
|
||||
await createUserApi(user.value).catch(({ response, message }) => {
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: `Unable to create user: ${
|
||||
response?.data?.detail || response?.statusText || message
|
||||
}`,
|
||||
icon: "mdi-close-circle",
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
show.value = false;
|
||||
emitter.emit("refreshView");
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<v-dialog v-model="show" max-width="500px" :scrim="false">
|
||||
<v-card>
|
||||
<v-toolbar density="compact" class="bg-terciary">
|
||||
<v-row class="align-center" no-gutters>
|
||||
<v-col cols="10">
|
||||
<v-icon icon="mdi-account" class="ml-5 mr-2" />
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn
|
||||
@click="show = false"
|
||||
class="bg-terciary"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
icon="mdi-close"
|
||||
block
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-toolbar>
|
||||
<v-divider class="border-opacity-25" :thickness="1" />
|
||||
|
||||
<v-card-text>
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-col>
|
||||
<v-text-field
|
||||
rounded="0"
|
||||
variant="outlined"
|
||||
v-model="user.username"
|
||||
label="username"
|
||||
required
|
||||
hide-details
|
||||
clearable
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-col>
|
||||
<v-text-field
|
||||
rounded="0"
|
||||
variant="outlined"
|
||||
v-model="user.password"
|
||||
label="Password"
|
||||
required
|
||||
hide-details
|
||||
clearable
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-col>
|
||||
<v-select
|
||||
v-model="user.role"
|
||||
rounded="0"
|
||||
variant="outlined"
|
||||
:items="['viewer', 'editor', 'admin']"
|
||||
label="Role"
|
||||
required
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<v-btn @click="show = false" class="bg-terciary">Cancel</v-btn>
|
||||
<v-btn
|
||||
:disabled="!user.username || !user.password"
|
||||
class="text-romm-green bg-terciary ml-5"
|
||||
@click="createUser()"
|
||||
>
|
||||
Create
|
||||
</v-btn>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
66
frontend/src/components/Dialog/User/DeleteUser.vue
Normal file
66
frontend/src/components/Dialog/User/DeleteUser.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { ref, inject } from "vue";
|
||||
|
||||
import { deleteUserApi } from "@/services/api";
|
||||
|
||||
const user = ref();
|
||||
const show = ref(false);
|
||||
|
||||
const emitter = inject("emitter");
|
||||
emitter.on("showDeleteUserDialog", (userToDelete) => {
|
||||
user.value = userToDelete;
|
||||
show.value = true;
|
||||
});
|
||||
|
||||
async function deleteUser() {
|
||||
await deleteUserApi(user.value).catch(({ response, message }) => {
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: `Unable to delete user: ${
|
||||
response?.data?.detail || response?.statusText || message
|
||||
}`,
|
||||
icon: "mdi-close-circle",
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
show.value = false;
|
||||
emitter.emit("refreshView");
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<v-dialog v-model="show" max-width="500px" :scrim="true">
|
||||
<v-card>
|
||||
<v-toolbar density="compact" class="bg-terciary">
|
||||
<v-row class="align-center" no-gutters>
|
||||
<v-col cols="10">
|
||||
<v-icon icon="mdi-delete" class="ml-5 mr-2" />
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn
|
||||
@click="show = false"
|
||||
class="bg-terciary"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
icon="mdi-close"
|
||||
block
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-toolbar>
|
||||
<v-divider class="border-opacity-25" :thickness="1" />
|
||||
|
||||
<v-card-text>
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<span class="mr-1">Deleting</span
|
||||
><span class="text-romm-accent-1">{{ user.username }}</span
|
||||
>.<span class="ml-1">Do you confirm?</span>
|
||||
</v-row>
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<v-btn @click="show = false" class="bg-terciary">Cancel</v-btn>
|
||||
<v-btn class="bg-terciary text-romm-red ml-5" @click="deleteUser()"
|
||||
>Confirm</v-btn
|
||||
>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
143
frontend/src/components/Dialog/User/EditUser.vue
Normal file
143
frontend/src/components/Dialog/User/EditUser.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, inject } from "vue";
|
||||
import { updateUserApi } from "@/services/api";
|
||||
import { defaultAvatarPath } from "@/utils/utils"
|
||||
|
||||
const user = ref();
|
||||
const show = ref(false);
|
||||
const avatarFile = ref();
|
||||
|
||||
const emitter = inject("emitter");
|
||||
emitter.on("showEditUserDialog", (userToEdit) => {
|
||||
user.value = userToEdit;
|
||||
show.value = true;
|
||||
});
|
||||
|
||||
function editUser() {
|
||||
updateUserApi(user.value)
|
||||
.then((response) => {
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: `User ${response.data.username} updated successfully`,
|
||||
icon: "mdi-check-bold",
|
||||
color: "green",
|
||||
timeout: 5000
|
||||
});
|
||||
})
|
||||
.catch(({ response, message }) => {
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: `Unable to edit user: ${
|
||||
response?.data?.detail || response?.statusText || message
|
||||
}`,
|
||||
icon: "mdi-close-circle",
|
||||
color: "red",
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
show.value = false;
|
||||
emitter.emit("refreshView");
|
||||
emitter.emit("refreshDrawer");
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<v-dialog v-model="show" max-width="700px" :scrim="false">
|
||||
<v-card>
|
||||
<v-toolbar density="compact" class="bg-terciary">
|
||||
<v-row class="align-center" no-gutters>
|
||||
<v-col cols="10">
|
||||
<v-icon icon="mdi-pencil-box" class="ml-5 mr-2" />
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn
|
||||
@click="show = false"
|
||||
class="bg-terciary"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
icon="mdi-close"
|
||||
block
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-toolbar>
|
||||
<v-divider class="border-opacity-25" :thickness="1" />
|
||||
|
||||
<v-card-text>
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12" lg="9">
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-col>
|
||||
<v-text-field
|
||||
rounded="0"
|
||||
variant="outlined"
|
||||
v-model="user.username"
|
||||
label="username"
|
||||
required
|
||||
hide-details
|
||||
clearable
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-col>
|
||||
<v-text-field
|
||||
rounded="0"
|
||||
variant="outlined"
|
||||
v-model="user.password"
|
||||
label="Password"
|
||||
required
|
||||
hide-details
|
||||
clearable
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-col>
|
||||
<v-select
|
||||
v-model="user.role"
|
||||
rounded="0"
|
||||
variant="outlined"
|
||||
:items="['viewer', 'editor', 'admin']"
|
||||
label="Role"
|
||||
required
|
||||
hide-details
|
||||
></v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
<v-col cols="12" lg="3">
|
||||
<v-row class="pa-2 justify-center" no-gutters>
|
||||
<v-avatar size="128" class="">
|
||||
<v-img
|
||||
:src="
|
||||
user.avatar_path
|
||||
? `/assets/romm/resources/${user.avatar_path}`
|
||||
: defaultAvatarPath
|
||||
"
|
||||
/>
|
||||
</v-avatar>
|
||||
</v-row>
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-col>
|
||||
<v-file-input
|
||||
class="text-truncate"
|
||||
@keyup.enter="updateRom()"
|
||||
v-model="user.avatar"
|
||||
label="Avatar"
|
||||
prepend-inner-icon="mdi-image"
|
||||
prepend-icon=""
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<v-btn @click="show = false" class="bg-terciary">Cancel</v-btn>
|
||||
<v-btn class="text-romm-green bg-terciary ml-5" @click="editUser()"
|
||||
>Apply</v-btn
|
||||
>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup>
|
||||
import { ref, inject } from "vue";
|
||||
import storePlatforms from "@/stores/platforms";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import DrawerHeader from "@/components/Drawer/Header.vue";
|
||||
import PlatformListItem from "@/components/Platform/PlatformListItem.vue";
|
||||
import RailBtn from "@/components/Drawer/RailBtn.vue";
|
||||
import RailFooter from "@/components/Drawer/Footer.vue";
|
||||
|
||||
// Props
|
||||
const platforms = storePlatforms();
|
||||
const auth = storeAuth();
|
||||
const drawer = ref(undefined);
|
||||
const open = ref(["Platforms", "Library", "Settings"]);
|
||||
const rail = ref(localStorage.getItem("rail") == "true");
|
||||
@@ -25,25 +28,20 @@ emitter.on("toggleDrawerRail", () => {
|
||||
<v-navigation-drawer
|
||||
v-model="drawer"
|
||||
:rail="rail"
|
||||
width="300"
|
||||
rail-width="145"
|
||||
width="270"
|
||||
rail-width="70"
|
||||
elevation="0"
|
||||
>
|
||||
<v-list v-model:opened="open">
|
||||
<router-link to="/">
|
||||
<v-list-item class="justify-center pa-0">
|
||||
<v-img src="/assets/isotipo.svg" width="70" class="home-btn" />
|
||||
</v-list-item>
|
||||
</router-link>
|
||||
<template v-slot:prepend>
|
||||
<drawer-header :rail="rail" />
|
||||
</template>
|
||||
<v-list v-model:opened="open" class="pa-0">
|
||||
<v-divider />
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-list-group value="Platforms">
|
||||
<v-list-group value="Platforms" fluid>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-list-item v-bind="props">
|
||||
<span class="text-body-1 text-truncate">{{
|
||||
rail ? "" : "Platforms"
|
||||
}}</span>
|
||||
<span v-if="!rail" class="text-body-1 text-truncate">Platforms</span>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar :rounded="0" size="40"
|
||||
><v-icon>mdi-controller</v-icon></v-avatar
|
||||
@@ -52,7 +50,6 @@ emitter.on("toggleDrawerRail", () => {
|
||||
</v-list-item>
|
||||
</template>
|
||||
<platform-list-item
|
||||
class="drawer-item"
|
||||
v-for="platform in platforms.value"
|
||||
:platform="platform"
|
||||
:rail="rail"
|
||||
@@ -60,12 +57,10 @@ emitter.on("toggleDrawerRail", () => {
|
||||
/>
|
||||
</v-list-group>
|
||||
|
||||
<v-list-group value="Library">
|
||||
<v-list-group value="Library" v-if="auth.scopes.includes('roms.write')" fluid>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-list-item v-bind="props">
|
||||
<span class="text-body-1 text-truncate">{{
|
||||
rail ? "" : "Library"
|
||||
}}</span>
|
||||
<span v-if="!rail" class="text-body-1 text-truncate">Library</span>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar :rounded="0" size="40"
|
||||
><v-icon>mdi-animation-outline</v-icon></v-avatar
|
||||
@@ -73,23 +68,17 @@ emitter.on("toggleDrawerRail", () => {
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<v-list-item class="drawer-item bg-terciary" to="/library/scan">
|
||||
<span class="text-body-2 text-truncate">{{
|
||||
rail ? "" : "Scan"
|
||||
}}</span>
|
||||
<v-list-item class="bg-terciary" to="/library/scan">
|
||||
<span v-if="!rail" class="text-body-2 text-truncate">Scan</span>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar :rounded="0" size="40"
|
||||
><v-icon>mdi-magnify-scan</v-icon></v-avatar
|
||||
>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item class="drawer-item bg-terciary" disabled>
|
||||
<span class="text-body-2 text-truncate">{{
|
||||
rail ? "" : "Upload"
|
||||
}}</span>
|
||||
<span class="text-caption text-truncate ml-1">{{
|
||||
rail ? "" : "[coming soon]"
|
||||
}}</span>
|
||||
<v-list-item class="bg-terciary" disabled>
|
||||
<span v-if="!rail" class="text-body-2 text-truncate">Upload</span>
|
||||
<span v-if="!rail" class="text-caption text-truncate ml-1">[coming soon]</span>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar :rounded="0" size="40"
|
||||
><v-icon>mdi-upload</v-icon></v-avatar
|
||||
@@ -98,12 +87,10 @@ emitter.on("toggleDrawerRail", () => {
|
||||
</v-list-item>
|
||||
</v-list-group>
|
||||
|
||||
<v-list-group value="Settings">
|
||||
<v-list-group value="Settings" fluid>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-list-item v-bind="props">
|
||||
<span class="text-body-1 text-truncate">{{
|
||||
rail ? "" : "Settings"
|
||||
}}</span>
|
||||
<span v-if="!rail" class="text-body-1 text-truncate">Settings</span>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar :rounded="0" size="40"
|
||||
><v-icon>mdi-cog</v-icon></v-avatar
|
||||
@@ -112,12 +99,10 @@ emitter.on("toggleDrawerRail", () => {
|
||||
</v-list-item>
|
||||
</template>
|
||||
<v-list-item
|
||||
class="drawer-item bg-terciary"
|
||||
class="bg-terciary"
|
||||
to="/settings/control-panel"
|
||||
>
|
||||
<span class="text-body-2 text-truncate">{{
|
||||
rail ? "" : "Control panel"
|
||||
}}</span>
|
||||
<span v-if="!rail" class="text-body-2 text-truncate">Control Panel</span>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar :rounded="0" size="40"
|
||||
><v-icon>mdi-view-dashboard</v-icon></v-avatar
|
||||
@@ -127,21 +112,9 @@ emitter.on("toggleDrawerRail", () => {
|
||||
</v-list-group>
|
||||
</v-list>
|
||||
|
||||
<template v-slot:append>
|
||||
<template v-if="auth.enabled" v-slot:append>
|
||||
<v-divider class="border-opacity-25" :thickness="1" />
|
||||
<rail-btn :rail="rail" />
|
||||
<rail-footer :rail="rail" />
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-btn {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drawer-item {
|
||||
padding-inline-start: 30px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
69
frontend/src/components/Drawer/Footer.vue
Normal file
69
frontend/src/components/Drawer/Footer.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { inject } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import { defaultAvatarPath } from "@/utils/utils"
|
||||
import { api } from "@/services/api";
|
||||
|
||||
// Props
|
||||
const props = defineProps(["rail"]);
|
||||
const router = useRouter();
|
||||
const emitter = inject("emitter");
|
||||
const auth = storeAuth();
|
||||
|
||||
// Functions
|
||||
async function logout() {
|
||||
api
|
||||
.post("/logout", {})
|
||||
.then(({ data }) => {
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: data.message,
|
||||
icon: "mdi-check-bold",
|
||||
color: "green",
|
||||
});
|
||||
router.push("/login");
|
||||
})
|
||||
.catch(() => {
|
||||
router.push("/login");
|
||||
})
|
||||
.finally(() => {
|
||||
auth.setUser(null);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-list-item height="60" class="bg-primary text-button" rounded="0">
|
||||
<template v-if="!rail">
|
||||
<div class="text-no-wrap text-truncate text-subtitle-1">{{ auth.user?.username }}</div>
|
||||
<div class="text-no-wrap text-truncate text-caption">{{ auth.user?.role }}</div>
|
||||
</template>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar :class="{ 'my-2': rail }">
|
||||
<v-img
|
||||
:src="
|
||||
auth.user?.avatar_path
|
||||
? `/assets/romm/resources/${auth.user?.avatar_path}`
|
||||
: defaultAvatarPath
|
||||
"
|
||||
/>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-btn
|
||||
v-if="!rail"
|
||||
variant="text"
|
||||
icon="mdi-location-exit"
|
||||
@click="logout()"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-btn
|
||||
v-if="rail"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
icon="mdi-location-exit"
|
||||
block
|
||||
@click="logout()"
|
||||
></v-btn>
|
||||
</template>
|
||||
32
frontend/src/components/Drawer/Header.vue
Normal file
32
frontend/src/components/Drawer/Header.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import RailBtn from "@/components/Drawer/RailBtn.vue";
|
||||
|
||||
const props = defineProps(["rail"]);
|
||||
</script>
|
||||
<template>
|
||||
<v-list-item :class="{ 'ml-9': !rail, 'ml-0': rail }" class="my-2">
|
||||
<template v-slot:prepend>
|
||||
<router-link to="/">
|
||||
<v-avatar :rounded="0" :size="rail ? 40 : 60" class="mr-3"
|
||||
><v-img src="/assets/isotipo.svg"
|
||||
/></v-avatar>
|
||||
<v-avatar v-if="!rail" :rounded="0" size="60"
|
||||
><v-img src="/assets/logotipo.svg"
|
||||
/></v-avatar>
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-if="!rail" v-slot:append>
|
||||
<rail-btn :rail="rail" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item class="pa-0" v-if="rail">
|
||||
<rail-btn :rail="rail" />
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-btn {
|
||||
width: 75px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -11,8 +11,8 @@ const emitter = inject("emitter");
|
||||
<template>
|
||||
<v-btn
|
||||
@click="emitter.emit('toggleDrawerRail')"
|
||||
rounded="0"
|
||||
color="primary"
|
||||
elevation="0"
|
||||
block
|
||||
>
|
||||
<v-icon v-if="rail">mdi-arrow-collapse-right</v-icon>
|
||||
|
||||
122
frontend/src/components/FabMenu/Base.vue
Normal file
122
frontend/src/components/FabMenu/Base.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import { inject } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import storeRoms from "@/stores/roms";
|
||||
import socket from "@/services/socket";
|
||||
import storeScanning from "@/stores/scanning";
|
||||
import { downloadRomApi } from "@/services/api";
|
||||
|
||||
// Event listeners bus
|
||||
const emitter = inject("emitter");
|
||||
|
||||
// Props
|
||||
const props = defineProps(["filteredRoms"]);
|
||||
const auth = storeAuth();
|
||||
const romsStore = storeRoms();
|
||||
const scanning = storeScanning();
|
||||
const route = useRoute();
|
||||
|
||||
socket.on("scan:scanning_rom", ({ id }) => {
|
||||
const rom = romsStore.selected.find((r) => r.id === id);
|
||||
romsStore.removeSelectedRoms(rom);
|
||||
});
|
||||
|
||||
socket.on("scan:done", () => {
|
||||
scanning.set(false);
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: "Scan completed successfully!",
|
||||
icon: "mdi-check-bold",
|
||||
color: "green",
|
||||
});
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
socket.on("scan:done_ko", (msg) => {
|
||||
scanning.set(false);
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: `Scan couldn't be completed. Something went wrong: ${msg}`,
|
||||
icon: "mdi-close-circle",
|
||||
color: "red",
|
||||
});
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
// Functions
|
||||
async function onScan() {
|
||||
scanning.set(true);
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: `Scanning ${route.params.platform}...`,
|
||||
icon: "mdi-loading mdi-spin",
|
||||
color: "romm-accent-1",
|
||||
});
|
||||
|
||||
if (!socket.connected) socket.connect();
|
||||
socket.emit("scan", {
|
||||
platforms: [route.params.platform],
|
||||
rescan: false,
|
||||
});
|
||||
}
|
||||
|
||||
function selectAllRoms() {
|
||||
if (props.filteredRoms.length === romsStore.selected.length) {
|
||||
romsStore.reset();
|
||||
emitter.emit("openFabMenu", false);
|
||||
} else {
|
||||
romsStore.updateSelectedRoms(props.filteredRoms);
|
||||
}
|
||||
emitter.emit("refreshSelected");
|
||||
}
|
||||
|
||||
function onDownload() {
|
||||
romsStore.selected.forEach((rom) => {
|
||||
downloadRomApi(rom);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn
|
||||
color="terciary"
|
||||
elevation="8"
|
||||
:icon="
|
||||
filteredRoms.length === romsStore.selected.length
|
||||
? 'mdi-select'
|
||||
: 'mdi-select-all'
|
||||
"
|
||||
class="mb-2 ml-1"
|
||||
@click.stop="selectAllRoms"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
v-if="auth.scopes.includes('roms.write')"
|
||||
color="terciary"
|
||||
elevation="8"
|
||||
icon
|
||||
class="mb-2 ml-1"
|
||||
@click="onScan"
|
||||
>
|
||||
<v-icon>mdi-magnify-scan</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="terciary"
|
||||
elevation="8"
|
||||
icon
|
||||
class="mb-2 ml-1"
|
||||
@click="onDownload"
|
||||
>
|
||||
<v-icon>mdi-download</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
v-if="auth.scopes.includes('roms.write')"
|
||||
color="terciary"
|
||||
elevation="8"
|
||||
icon
|
||||
class="mb-3 ml-1"
|
||||
@click="emitter.emit('showDeleteRomDialog', romsStore.selected)"
|
||||
>
|
||||
<v-icon color="romm-red">mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { inject, ref, onMounted } from "vue";
|
||||
import { debounce } from "lodash";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter.js";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter";
|
||||
|
||||
// Props
|
||||
const galleryFilter = storeGalleryFilter();
|
||||
@@ -31,6 +31,7 @@ const filterRoms = debounce(() => {
|
||||
v-model="filterValue"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="search"
|
||||
rounded="0"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { views } from "@/utils/utils.js";
|
||||
import storeGalleryView from "@/stores/galleryView.js";
|
||||
import { views } from "@/utils/utils";
|
||||
import storeGalleryView from "@/stores/galleryView";
|
||||
|
||||
// Props
|
||||
const galleryView = storeGalleryView();
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { downloadRomApi } from "@/services/api.js";
|
||||
import useDownloadStore from "@/stores/download.js";
|
||||
import { downloadRomApi } from "@/services/api";
|
||||
import storeDownload from "@/stores/download";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import AdminMenu from "@/components/AdminMenu/Base.vue";
|
||||
|
||||
// Props
|
||||
const props = defineProps(["rom"]);
|
||||
const saveFiles = ref(false);
|
||||
const downloadStore = useDownloadStore();
|
||||
const auth = storeAuth();
|
||||
const downloadStore = storeDownload();
|
||||
const downloadUrl = `${window.location.origin}${props.rom.download_path}`;
|
||||
</script>
|
||||
|
||||
@@ -48,6 +50,7 @@ const downloadUrl = `${window.location.origin}${props.rom.download_path}`;
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
@click=""
|
||||
:disabled="!auth.scopes.includes('roms.write')"
|
||||
v-bind="props"
|
||||
icon="mdi-dots-vertical"
|
||||
size="x-small"
|
||||
|
||||
@@ -1,31 +1,62 @@
|
||||
<script setup>
|
||||
import { ref, inject } from "vue";
|
||||
import useRomsStore from "@/stores/roms.js";
|
||||
import ActionBar from "@/components/Game/Card/ActionBar.vue";
|
||||
import Cover from "@/components/Game/Card/Cover.vue";
|
||||
|
||||
// Props
|
||||
const props = defineProps(["rom"]);
|
||||
const props = defineProps(["rom", "index"]);
|
||||
const emit = defineEmits(["selectRom"]);
|
||||
const romsStore = useRomsStore();
|
||||
const selected = ref();
|
||||
|
||||
// Functions
|
||||
function selectRom(event) {
|
||||
selected.value = !selected.value;
|
||||
if (selected.value) {
|
||||
romsStore.addSelectedRoms(props.rom);
|
||||
} else {
|
||||
romsStore.removeSelectedRoms(props.rom);
|
||||
}
|
||||
emit("selectRom", { event, index: props.index, selected: selected.value });
|
||||
}
|
||||
|
||||
const emitter = inject("emitter");
|
||||
emitter.on("refreshSelected", () => {
|
||||
selected.value = romsStore.selected
|
||||
.map((rom) => rom.id)
|
||||
.includes(props.rom.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-hover v-slot="{ isHovering, props }">
|
||||
<v-card
|
||||
v-bind="props"
|
||||
:class="{ 'on-hover': isHovering }"
|
||||
class="rom-card"
|
||||
:class="{ 'on-hover': isHovering, 'rom-selected': selected }"
|
||||
:elevation="isHovering ? 20 : 3"
|
||||
>
|
||||
<v-hover v-slot="{ isHovering, props }" open-delay="800">
|
||||
<cover :rom="rom" :isHovering="isHovering" :hoverProps="props" />
|
||||
<action-bar :rom="rom" />
|
||||
</v-hover>
|
||||
<cover
|
||||
:rom="rom"
|
||||
:isHoveringTop="isHovering"
|
||||
:selected="selected"
|
||||
@selectRom="selectRom"
|
||||
/>
|
||||
<action-bar :rom="rom" />
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.v-card {
|
||||
opacity: 0.85;
|
||||
border: 3px solid rgba(var(--v-theme-primary));
|
||||
}
|
||||
.v-card.on-hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.v-card:not(.on-hover) {
|
||||
opacity: 0.85;
|
||||
.v-card.rom-selected {
|
||||
border: 3px solid rgba(var(--v-theme-romm-accent-2));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,63 +1,104 @@
|
||||
<script setup>
|
||||
import useDownloadStore from "@/stores/download.js";
|
||||
import storeDownload from "@/stores/download";
|
||||
import storeRoms from "@/stores/roms";
|
||||
|
||||
const downloadStore = useDownloadStore();
|
||||
const downloadStore = storeDownload();
|
||||
const romsStore = storeRoms();
|
||||
|
||||
// Props
|
||||
const props = defineProps(["rom", "isHovering", "hoverProps", "size"]);
|
||||
const props = defineProps(["rom", "isHoveringTop", "size", "selected"]);
|
||||
const emit = defineEmits(["selectRom"]);
|
||||
|
||||
// Functions
|
||||
function selectRom(event) {
|
||||
if (!event.ctrlKey && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
emit("selectRom", event);
|
||||
}
|
||||
}
|
||||
|
||||
function onNavigate(event) {
|
||||
if (event.ctrlKey || event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
emit("selectRom", event);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link
|
||||
style="text-decoration: none; color: inherit"
|
||||
:to="`/platform/${$route.params.platform}/${rom.id}`"
|
||||
:to="
|
||||
romsStore.length > 0
|
||||
? `#`
|
||||
: `/platform/${$route.params.platform}/${rom.id}`
|
||||
"
|
||||
@click="onNavigate"
|
||||
>
|
||||
<v-progress-linear
|
||||
color="rommAccent1"
|
||||
color="romm-accent-1"
|
||||
:active="downloadStore.value.includes(rom.id)"
|
||||
:indeterminate="true"
|
||||
absolute
|
||||
/>
|
||||
<v-img
|
||||
:value="rom.id"
|
||||
:key="rom.id"
|
||||
v-bind="hoverProps"
|
||||
:src="`/assets/romm/resources/${rom.path_cover_l}`"
|
||||
:lazy-src="`/assets/romm/resources/${rom.path_cover_s}`"
|
||||
class="cover"
|
||||
cover
|
||||
>
|
||||
<template v-slot:placeholder>
|
||||
<div class="d-flex align-center justify-center fill-height">
|
||||
<v-progress-circular color="rommAccent1" :width="2" indeterminate />
|
||||
</div>
|
||||
</template>
|
||||
<v-expand-transition>
|
||||
<div
|
||||
v-if="isHovering || !rom.has_cover"
|
||||
class="rom-title d-flex transition-fast-in-fast-out bg-tooltip text-caption"
|
||||
<v-hover v-slot="{ isHovering, props }" open-delay="800">
|
||||
<v-img
|
||||
:value="rom.id"
|
||||
:key="rom.id"
|
||||
v-bind="props"
|
||||
:src="`/assets/romm/resources/${rom.path_cover_l}`"
|
||||
:lazy-src="`/assets/romm/resources/${rom.path_cover_s}`"
|
||||
class="cover"
|
||||
cover
|
||||
>
|
||||
<template v-slot:placeholder>
|
||||
<div class="d-flex align-center justify-center fill-height">
|
||||
<v-progress-circular
|
||||
color="romm-accent-1"
|
||||
:width="2"
|
||||
indeterminate
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<v-expand-transition>
|
||||
<div
|
||||
v-if="isHovering || !rom.has_cover"
|
||||
class="rom-title d-flex transition-fast-in-fast-out bg-tooltip text-caption"
|
||||
>
|
||||
<v-list-item>{{ rom.file_name }}</v-list-item>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
<v-chip-group class="pl-1 pt-0">
|
||||
<v-chip v-show="rom.region" size="x-small" class="bg-chip" label>
|
||||
{{ rom.region }}
|
||||
</v-chip>
|
||||
<v-chip v-show="rom.revision" size="x-small" class="bg-chip" label>
|
||||
{{ rom.revision }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
<v-icon
|
||||
v-show="isHoveringTop"
|
||||
@click="selectRom"
|
||||
size="small"
|
||||
class="position-absolute checkbox"
|
||||
:class="{ 'checkbox-selected': selected }"
|
||||
>{{ selected ? "mdi-circle-slice-8" : "mdi-circle-outline" }}</v-icon
|
||||
>
|
||||
<v-list-item>{{ rom.file_name }}</v-list-item>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
<v-chip-group class="pl-1 pt-0">
|
||||
<v-chip v-show="rom.region" size="x-small" class="bg-chip" label>
|
||||
{{ rom.region }}
|
||||
</v-chip>
|
||||
<v-chip v-show="rom.revision" size="x-small" class="bg-chip" label>
|
||||
{{ rom.revision }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</v-img>
|
||||
</v-img>
|
||||
</v-hover>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rom-title {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.rom-title.on-hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rom-title:not(.on-hover) {
|
||||
opacity: 0.85;
|
||||
.checkbox {
|
||||
bottom: 0.2rem;
|
||||
right: 0.2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
151
frontend/src/components/Game/DataTable/Base.vue
Normal file
151
frontend/src/components/Game/DataTable/Base.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup>
|
||||
import { ref, inject } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { downloadRomApi } from "@/services/api";
|
||||
import storeDownload from "@/stores/download";
|
||||
import useRomsStore from "@/stores/roms";
|
||||
import { VDataTable } from "vuetify/labs/VDataTable";
|
||||
import AdminMenu from "@/components/AdminMenu/Base.vue";
|
||||
|
||||
// Props
|
||||
const emitter = inject("emitter");
|
||||
const props = defineProps(["filteredRoms"]);
|
||||
const location = window.location.origin;
|
||||
const router = useRouter();
|
||||
const downloadStore = storeDownload();
|
||||
const romsStore = useRomsStore();
|
||||
const saveFiles = ref(false);
|
||||
const romsPerPage = ref(-1);
|
||||
const HEADERS = [
|
||||
{
|
||||
title: "",
|
||||
align: "start",
|
||||
sortable: false,
|
||||
key: "path_cover_s",
|
||||
},
|
||||
{
|
||||
title: "Name",
|
||||
align: "start",
|
||||
sortable: true,
|
||||
key: "r_name",
|
||||
},
|
||||
{
|
||||
title: "File",
|
||||
align: "start",
|
||||
sortable: true,
|
||||
key: "file_name",
|
||||
},
|
||||
{
|
||||
title: "Size",
|
||||
align: "start",
|
||||
sortable: true,
|
||||
key: "file_size",
|
||||
},
|
||||
{
|
||||
title: "Reg",
|
||||
align: "start",
|
||||
sortable: true,
|
||||
key: "region",
|
||||
},
|
||||
{
|
||||
title: "Rev",
|
||||
align: "start",
|
||||
sortable: true,
|
||||
key: "revision",
|
||||
},
|
||||
{ align: "end", key: "actions", sortable: false },
|
||||
];
|
||||
const PER_PAGE_OPTIONS = [
|
||||
{ value: -1, title: "$vuetify.dataFooter.itemsPerPageAll" },
|
||||
];
|
||||
|
||||
function rowClick(_, row) {
|
||||
router.push(
|
||||
`/platform/${row.item.selectable.p_slug}/${row.item.selectable.id}`
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-data-table
|
||||
:items-per-page="romsPerPage"
|
||||
:items-per-page-options="PER_PAGE_OPTIONS"
|
||||
items-per-page-text=""
|
||||
:headers="HEADERS"
|
||||
:item-value="item => item"
|
||||
:items="filteredRoms"
|
||||
@click:row="rowClick"
|
||||
show-select
|
||||
v-model="romsStore.selected"
|
||||
@update:model-value="emitter.emit('refreshSelected')"
|
||||
>
|
||||
<template v-slot:item.path_cover_s="{ item }">
|
||||
<v-avatar :rounded="0">
|
||||
<v-progress-linear
|
||||
color="romm-accent-1"
|
||||
:active="downloadStore.value.includes(item.selectable.id)"
|
||||
:indeterminate="true"
|
||||
absolute
|
||||
/>
|
||||
<v-img
|
||||
:src="`/assets/romm/resources/${item.selectable.path_cover_s}`"
|
||||
:lazy-src="`/assets/romm/resources/${item.selectable.path_cover_s}`"
|
||||
min-height="150"
|
||||
/>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<template v-slot:item.file_size="{ item }">
|
||||
<span
|
||||
>{{ item.selectable.file_size }}
|
||||
{{ item.selectable.file_size_units }}</span
|
||||
>
|
||||
</template>
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<template v-if="item.selectable.multi">
|
||||
<v-btn
|
||||
class="my-1"
|
||||
rounded="0"
|
||||
@click.stop="downloadRomApi(item.selectable)"
|
||||
:disabled="downloadStore.value.includes(item.selectable.id)"
|
||||
download
|
||||
size="small"
|
||||
variant="text"
|
||||
><v-icon>mdi-download</v-icon></v-btn
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn
|
||||
class="my-1"
|
||||
rounded="0"
|
||||
@click.stop=""
|
||||
:href="`${location}${item.selectable.download_path}`"
|
||||
download
|
||||
size="small"
|
||||
variant="text"
|
||||
><v-icon>mdi-download</v-icon></v-btn
|
||||
>
|
||||
</template>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="!saveFiles"
|
||||
class="my-1"
|
||||
rounded="0"
|
||||
><v-icon>mdi-content-save-all</v-icon></v-btn
|
||||
>
|
||||
<v-menu location="bottom">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
rounded="0"
|
||||
v-bind="props"
|
||||
size="small"
|
||||
variant="text"
|
||||
class="my-1"
|
||||
><v-icon>mdi-dots-vertical</v-icon></v-btn
|
||||
>
|
||||
</template>
|
||||
<admin-menu :rom="item.selectable" />
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<thead>
|
||||
<v-row no-gutters>
|
||||
<v-col>
|
||||
<v-list-item>
|
||||
<v-row class="text-subtitle-2 align-center">
|
||||
<v-col cols="9" xs="9" sm="6" md="3" lg="3"
|
||||
><span>Name</span></v-col
|
||||
>
|
||||
<v-col md="4" lg="4" class="hidden-sm-and-down"
|
||||
><span>File</span></v-col
|
||||
>
|
||||
<v-col md="1" lg="1" class="hidden-sm-and-down"
|
||||
><span>Platform</span></v-col
|
||||
>
|
||||
<v-col sm="2" md="2" lg="2" class="hidden-xs"
|
||||
><span>Size</span></v-col
|
||||
>
|
||||
<v-col sm="1" md="1" lg="1" class="hidden-xs"
|
||||
><span>Reg</span></v-col
|
||||
>
|
||||
<v-col sm="1" md="1" lg="1" class="hidden-xs"
|
||||
><span>Rev</span></v-col
|
||||
>
|
||||
</v-row>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar></v-avatar>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
<v-col cols="3" xs="3" sm="1" md="1" lg="1" class="mr-4">
|
||||
<v-btn size="x-small" variant="text" disabled />
|
||||
<v-btn size="x-small" variant="text" disabled />
|
||||
<v-btn size="x-small" variant="text" disabled />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</thead>
|
||||
</template>
|
||||
@@ -1,111 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, inject } from "vue";
|
||||
import { downloadRomApi } from "@/services/api.js";
|
||||
import useDownloadStore from "@/stores/download.js";
|
||||
import AdminMenu from "@/components/AdminMenu/Base.vue";
|
||||
|
||||
// Props
|
||||
const emitter = inject("emitter");
|
||||
const props = defineProps(["rom"]);
|
||||
const saveFiles = ref(false);
|
||||
const downloadStore = useDownloadStore();
|
||||
const downloadUrl = `${window.location.origin}${props.rom.download_path}`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-row no-gutters>
|
||||
<v-col>
|
||||
<v-list-item
|
||||
:to="`/platform/${$route.params.platform}/${rom.id}`"
|
||||
:value="rom.id"
|
||||
:key="rom.id"
|
||||
>
|
||||
<v-row class="text-subtitle-2 align-center">
|
||||
<v-col cols="9" xs="9" sm="6" md="3" lg="3"
|
||||
><span>{{ rom.r_name }}</span></v-col
|
||||
>
|
||||
<v-col md="4" lg="4" class="hidden-sm-and-down"
|
||||
><span>{{ rom.file_name }}</span></v-col
|
||||
>
|
||||
<v-col md="1" lg="1" class="hidden-sm-and-down"
|
||||
><span>{{ rom.p_slug }}</span></v-col
|
||||
>
|
||||
<v-col sm="2" md="2" lg="2" class="hidden-xs"
|
||||
><span>{{ rom.file_size }} {{ rom.file_size_units }}</span></v-col
|
||||
>
|
||||
<v-col sm="1" md="1" lg="1" class="hidden-xs"
|
||||
><span>{{ rom.region }}</span></v-col
|
||||
>
|
||||
<v-col sm="1" md="1" lg="1" class="hidden-xs"
|
||||
><span>{{ rom.revision }}</span></v-col
|
||||
>
|
||||
</v-row>
|
||||
|
||||
<template v-slot:prepend>
|
||||
<v-avatar :rounded="0">
|
||||
<v-progress-linear
|
||||
color="rommAccent1"
|
||||
:active="downloadStore.value.includes(rom.id)"
|
||||
:indeterminate="true"
|
||||
absolute
|
||||
/>
|
||||
<v-img
|
||||
:src="`/assets/romm/resources/${rom.path_cover_s}`"
|
||||
:lazy-src="`/assets/romm/resources/${rom.path_cover_s}`"
|
||||
min-height="150"
|
||||
/>
|
||||
</v-avatar>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="3"
|
||||
xs="3"
|
||||
sm="1"
|
||||
md="1"
|
||||
lg="1"
|
||||
class="d-flex justify-center align-center mr-4"
|
||||
>
|
||||
<template v-if="rom.multi">
|
||||
<v-btn
|
||||
@click="downloadRomApi(rom)"
|
||||
:disabled="downloadStore.value.includes(rom.id)"
|
||||
icon="mdi-download"
|
||||
size="x-small"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn
|
||||
:href="downloadUrl"
|
||||
download
|
||||
icon="mdi-download"
|
||||
size="x-small"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
/>
|
||||
</template>
|
||||
<v-btn
|
||||
icon="mdi-content-save-all"
|
||||
size="x-small"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
:disabled="!saveFiles"
|
||||
/>
|
||||
<v-menu location="bottom">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
@click=""
|
||||
v-bind="props"
|
||||
icon="mdi-dots-vertical"
|
||||
size="x-small"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
/>
|
||||
</template>
|
||||
<admin-menu :rom="rom" />
|
||||
</v-menu>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
@@ -16,14 +16,14 @@ emitter.on("snackbarShow", (snackbar) => {
|
||||
<template>
|
||||
<v-snackbar
|
||||
v-model="snackbarShow"
|
||||
:timeout="2000"
|
||||
:timeout="snackbarStatus.timeout ? snackbarStatus.timeout : 2000"
|
||||
location="top"
|
||||
color="tooltip"
|
||||
>
|
||||
<v-icon
|
||||
:icon="snackbarStatus.icon"
|
||||
:color="snackbarStatus.color"
|
||||
class="ml-2 mr-2"
|
||||
class="mx-2"
|
||||
/>
|
||||
{{ snackbarStatus.msg }}
|
||||
<template v-slot:actions>
|
||||
|
||||
@@ -9,15 +9,16 @@ const props = defineProps(["platform"]);
|
||||
<v-hover v-slot="{ isHovering, props }">
|
||||
<v-card
|
||||
v-bind="props"
|
||||
class="bg-terciary"
|
||||
:class="{ 'on-hover': isHovering }"
|
||||
:elevation="isHovering ? 20 : 3"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-row class="pa-1 justify-center bg-secondary">
|
||||
<span class="text-truncate text-overline">{{ platform.slug }}</span>
|
||||
<v-row class="pa-1 justify-center bg-primary">
|
||||
<div class="px-2 text-truncate text-overline">{{ platform.slug }}</div>
|
||||
</v-row>
|
||||
<v-row class="pa-1 justify-center">
|
||||
<v-avatar :rounded="0" size="100%" class="mt-2">
|
||||
<v-avatar :rounded="0" size="105" class="mt-2">
|
||||
<platform-icon :platform="platform"></platform-icon>
|
||||
</v-avatar>
|
||||
<v-chip
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps(["platform"]);
|
||||
const platformIconUrl = ref(`/assets/platforms/${props.platform.slug.toLowerCase()}.ico`);
|
||||
const platformIconUrl = ref(
|
||||
`/assets/platforms/${props.platform.slug.toLowerCase()}.ico`
|
||||
);
|
||||
|
||||
function onImageError() {
|
||||
platformIconUrl.value = "/assets/platforms/default.ico";
|
||||
@@ -10,5 +12,5 @@ function onImageError() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-img :src="platformIconUrl" @error="onImageError"></v-img>
|
||||
<v-img :src="platformIconUrl" @error="onImageError" />
|
||||
</template>
|
||||
|
||||
@@ -11,20 +11,16 @@ import PlatformIcon from "./PlatformIcon.vue";
|
||||
:key="platform"
|
||||
class="pt-4 pb-4 bg-terciary"
|
||||
>
|
||||
<span class="text-body-2 text-truncate">{{
|
||||
rail ? "" : platform.name
|
||||
}}</span>
|
||||
<span v-if="!rail" class="text-body-2">{{ platform.name }}</span>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar :rounded="0" size="40">
|
||||
<platform-icon :platform="platform"></platform-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-chip class="ml-4 bg-chip" size="x-small" label>{{
|
||||
<v-chip v-if="!rail" class="ml-4 bg-chip" size="x-small" label>{{
|
||||
platform.n_roms
|
||||
}}</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { createApp } from "vue";
|
||||
import { createVuetify } from "vuetify";
|
||||
import { registerPlugins } from "@/plugins";
|
||||
|
||||
import App from "./App.vue";
|
||||
|
||||
// Event bus
|
||||
import mitt from "mitt";
|
||||
const emitter = mitt();
|
||||
|
||||
export default createVuetify({
|
||||
defaults: {
|
||||
VBtn: {
|
||||
rounded: 0, // TODO: Vuetify global configuration not working
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
registerPlugins(app);
|
||||
|
||||
@@ -2,31 +2,51 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () => import("@/views/Login.vue"),
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: () => import("@/views/Home.vue"),
|
||||
},
|
||||
{
|
||||
path: "/platform/:platform",
|
||||
component: () => import("@/views/Gallery.vue"),
|
||||
},
|
||||
{
|
||||
path: "/platform/:platform/:rom",
|
||||
component: () => import("@/views/Details.vue"),
|
||||
},
|
||||
{
|
||||
path: "/library/scan",
|
||||
component: () => import("@/views/library/Scan.vue"),
|
||||
},
|
||||
{
|
||||
path: "/settings/control-panel",
|
||||
component: () => import("@/views/settings/ControlPanel.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
name: "dashboard",
|
||||
component: () => import("@/views/Dashboard/Base.vue"),
|
||||
},
|
||||
{
|
||||
path: "/platform/:platform",
|
||||
name: "platform",
|
||||
component: () => import("@/views/Gallery/Base.vue"),
|
||||
},
|
||||
{
|
||||
path: "/platform/:platform/:rom",
|
||||
name: "rom",
|
||||
component: () => import("@/views/Details/Base.vue"),
|
||||
},
|
||||
{
|
||||
path: "/library/scan",
|
||||
name: "scan",
|
||||
component: () => import("@/views/Library/Scan.vue"),
|
||||
},
|
||||
{
|
||||
path: "/settings/control-panel",
|
||||
name: "controlPanel",
|
||||
component: () => import("@/views/Settings/ControlPanel.vue"),
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "noMatch",
|
||||
component: () => import("@/views/Dashboard/Base.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
export default createRouter({
|
||||
history: createWebHistory(process.env.BASE_URL),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Styles
|
||||
import "@mdi/font/css/materialdesignicons.css";
|
||||
import "vuetify/styles";
|
||||
import { rommDark, rommLight } from "@/styles/themes.js";
|
||||
import { rommDark, rommLight } from "@/styles/themes";
|
||||
|
||||
// Composables
|
||||
import { createVuetify } from "vuetify";
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import axios from "axios";
|
||||
import useDownloadStore from "@/stores/download.js";
|
||||
import socket from "@/services/socket.js";
|
||||
import storeDownload from "@/stores/download";
|
||||
import socket from "@/services/socket";
|
||||
import router from "@/plugins/router";
|
||||
|
||||
export const api = axios.create({ baseURL: "/api", timeout: 120000 });
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response.status === 403) {
|
||||
router.push(`/login?next=${router.currentRoute.value.path}`);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export async function fetchPlatformsApi() {
|
||||
return axios.get("/api/platforms");
|
||||
return api.get("/platforms");
|
||||
}
|
||||
|
||||
export async function fetchRomsApi({
|
||||
@@ -12,17 +25,17 @@ export async function fetchRomsApi({
|
||||
size = 60,
|
||||
searchTerm = "",
|
||||
}) {
|
||||
return axios.get(
|
||||
`/api/platforms/${platform}/roms?cursor=${cursor}&size=${size}&search_term=${searchTerm}`
|
||||
return api.get(
|
||||
`/platforms/${platform}/roms?cursor=${cursor}&size=${size}&search_term=${searchTerm}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchRomApi(platform, rom) {
|
||||
return axios.get(`/api/platforms/${platform}/roms/${rom}`);
|
||||
return api.get(`/platforms/${platform}/roms/${rom}`);
|
||||
}
|
||||
|
||||
function clearRomFromDownloads({ id }) {
|
||||
const downloadStore = useDownloadStore();
|
||||
const downloadStore = storeDownload();
|
||||
downloadStore.remove(id);
|
||||
|
||||
// Disconnect socket when no more downloads are in progress
|
||||
@@ -46,13 +59,16 @@ export async function downloadRomApi(rom, files) {
|
||||
a.download = `${rom.r_name}.zip`;
|
||||
a.click();
|
||||
|
||||
if (!socket.connected) socket.connect();
|
||||
useDownloadStore().add(rom.id);
|
||||
// Only connect socket if multi-file download
|
||||
if (rom.multi) {
|
||||
if (!socket.connected) socket.connect();
|
||||
storeDownload().add(rom.id);
|
||||
|
||||
// Clear download state after 60 seconds in case error/timeout
|
||||
setTimeout(() => {
|
||||
clearRomFromDownloads(rom);
|
||||
}, 60 * 1000);
|
||||
// Clear download state after 60 seconds in case error/timeout
|
||||
setTimeout(() => {
|
||||
clearRomFromDownloads(rom);
|
||||
}, 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRomApi(rom, updatedData, renameAsIGDB) {
|
||||
@@ -67,20 +83,70 @@ export async function updateRomApi(rom, updatedData, renameAsIGDB) {
|
||||
? rom.file_name.replace(rom.file_name_no_tags, updatedData.r_name)
|
||||
: updatedData.file_name,
|
||||
};
|
||||
return axios.patch(`/api/platforms/${rom.p_slug}/roms/${rom.id}`, {
|
||||
|
||||
return api.patch(`/platforms/${rom.p_slug}/roms/${rom.id}`, {
|
||||
updatedRom,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteRomApi(rom, deleteFromFs) {
|
||||
return axios.delete(
|
||||
`/api/platforms/${rom.p_slug}/roms/${rom.id}?filesystem=${deleteFromFs}`
|
||||
return api.delete(
|
||||
`/platforms/${rom.p_slug}/roms/${rom.id}?filesystem=${deleteFromFs}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteRomsApi(roms, deleteFromFs) {
|
||||
return api.post(
|
||||
`/platforms/${roms[0].p_slug}/roms/delete?filesystem=${deleteFromFs}`,
|
||||
{ roms: roms.map((r) => r.id) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchRomIGDBApi(searchTerm, searchBy, rom) {
|
||||
return axios.put(
|
||||
`/api/search/roms/igdb?search_term=${searchTerm}&search_by=${searchBy}`,
|
||||
return api.put(
|
||||
`/search/roms/igdb?search_term=${searchTerm}&search_by=${searchBy}`,
|
||||
{ rom: rom }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchCurrentUserApi() {
|
||||
return api.get("/users/me");
|
||||
}
|
||||
|
||||
export async function fetchUsersApi() {
|
||||
return api.get("/users");
|
||||
}
|
||||
|
||||
export async function fetchUserApi(user) {
|
||||
return api.get(`/users/${user.id}`);
|
||||
}
|
||||
|
||||
export async function createUserApi({ username, password, role }) {
|
||||
return api.post("/users", {}, { params: { username, password, role } });
|
||||
}
|
||||
|
||||
export async function updateUserApi({
|
||||
id,
|
||||
username,
|
||||
password,
|
||||
role,
|
||||
enabled,
|
||||
avatar,
|
||||
}) {
|
||||
return api.put(
|
||||
`/users/${id}`,
|
||||
{
|
||||
avatar: avatar ? avatar[0] : null,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": avatar ? "multipart/form-data" : "text/text",
|
||||
},
|
||||
params: { username, password, role, enabled },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteUserApi(user) {
|
||||
return api.delete(`/users/${user.id}`);
|
||||
}
|
||||
|
||||
32
frontend/src/stores/auth.js
Normal file
32
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
const FULL_SCOPES_LIST = [
|
||||
"me.read",
|
||||
"me.write",
|
||||
"roms.read",
|
||||
"roms.write",
|
||||
"platforms.read",
|
||||
"platforms.write",
|
||||
"users.read",
|
||||
"users.write",
|
||||
];
|
||||
|
||||
export default defineStore("auth", {
|
||||
state: () => ({ enabled: false, user: null, oauth_scopes: [] }),
|
||||
|
||||
getters: {
|
||||
scopes: (state) => {
|
||||
if (!state.enabled) return FULL_SCOPES_LIST;
|
||||
return state.user?.oauth_scopes ?? [];
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
setUser(user) {
|
||||
this.user = user;
|
||||
},
|
||||
setEnabled(enabled) {
|
||||
this.enabled = enabled;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { normalizeString } from "@/utils/utils.js";
|
||||
import { normalizeString } from "@/utils/utils";
|
||||
|
||||
export default defineStore("galleryFilter", {
|
||||
state: () => ({ value: "" }),
|
||||
|
||||
26
frontend/src/stores/roms.js
Normal file
26
frontend/src/stores/roms.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export default defineStore("roms", {
|
||||
state: () => ({ selected: [], lastSelectedIndex: -1 }),
|
||||
|
||||
actions: {
|
||||
updateSelectedRoms(roms) {
|
||||
this.selected = roms;
|
||||
},
|
||||
addSelectedRoms(rom) {
|
||||
this.selected.push(rom);
|
||||
},
|
||||
removeSelectedRoms(rom) {
|
||||
this.selected = this.selected.filter(function (value) {
|
||||
return value.id != rom.id;
|
||||
});
|
||||
},
|
||||
updateLastSelectedRom(index) {
|
||||
this.lastSelectedIndex = index;
|
||||
},
|
||||
reset(){
|
||||
this.selected = [];
|
||||
this.lastSelectedIndex = -1;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #6e6e6e rgba(0, 0, 0, 0);
|
||||
scrollbar-color: #202832 rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/* Chrome, Edge, and Safari */
|
||||
*::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
@@ -15,5 +15,5 @@
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: #808080;
|
||||
border-radius: 5px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
const commonColors = {
|
||||
"romm-accent-1": "#a452fe",
|
||||
"romm-accent-2": "#c400f7",
|
||||
"romm-accent-3": "#3808a4",
|
||||
|
||||
"romm-red": "#da3633",
|
||||
"romm-green": "#3FB950",
|
||||
"romm-white": "#fefdfe",
|
||||
"romm-gray": "#5D5D5D",
|
||||
"romm-black": "#000000",
|
||||
};
|
||||
|
||||
export const rommDark = {
|
||||
dark: true,
|
||||
colors: {
|
||||
@@ -10,14 +22,7 @@ export const rommDark = {
|
||||
tooltip: "#202832",
|
||||
chip: "#161b22",
|
||||
|
||||
rommAccent1: "#a452fe",
|
||||
rommAccent2: "#c400f7",
|
||||
rommAccent3: "#3808a4",
|
||||
|
||||
rommRed: "#da3633",
|
||||
rommGreen: "#3FB950",
|
||||
rommWhite: "#fefdfe",
|
||||
rommBlack: "#000000",
|
||||
...commonColors,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,13 +38,11 @@ export const rommLight = {
|
||||
tooltip: "#fefdfe",
|
||||
chip: "#fefdfe",
|
||||
|
||||
rommAccent1: "#a452fe",
|
||||
rommAccent2: "#c400f7",
|
||||
rommAccent3: "#3808a4",
|
||||
|
||||
rommRed: "#da3633",
|
||||
rommGreen: "#3FB950",
|
||||
rommWhite: "#fefdfe",
|
||||
rommBlack: "#000000",
|
||||
...commonColors,
|
||||
},
|
||||
};
|
||||
|
||||
export const themes = {
|
||||
0: "rommDark",
|
||||
1: "rommLight",
|
||||
};
|
||||
|
||||
@@ -34,3 +34,5 @@ export function normalizeString(s) {
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "");
|
||||
}
|
||||
|
||||
export const defaultAvatarPath = "/assets/default_avatar.png";
|
||||
|
||||
26
frontend/src/views/Dashboard/Base.vue
Normal file
26
frontend/src/views/Dashboard/Base.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import storePlatforms from "@/stores/platforms";
|
||||
import dashboardSummary from "@/views/Dashboard/Summary.vue"
|
||||
import dashboardPlatforms from "@/views/Dashboard/Platforms.vue"
|
||||
|
||||
// Props
|
||||
const platforms = storePlatforms();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Summary -->
|
||||
<v-row class="pa-1" no-gutters>
|
||||
<v-col>
|
||||
<dashboard-summary />
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Platforms -->
|
||||
<template v-if="platforms.value.length > 0">
|
||||
<v-row class="pa-1" no-gutters>
|
||||
<v-col>
|
||||
<dashboard-platforms />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</template>
|
||||
34
frontend/src/views/Dashboard/Platforms.vue
Normal file
34
frontend/src/views/Dashboard/Platforms.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { views } from "@/utils/utils";
|
||||
import PlatformCard from "@/components/Platform/PlatformCard.vue";
|
||||
import storePlatforms from "@/stores/platforms";
|
||||
|
||||
// Props
|
||||
const platforms = storePlatforms();
|
||||
</script>
|
||||
<template>
|
||||
<v-card rounded="0">
|
||||
<v-toolbar class="bg-terciary" density="compact"
|
||||
><v-toolbar-title class="text-button"
|
||||
><v-icon class="mr-3">mdi-controller</v-icon>Platforms</v-toolbar-title
|
||||
></v-toolbar
|
||||
>
|
||||
<v-divider class="border-opacity-25" />
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="platform in platforms.value"
|
||||
class="pa-1"
|
||||
:key="platform.slug"
|
||||
:cols="views[0]['size-cols']"
|
||||
:xs="views[0]['size-xs']"
|
||||
:sm="views[0]['size-sm']"
|
||||
:md="views[0]['size-md']"
|
||||
:lg="views[0]['size-lg']"
|
||||
>
|
||||
<platform-card :platform="platform" :key="platform.slug" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
21
frontend/src/views/Dashboard/Summary.vue
Normal file
21
frontend/src/views/Dashboard/Summary.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import storePlatforms from "@/stores/platforms";
|
||||
|
||||
// Props
|
||||
const platforms = storePlatforms();
|
||||
</script>
|
||||
<template>
|
||||
<v-card rounded="0">
|
||||
<v-card-text class="py-0 px-2">
|
||||
<v-chip-group>
|
||||
<v-chip class="text-overline" variant="text" label>
|
||||
<v-icon class="mr-2">mdi-controller</v-icon
|
||||
>{{ platforms.value.length }} Platforms
|
||||
</v-chip>
|
||||
<v-chip class="text-overline" variant="text" label>
|
||||
<v-icon class="mr-2">mdi-disc</v-icon>{{ platforms.totalGames }} Games
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -2,18 +2,20 @@
|
||||
import { ref, inject, onBeforeMount } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useDisplay } from "vuetify";
|
||||
import { fetchRomApi, downloadRomApi } from "@/services/api.js";
|
||||
import useDownloadStore from "@/stores/download.js";
|
||||
import { fetchRomApi, downloadRomApi } from "@/services/api";
|
||||
import storeDownload from "@/stores/download";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import BackgroundHeader from "@/components/Game/Details/BackgroundHeader.vue";
|
||||
import AdminMenu from "@/components/AdminMenu/Base.vue";
|
||||
import SearchRomDialog from "@/components/Dialog/SearchRom.vue";
|
||||
import EditRomDialog from "@/components/Dialog/EditRom.vue";
|
||||
import DeleteRomDialog from "@/components/Dialog/DeleteRom.vue";
|
||||
import SearchRomDialog from "@/components/Dialog/Rom/SearchRom.vue";
|
||||
import EditRomDialog from "@/components/Dialog/Rom/EditRom.vue";
|
||||
import DeleteRomDialog from "@/components/Dialog/Rom/DeleteRom.vue";
|
||||
import LoadingDialog from "@/components/Dialog/Loading.vue";
|
||||
|
||||
// Props
|
||||
const route = useRoute();
|
||||
const downloadStore = useDownloadStore();
|
||||
const downloadStore = storeDownload();
|
||||
const auth = storeAuth();
|
||||
const rom = ref();
|
||||
const updatedRom = ref();
|
||||
const saveFiles = ref(false);
|
||||
@@ -68,7 +70,7 @@ onBeforeMount(async () => {
|
||||
elevation="2"
|
||||
:loading="
|
||||
downloadStore.value.includes(rom.id)
|
||||
? 'rommAccent1'
|
||||
? 'romm-accent-1'
|
||||
: null
|
||||
"
|
||||
>
|
||||
@@ -80,7 +82,7 @@ onBeforeMount(async () => {
|
||||
<template v-slot:placeholder>
|
||||
<div class="d-flex align-center justify-center fill-height">
|
||||
<v-progress-circular
|
||||
color="rommAccent1"
|
||||
color="romm-accent-1"
|
||||
:width="2"
|
||||
:size="20"
|
||||
indeterminate
|
||||
@@ -91,7 +93,7 @@ onBeforeMount(async () => {
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row class="pl-3 pr-3 action-buttons">
|
||||
<v-row class="px-3 action-buttons">
|
||||
<v-col class="pa-0">
|
||||
<template v-if="rom.multi">
|
||||
<v-btn
|
||||
@@ -124,7 +126,7 @@ onBeforeMount(async () => {
|
||||
<v-col class="pa-0">
|
||||
<v-menu location="bottom">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" rounded="0" block>
|
||||
<v-btn :disabled="!auth.scopes.includes('roms.write')" v-bind="props" rounded="0" block>
|
||||
<v-icon icon="mdi-dots-vertical" size="large" />
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -188,7 +190,7 @@ onBeforeMount(async () => {
|
||||
'details-content-mobile': xs,
|
||||
}"
|
||||
>
|
||||
<v-tabs v-model="tab" slider-color="rommAccent1" rounded="0">
|
||||
<v-tabs v-model="tab" slider-color="romm-accent-1" rounded="0">
|
||||
<v-tab value="details" rounded="0">Details</v-tab>
|
||||
<v-tab value="saves" rounded="0" disabled
|
||||
>Saves<span class="text-caption text-truncate ml-1"
|
||||
@@ -240,7 +242,7 @@ onBeforeMount(async () => {
|
||||
item-title="file_name"
|
||||
v-model="filesToDownload"
|
||||
:items="rom.files"
|
||||
class="mt-2 mb-2"
|
||||
class="my-2"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
return-object
|
||||
@@ -324,7 +326,7 @@ onBeforeMount(async () => {
|
||||
<v-carousel
|
||||
hide-delimiter-background
|
||||
delimiter-icon="mdi-square"
|
||||
class="bg-rommBlack"
|
||||
class="bg-romm-black"
|
||||
show-arrows="hover"
|
||||
height="400"
|
||||
>
|
||||
@@ -1,258 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, inject, onMounted } from "vue";
|
||||
import { onBeforeRouteUpdate, useRoute } from "vue-router";
|
||||
import { fetchRomsApi } from "@/services/api.js";
|
||||
import socket from "@/services/socket.js";
|
||||
import { views, normalizeString } from "@/utils/utils.js";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter.js";
|
||||
import storeGalleryView from "@/stores/galleryView.js";
|
||||
import storeScanning from "@/stores/scanning.js";
|
||||
import FilterBar from "@/components/GalleryAppBar/FilterBar.vue";
|
||||
import GalleryViewBtn from "@/components/GalleryAppBar/GalleryViewBtn.vue";
|
||||
import GameCard from "@/components/Game/Card/Base.vue";
|
||||
import GameListHeader from "@/components/Game/ListItem/Header.vue";
|
||||
import GameListItem from "@/components/Game/ListItem/Item.vue";
|
||||
import SearchRomDialog from "@/components/Dialog/SearchRom.vue";
|
||||
import EditRomDialog from "@/components/Dialog/EditRom.vue";
|
||||
import DeleteRomDialog from "@/components/Dialog/DeleteRom.vue";
|
||||
import LoadingDialog from "@/components/Dialog/Loading.vue";
|
||||
|
||||
// Props
|
||||
const route = useRoute();
|
||||
const roms = ref([]);
|
||||
const updatedRom = ref([]);
|
||||
const searchRoms = ref([]);
|
||||
const filteredRoms = ref([]);
|
||||
const galleryView = storeGalleryView();
|
||||
const galleryFilter = storeGalleryFilter();
|
||||
const gettingRoms = ref(false);
|
||||
const scanning = storeScanning();
|
||||
const cursor = ref("");
|
||||
const searchCursor = ref("");
|
||||
|
||||
// Event listeners bus
|
||||
const emitter = inject("emitter");
|
||||
emitter.on("filter", onFilterChange);
|
||||
|
||||
socket.on("scan:done", () => {
|
||||
scanning.set(false);
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: "Scan completed successfully!",
|
||||
icon: "mdi-check-bold",
|
||||
color: "green",
|
||||
});
|
||||
socket.disconnect();
|
||||
emitter.emit("refreshPlatforms");
|
||||
emitter.emit("refreshGallery");
|
||||
});
|
||||
|
||||
socket.on("scan:done_ko", (msg) => {
|
||||
scanning.set(false);
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: `Scan couldn't be completed. Something went wrong: ${msg}`,
|
||||
icon: "mdi-close-circle",
|
||||
color: "red",
|
||||
});
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
// Functions
|
||||
async function scan() {
|
||||
scanning.set(true);
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: `Scanning ${route.params.platform}...`,
|
||||
icon: "mdi-loading mdi-spin",
|
||||
color: "rommAccent1",
|
||||
});
|
||||
|
||||
if (!socket.connected) socket.connect();
|
||||
socket.emit("scan", route.params.platform, false);
|
||||
}
|
||||
|
||||
async function fetchMoreSearch() {
|
||||
if (searchCursor.value === null || gettingRoms.value) return;
|
||||
|
||||
gettingRoms.value = true;
|
||||
emitter.emit("showLoadingDialog", {
|
||||
loading: gettingRoms.value,
|
||||
scrim: false,
|
||||
});
|
||||
await fetchRomsApi({
|
||||
platform: route.params.platform,
|
||||
cursor: searchCursor.value,
|
||||
searchTerm: normalizeString(galleryFilter.value),
|
||||
})
|
||||
.then((response) => {
|
||||
searchRoms.value = [...searchRoms.value, ...response.data.items];
|
||||
filteredRoms.value = searchRoms.value;
|
||||
searchCursor.value = response.data.next_page;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Couldn't fetch roms for ${route.params.platform}: ${error}`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
gettingRoms.value = false;
|
||||
emitter.emit("showLoadingDialog", {
|
||||
loading: gettingRoms.value,
|
||||
scrim: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchMoreRoms(platform) {
|
||||
if (cursor.value === null || gettingRoms.value) return;
|
||||
|
||||
gettingRoms.value = true;
|
||||
emitter.emit("showLoadingDialog", {
|
||||
loading: gettingRoms.value,
|
||||
scrim: false,
|
||||
});
|
||||
await fetchRomsApi({ platform, cursor: cursor.value })
|
||||
.then((response) => {
|
||||
roms.value = [...roms.value, ...response.data.items];
|
||||
filteredRoms.value = roms.value;
|
||||
cursor.value = response.data.next_page;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Couldn't fetch roms for ${platform}: ${error}`);
|
||||
})
|
||||
.finally(() => {
|
||||
gettingRoms.value = false;
|
||||
emitter.emit("showLoadingDialog", {
|
||||
loading: gettingRoms.value,
|
||||
scrim: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
searchCursor.value = "";
|
||||
searchRoms.value = [];
|
||||
|
||||
if (galleryFilter.value === "") {
|
||||
filteredRoms.value = roms.value;
|
||||
return;
|
||||
}
|
||||
|
||||
fetchMoreSearch();
|
||||
}
|
||||
|
||||
function onGridScroll() {
|
||||
if (cursor.value === null && searchCursor.value === null) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
|
||||
const scrollOffset = 60;
|
||||
|
||||
// If we are close at the bottom of the page, fetch more roms
|
||||
if (scrollTop + clientHeight + scrollOffset >= scrollHeight) {
|
||||
galleryFilter.value
|
||||
? fetchMoreSearch()
|
||||
: fetchMoreRoms(route.params.platform);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
fetchMoreRoms(route.params.platform);
|
||||
});
|
||||
|
||||
onBeforeRouteUpdate(async (to, _) => {
|
||||
cursor.value = "";
|
||||
searchCursor.value = "";
|
||||
|
||||
roms.value = [];
|
||||
searchRoms.value = [];
|
||||
filteredRoms.value = [];
|
||||
|
||||
fetchMoreRoms(to.params.platform);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app-bar id="gallery-app-bar" elevation="0" density="compact">
|
||||
<filter-bar />
|
||||
<gallery-view-btn />
|
||||
<v-btn
|
||||
@click="scan"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
class="mr-0"
|
||||
icon="mdi-magnify-scan"
|
||||
/>
|
||||
</v-app-bar>
|
||||
|
||||
<template v-if="filteredRoms.length > 0 || gettingRoms">
|
||||
<!-- Gallery cards view -->
|
||||
<v-row
|
||||
id="grid-view"
|
||||
v-show="galleryView.value != 2"
|
||||
no-gutters
|
||||
v-scroll="onGridScroll"
|
||||
>
|
||||
<v-col
|
||||
v-for="rom in filteredRoms"
|
||||
class="pa-1"
|
||||
:key="rom.id"
|
||||
:cols="views[galleryView.value]['size-cols']"
|
||||
:xs="views[galleryView.value]['size-xs']"
|
||||
:sm="views[galleryView.value]['size-sm']"
|
||||
:md="views[galleryView.value]['size-md']"
|
||||
:lg="views[galleryView.value]['size-lg']"
|
||||
>
|
||||
<game-card :rom="rom" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Gallery list view -->
|
||||
<v-row v-show="galleryView.value == 2" no-gutters>
|
||||
<v-col
|
||||
:cols="views[galleryView.value]['size-cols']"
|
||||
:xs="views[galleryView.value]['size-xs']"
|
||||
:sm="views[galleryView.value]['size-sm']"
|
||||
:md="views[galleryView.value]['size-md']"
|
||||
:lg="views[galleryView.value]['size-lg']"
|
||||
>
|
||||
<v-table class="bg-secondary">
|
||||
<game-list-header />
|
||||
<v-divider
|
||||
class="border-opacity-100 mb-4 ml-2 mr-2"
|
||||
color="rommAccent1"
|
||||
:thickness="1"
|
||||
/>
|
||||
<tbody>
|
||||
<v-list class="bg-secondary">
|
||||
<v-list-item
|
||||
v-for="item in filteredRoms"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
>
|
||||
<game-list-item :rom="item" />
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<!-- Empty gallery message -->
|
||||
<template v-else>
|
||||
<v-row class="fill-height justify-center align-center" no-gutters>
|
||||
<div class="text-h6">
|
||||
Feels empty here... <v-icon>mdi-emoticon-sad</v-icon>
|
||||
</div>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<search-rom-dialog />
|
||||
<edit-rom-dialog />
|
||||
<delete-rom-dialog />
|
||||
<loading-dialog />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#gallery-app-bar {
|
||||
z-index: 999 !important;
|
||||
}
|
||||
</style>
|
||||
316
frontend/src/views/Gallery/Base.vue
Normal file
316
frontend/src/views/Gallery/Base.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<script setup>
|
||||
import { ref, inject, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onBeforeRouteUpdate, useRoute } from "vue-router";
|
||||
import { fetchRomsApi } from "@/services/api";
|
||||
import socket from "@/services/socket";
|
||||
import { views, normalizeString } from "@/utils/utils";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter";
|
||||
import storeGalleryView from "@/stores/galleryView";
|
||||
import storeRoms from "@/stores/roms";
|
||||
import storeScanning from "@/stores/scanning";
|
||||
import FilterBar from "@/components/GalleryAppBar/FilterBar.vue";
|
||||
import GalleryViewBtn from "@/components/GalleryAppBar/GalleryViewBtn.vue";
|
||||
import GameCard from "@/components/Game/Card/Base.vue";
|
||||
import GameDataTable from "@/components/Game/DataTable/Base.vue";
|
||||
import SearchRomDialog from "@/components/Dialog/Rom/SearchRom.vue";
|
||||
import EditRomDialog from "@/components/Dialog/Rom/EditRom.vue";
|
||||
import DeleteRomDialog from "@/components/Dialog/Rom/DeleteRom.vue";
|
||||
import LoadingDialog from "@/components/Dialog/Loading.vue";
|
||||
import FabMenu from "@/components/FabMenu/Base.vue";
|
||||
|
||||
// Props
|
||||
const route = useRoute();
|
||||
const roms = ref([]);
|
||||
const searchRoms = ref([]);
|
||||
const filteredRoms = ref([]);
|
||||
const galleryView = storeGalleryView();
|
||||
const galleryFilter = storeGalleryFilter();
|
||||
const gettingRoms = ref(false);
|
||||
const scanning = storeScanning();
|
||||
const cursor = ref("");
|
||||
const searchCursor = ref("");
|
||||
const romsStore = storeRoms();
|
||||
const fabMenu = ref(false);
|
||||
const scrolledToTop = ref(true);
|
||||
|
||||
// Event listeners bus
|
||||
const emitter = inject("emitter");
|
||||
emitter.on("filter", onFilterChange);
|
||||
emitter.on("openFabMenu", (open) => {
|
||||
fabMenu.value = open;
|
||||
});
|
||||
|
||||
socket.on("scan:done", () => {
|
||||
scanning.set(false);
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: "Scan completed successfully!",
|
||||
icon: "mdi-check-bold",
|
||||
color: "green",
|
||||
});
|
||||
socket.disconnect();
|
||||
emitter.emit("refreshDrawer");
|
||||
emitter.emit("refreshView");
|
||||
});
|
||||
|
||||
socket.on("scan:done_ko", (msg) => {
|
||||
scanning.set(false);
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: `Scan couldn't be completed. Something went wrong: ${msg}`,
|
||||
icon: "mdi-close-circle",
|
||||
color: "red",
|
||||
});
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
// Functions
|
||||
async function scan() {
|
||||
scanning.set(true);
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: `Scanning ${route.params.platform}...`,
|
||||
icon: "mdi-loading mdi-spin",
|
||||
color: "romm-accent-1",
|
||||
});
|
||||
|
||||
if (!socket.connected) socket.connect();
|
||||
socket.emit("scan", {
|
||||
platforms: [route.params.platform],
|
||||
rescan: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchRoms(platform) {
|
||||
const isFiltered = normalizeString(galleryFilter.value).trim() != "";
|
||||
|
||||
if (
|
||||
(searchCursor.value === null && isFiltered) ||
|
||||
(cursor.value === null && !isFiltered) ||
|
||||
gettingRoms.value
|
||||
)
|
||||
return;
|
||||
|
||||
gettingRoms.value = true;
|
||||
emitter.emit("showLoadingDialog", {
|
||||
loading: gettingRoms.value,
|
||||
scrim: false,
|
||||
});
|
||||
|
||||
await fetchRomsApi({
|
||||
platform: platform,
|
||||
cursor: isFiltered ? searchCursor.value : cursor.value,
|
||||
searchTerm: normalizeString(galleryFilter.value),
|
||||
})
|
||||
.then((response) => {
|
||||
if (isFiltered) {
|
||||
searchCursor.value = response.data.next_page;
|
||||
searchRoms.value = [...searchRoms.value, ...response.data.items];
|
||||
filteredRoms.value = searchRoms.value;
|
||||
} else {
|
||||
cursor.value = response.data.next_page;
|
||||
roms.value = [...roms.value, ...response.data.items];
|
||||
filteredRoms.value = roms.value;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Couldn't fetch roms for ${platform}: ${error}`);
|
||||
})
|
||||
.finally(() => {
|
||||
gettingRoms.value = false;
|
||||
emitter.emit("showLoadingDialog", {
|
||||
loading: gettingRoms.value,
|
||||
scrim: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
searchCursor.value = "";
|
||||
searchRoms.value = [];
|
||||
|
||||
if (galleryFilter.value === "") {
|
||||
filteredRoms.value = roms.value;
|
||||
return;
|
||||
}
|
||||
|
||||
fetchRoms(route.params.platform);
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
|
||||
scrolledToTop.value = scrollTop === 0;
|
||||
|
||||
if (!cursor.value && !searchCursor.value) return;
|
||||
|
||||
const scrollOffset = 60;
|
||||
if (scrollTop + clientHeight + scrollOffset >= scrollHeight) {
|
||||
fetchRoms(route.params.platform);
|
||||
}
|
||||
}
|
||||
|
||||
function toTop() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
left: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
function selectRom({ event, index, selected }) {
|
||||
if (event.shiftKey) {
|
||||
const [start, end] = [romsStore.lastSelectedIndex, index].sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
if (selected) {
|
||||
for (let i = start + 1; i < end; i++) {
|
||||
romsStore.addSelectedRoms(filteredRoms.value[i]);
|
||||
}
|
||||
} else {
|
||||
for (let i = start; i <= end; i++) {
|
||||
romsStore.removeSelectedRoms(filteredRoms.value[i]);
|
||||
}
|
||||
}
|
||||
romsStore.updateLastSelectedRom(selected ? index : index - 1);
|
||||
} else {
|
||||
romsStore.updateLastSelectedRom(index);
|
||||
}
|
||||
emitter.emit("refreshSelected");
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
fetchRoms(route.params.platform);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
socket.off("scan:scanning_rom");
|
||||
socket.off("scan:done");
|
||||
socket.off("scan:done_ko");
|
||||
romsStore.reset();
|
||||
});
|
||||
|
||||
onBeforeRouteUpdate(async (to, _) => {
|
||||
cursor.value = "";
|
||||
searchCursor.value = "";
|
||||
roms.value = [];
|
||||
searchRoms.value = [];
|
||||
filteredRoms.value = [];
|
||||
romsStore.reset();
|
||||
fetchRoms(to.params.platform);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app-bar id="gallery-app-bar" elevation="0" density="compact">
|
||||
<filter-bar />
|
||||
<gallery-view-btn />
|
||||
<v-btn
|
||||
@click="scan"
|
||||
rounded="0"
|
||||
variant="text"
|
||||
class="mr-0"
|
||||
icon="mdi-magnify-scan"
|
||||
/>
|
||||
</v-app-bar>
|
||||
|
||||
<template v-if="filteredRoms.length > 0">
|
||||
<v-row no-gutters v-scroll="onScroll">
|
||||
<!-- Gallery cards view -->
|
||||
<v-col
|
||||
v-show="galleryView.value != 2"
|
||||
v-for="rom in filteredRoms"
|
||||
class="pa-1"
|
||||
:key="rom.id"
|
||||
:cols="views[galleryView.value]['size-cols']"
|
||||
:xs="views[galleryView.value]['size-xs']"
|
||||
:sm="views[galleryView.value]['size-sm']"
|
||||
:md="views[galleryView.value]['size-md']"
|
||||
:lg="views[galleryView.value]['size-lg']"
|
||||
>
|
||||
<game-card
|
||||
:rom="rom"
|
||||
:index="filteredRoms.indexOf(rom)"
|
||||
@selectRom="selectRom"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Gallery list view -->
|
||||
<v-col v-show="galleryView.value == 2">
|
||||
<game-data-table :filteredRoms="filteredRoms" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<!-- Empty gallery message -->
|
||||
<template v-if="filteredRoms.length == 0 && !gettingRoms">
|
||||
<v-row class="align-center justify-center" no-gutters>
|
||||
<v-col cols="6" md="2">
|
||||
<div class="mt-16">
|
||||
Feels empty here... <v-icon>mdi-emoticon-sad</v-icon>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<v-layout-item
|
||||
v-scroll="onScroll"
|
||||
class="text-end"
|
||||
:model-value="true"
|
||||
position="bottom"
|
||||
size="88"
|
||||
>
|
||||
<div class="ma-4">
|
||||
<v-scroll-y-reverse-transition>
|
||||
<v-btn
|
||||
id="scrollToTop"
|
||||
v-show="!scrolledToTop"
|
||||
color="primary"
|
||||
elevation="8"
|
||||
icon
|
||||
class="mr-2"
|
||||
size="large"
|
||||
@click="toTop"
|
||||
><v-icon color="romm-accent-2">mdi-chevron-up</v-icon></v-btn
|
||||
>
|
||||
</v-scroll-y-reverse-transition>
|
||||
<v-menu
|
||||
location="top"
|
||||
v-model="fabMenu"
|
||||
:transition="
|
||||
fabMenu ? 'scroll-y-reverse-transition' : 'scroll-y-transition'
|
||||
"
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-fab-transition>
|
||||
<v-btn
|
||||
v-show="romsStore.selected.length > 0"
|
||||
color="romm-accent-1"
|
||||
v-bind="props"
|
||||
elevation="8"
|
||||
icon
|
||||
size="large"
|
||||
>{{ romsStore.selected.length }}</v-btn
|
||||
>
|
||||
</v-fab-transition>
|
||||
</template>
|
||||
|
||||
<fab-menu :filteredRoms="filteredRoms" />
|
||||
</v-menu>
|
||||
</div>
|
||||
</v-layout-item>
|
||||
|
||||
<search-rom-dialog />
|
||||
<edit-rom-dialog />
|
||||
<delete-rom-dialog />
|
||||
<loading-dialog />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#gallery-app-bar {
|
||||
z-index: 999 !important;
|
||||
}
|
||||
.game-card.game-selected {
|
||||
border: 2px solid rgba(var(--v-theme-romm-accent-2));
|
||||
padding: 0;
|
||||
}
|
||||
#scrollToTop {
|
||||
border: 1px solid rgba(var(--v-theme-romm-accent-2));
|
||||
}
|
||||
</style>
|
||||
@@ -1,78 +1,65 @@
|
||||
<script setup>
|
||||
import { ref, inject, onMounted } from "vue";
|
||||
import { useDisplay } from "vuetify";
|
||||
import { views } from "@/utils/utils.js";
|
||||
import storePlatforms from "@/stores/platforms.js";
|
||||
import PlatformCard from "@/components/Platform/PlatformCard.vue";
|
||||
import { fetchPlatformsApi, fetchCurrentUserApi } from "@/services/api";
|
||||
import storePlatforms from "@/stores/platforms";
|
||||
import storeScanning from "@/stores/scanning";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import Drawer from "@/components/Drawer/Base.vue";
|
||||
import AppBar from "@/components/AppBar/Base.vue";
|
||||
|
||||
// Props
|
||||
const { mdAndDown } = useDisplay();
|
||||
const platforms = storePlatforms();
|
||||
const totalGames = platforms.totalGames;
|
||||
const { lgAndUp } = useDisplay();
|
||||
const scanning = storeScanning();
|
||||
const auth = storeAuth();
|
||||
const refreshView = ref(false);
|
||||
const refreshDrawer = ref(false);
|
||||
|
||||
// Event listeners bus
|
||||
const emitter = inject("emitter");
|
||||
emitter.on("refreshView", () => {
|
||||
refreshView.value = !refreshView.value;
|
||||
});
|
||||
emitter.on("refreshDrawer", () => {
|
||||
refreshDrawer.value = !refreshDrawer.value;
|
||||
});
|
||||
|
||||
// Functions
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data: platformData } = await fetchPlatformsApi();
|
||||
platforms.set(platformData);
|
||||
const { data: userData } = await fetchCurrentUserApi();
|
||||
if (userData) auth.setUser(userData);
|
||||
emitter.emit("refreshDrawer");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Header logo -->
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-spacer />
|
||||
<v-col cols="12" xs="12" sm="10" md="10" lg="10">
|
||||
<v-img
|
||||
:height="lgAndUp ? 200 : 150"
|
||||
src="/assets/romm_complete.svg"
|
||||
cover
|
||||
/>
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
</v-row>
|
||||
<v-progress-linear
|
||||
id="scan-progress-bar"
|
||||
color="romm-accent-1"
|
||||
:active="scanning.value"
|
||||
:indeterminate="true"
|
||||
absolute
|
||||
fixed
|
||||
/>
|
||||
|
||||
<!-- Info chips -->
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-spacer />
|
||||
<v-col
|
||||
cols="12"
|
||||
xs="12"
|
||||
sm="10"
|
||||
md="10"
|
||||
lg="10"
|
||||
class="d-flex justify-center"
|
||||
>
|
||||
<v-chip-group>
|
||||
<v-chip class="bg-chip" label>
|
||||
<span class="text-overline">
|
||||
{{ platforms.value.length }} platforms
|
||||
</span>
|
||||
</v-chip>
|
||||
<v-chip class="bg-chip" label>
|
||||
<span class="text-overline">{{ totalGames }} games</span>
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
</v-row>
|
||||
<drawer :key="refreshDrawer" />
|
||||
|
||||
<template v-if="platforms.value.length > 0">
|
||||
<!-- Platforms section title -->
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-avatar :rounded="0" size="auto">
|
||||
<v-icon>mdi-controller</v-icon>
|
||||
</v-avatar>
|
||||
<span class="text-h6 ml-2">Platforms</span>
|
||||
<v-divider class="border-opacity-25" />
|
||||
</v-row>
|
||||
<app-bar v-if="mdAndDown" />
|
||||
|
||||
<!-- Platforms cards -->
|
||||
<v-row class="pa-2" no-gutters>
|
||||
<v-col
|
||||
v-for="platform in platforms.value"
|
||||
class="pa-1"
|
||||
:key="platform.slug"
|
||||
:cols="views[0]['size-cols']"
|
||||
:xs="views[0]['size-xs']"
|
||||
:sm="views[0]['size-sm']"
|
||||
:md="views[0]['size-md']"
|
||||
:lg="views[0]['size-lg']"
|
||||
>
|
||||
<platform-card :platform="platform" :key="platform.slug" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
<v-container class="pa-1" fluid>
|
||||
<router-view :key="refreshView" />
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#scan-progress-bar {
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, inject } from "vue";
|
||||
import socket from "@/services/socket.js";
|
||||
import storePlatforms from "@/stores/platforms.js";
|
||||
import storeScanning from "@/stores/scanning.js";
|
||||
import { ref, inject, onBeforeUnmount } from "vue";
|
||||
import socket from "@/services/socket";
|
||||
import storePlatforms from "@/stores/platforms";
|
||||
import storeScanning from "@/stores/scanning";
|
||||
import PlatformIcon from "@/components/Platform/PlatformIcon.vue";
|
||||
|
||||
// Props
|
||||
@@ -15,16 +15,8 @@ const completeRescan = ref(false);
|
||||
// Event listeners bus
|
||||
const emitter = inject("emitter");
|
||||
|
||||
function scrollToBottom() {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
|
||||
socket.on("scan:scanning_platform", ({ p_name, p_slug }) => {
|
||||
scannedPlatforms.value.push({
|
||||
name: p_name,
|
||||
slug: p_slug,
|
||||
roms: [],
|
||||
});
|
||||
socket.on("scan:scanning_platform", ({ name, slug }) => {
|
||||
scannedPlatforms.value.push({ name, slug, roms: [] });
|
||||
window.setTimeout(scrollToBottom, 100);
|
||||
});
|
||||
|
||||
@@ -48,8 +40,9 @@ socket.on("scan:scanning_rom", ({ p_slug, p_name, ...rom }) => {
|
||||
|
||||
socket.on("scan:done", () => {
|
||||
scanning.set(false);
|
||||
|
||||
emitter.emit("refreshPlatforms");
|
||||
|
||||
emitter.emit("refreshDrawer");
|
||||
emitter.emit("refreshView");
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: "Scan completed successfully!",
|
||||
icon: "mdi-check-bold",
|
||||
@@ -70,18 +63,28 @@ socket.on("scan:done_ko", (msg) => {
|
||||
});
|
||||
|
||||
// Functions
|
||||
async function scan() {
|
||||
function scrollToBottom() {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
|
||||
async function onScan() {
|
||||
scanning.set(true);
|
||||
scannedPlatforms.value = [];
|
||||
|
||||
if (!socket.connected) socket.connect();
|
||||
|
||||
socket.emit(
|
||||
"scan",
|
||||
platformsToScan.value.map((p) => p.fs_slug).join(","),
|
||||
completeRescan.value
|
||||
);
|
||||
socket.emit("scan", {
|
||||
platforms: platformsToScan.value.map((p) => p.fs_slug),
|
||||
rescan: completeRescan.value,
|
||||
});
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
socket.off("scan:scanning_platform");
|
||||
socket.off("scan:scanning_rom");
|
||||
socket.off("scan:done");
|
||||
socket.off("scan:done_ko");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -117,26 +120,26 @@ async function scan() {
|
||||
<!-- Scan button -->
|
||||
<v-row class="pa-4" no-gutters>
|
||||
<v-btn
|
||||
@click="scan()"
|
||||
@click="onScan()"
|
||||
:disabled="scanning.value"
|
||||
prepend-icon="mdi-magnify-scan"
|
||||
rounded="0"
|
||||
>
|
||||
<span v-if="!scanning.value">Scan</span>
|
||||
<v-progress-circular
|
||||
v-show="scanning.value"
|
||||
color="rommAccent1"
|
||||
class="ml-3 mr-2"
|
||||
:width="2"
|
||||
:size="20"
|
||||
indeterminate
|
||||
/>
|
||||
:loading="scanning.value"
|
||||
>Scan
|
||||
<template v-slot:loader>
|
||||
<v-progress-circular
|
||||
color="romm-accent-1"
|
||||
:width="2"
|
||||
:size="20"
|
||||
indeterminate
|
||||
/>
|
||||
</template>
|
||||
</v-btn>
|
||||
</v-row>
|
||||
|
||||
<v-divider
|
||||
class="border-opacity-100 ma-4"
|
||||
color="rommAccent1"
|
||||
color="romm-accent-1"
|
||||
:thickness="1"
|
||||
/>
|
||||
|
||||
118
frontend/src/views/Login.vue
Normal file
118
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup>
|
||||
import { ref, inject, onBeforeMount } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
// Props
|
||||
const auth = storeAuth();
|
||||
const emitter = inject("emitter");
|
||||
const router = useRouter();
|
||||
const username = ref();
|
||||
const password = ref();
|
||||
const visiblePassword = ref(false);
|
||||
|
||||
function login() {
|
||||
api
|
||||
.post(
|
||||
"/login",
|
||||
{},
|
||||
{
|
||||
auth: {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
},
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
const next = router.currentRoute.value.query?.next || "/";
|
||||
router.push(next);
|
||||
})
|
||||
.catch(({ response, message }) => {
|
||||
emitter.emit("snackbarShow", {
|
||||
msg: `Unable to login: ${
|
||||
response?.data?.detail || response?.statusText || message
|
||||
}`,
|
||||
icon: "mdi-close-circle",
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
// Check if authentication is enabled
|
||||
if (!auth.enabled) {
|
||||
return router.push("/");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span id="bg"></span>
|
||||
|
||||
<v-container class="fill-height justify-center">
|
||||
<v-card id="card" class="py-8 px-5" width="500">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-img
|
||||
src="/assets/isotipo.svg"
|
||||
class="mx-auto"
|
||||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
|
||||
<v-row class="justify-center">
|
||||
<v-col cols="10" md="8">
|
||||
<v-text-field
|
||||
@keyup.enter="login()"
|
||||
prepend-inner-icon="mdi-account"
|
||||
type="text"
|
||||
v-model="username"
|
||||
label="Username"
|
||||
variant="underlined"
|
||||
></v-text-field>
|
||||
<v-text-field
|
||||
@keyup.enter="login()"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
:type="visiblePassword ? 'text' : 'password'"
|
||||
v-model="password"
|
||||
label="Password"
|
||||
variant="underlined"
|
||||
:append-inner-icon="visiblePassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
@click:append-inner="visiblePassword = !visiblePassword"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="justify-center">
|
||||
<v-col cols="10" md="8">
|
||||
<v-btn
|
||||
@click="login()"
|
||||
append-icon="mdi-chevron-right-circle-outline"
|
||||
block
|
||||
>Login</v-btn
|
||||
>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#bg {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background: url("/assets/login_bg.png") center center;
|
||||
background-size: cover;
|
||||
-webkit-filter: blur(3px);
|
||||
filter: blur(3px);
|
||||
}
|
||||
#card {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
</style>
|
||||
54
frontend/src/views/Settings/ControlPanel.vue
Normal file
54
frontend/src/views/Settings/ControlPanel.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import Users from "@/views/Settings/Users/Users.vue";
|
||||
import Theme from "@/views/Settings/UserInterface/Theme.vue";
|
||||
import version from "../../../package";
|
||||
|
||||
// Props
|
||||
const auth = storeAuth();
|
||||
const tab = ref(auth.scopes.includes("users.read") ? "users" : "ui");
|
||||
const ROMM_VERSION = version.version;
|
||||
</script>
|
||||
<template>
|
||||
<!-- Settings tabs -->
|
||||
<v-app-bar elevation="0" density="compact">
|
||||
<v-tabs v-model="tab" slider-color="romm-accent-1" class="bg-primary">
|
||||
<v-tab
|
||||
:disabled="!auth.scopes.includes('users.read')"
|
||||
value="users"
|
||||
rounded="0"
|
||||
>
|
||||
Users
|
||||
</v-tab>
|
||||
<v-tab value="ui" rounded="0">User Interface</v-tab>
|
||||
</v-tabs>
|
||||
</v-app-bar>
|
||||
|
||||
<v-window v-model="tab">
|
||||
<!-- Users tab -->
|
||||
<v-window-item value="users">
|
||||
<v-row class="pa-1">
|
||||
<v-col>
|
||||
<users />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-window-item>
|
||||
|
||||
<!-- User Interface tab -->
|
||||
<v-window-item value="ui">
|
||||
<v-row class="pa-1">
|
||||
<v-col>
|
||||
<theme />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
|
||||
<v-bottom-navigation :elevation="0" height="36" class="text-caption">
|
||||
<v-row class="align-center justify-center" no-gutters>
|
||||
<span class="text-romm-accent-1">RomM</span>
|
||||
<span class="ml-1">{{ ROMM_VERSION }}</span>
|
||||
</v-row>
|
||||
</v-bottom-navigation>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user