Add settings page (#1143)

This commit is contained in:
Zoe Roux 2025-11-09 18:49:42 +01:00 committed by GitHub
commit 4f9d340ef4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 1277 additions and 1147 deletions

View File

@ -276,7 +276,7 @@ set
username = coalesce($2, username), username = coalesce($2, username),
email = coalesce($3, email), email = coalesce($3, email),
password = coalesce($4, password), password = coalesce($4, password),
claims = coalesce($5, claims) claims = claims || coalesce($5, '{}'::jsonb)
where where
id = $1 id = $1
returning returning

View File

@ -10,6 +10,6 @@ pkgs.mkShell {
postgresql_15 postgresql_15
pgformatter pgformatter
# to run tests # to run tests
# hurl hurl
]; ];
} }

View File

@ -72,7 +72,7 @@ set
username = coalesce(sqlc.narg(username), username), username = coalesce(sqlc.narg(username), username),
email = coalesce(sqlc.narg(email), email), email = coalesce(sqlc.narg(email), email),
password = coalesce(sqlc.narg(password), password), password = coalesce(sqlc.narg(password), password),
claims = coalesce(sqlc.narg(claims), claims) claims = claims || coalesce(sqlc.narg(claims), '{}'::jsonb)
where where
id = $1 id = $1
returning returning

View File

@ -26,7 +26,16 @@ jwt: jsonpath "$.token"
PATCH {{host}}/users/me/password PATCH {{host}}/users/me/password
Authorization: Bearer {{jwt}} Authorization: Bearer {{jwt}}
{ {
"password": "new-password" "oldPassword": "invalid-one",
"newPassword": "wont-be-changed"
}
HTTP 403
PATCH {{host}}/users/me/password
Authorization: Bearer {{jwt}}
{
"oldPassword": "password-login-user",
"newPassword": "new-password"
} }
HTTP 204 HTTP 204

View File

@ -59,7 +59,8 @@ type EditUserDto struct {
} }
type EditPasswordDto struct { type EditPasswordDto struct {
Password string `json:"password" validate:"required" example:"password1234"` OldPassword string `json:"oldPassword" validate:"required" example:"password1234"`
NewPassword string `json:"newPassword" validate:"required" example:"password1234"`
} }
func MapDbUser(user *dbc.User) User { func MapDbUser(user *dbc.User) User {
@ -182,7 +183,7 @@ func (h *Handler) GetMe(c echo.Context) error {
if err != nil { if err != nil {
return err return err
} }
dbuser, err := h.db.GetUser(context.Background(), dbc.GetUserParams{ dbuser, err := h.db.GetUser(c.Request().Context(), dbc.GetUserParams{
UseId: true, UseId: true,
Id: id, Id: id,
}) })
@ -406,6 +407,10 @@ func (h *Handler) ChangePassword(c echo.Context) error {
if err != nil { if err != nil {
return err return err
} }
user, err := h.db.GetUser(c.Request().Context(), dbc.GetUserParams{
UseId: true,
Id: uid,
})
sid, err := GetCurrentSessionId(c) sid, err := GetCurrentSessionId(c)
if err != nil { if err != nil {
@ -421,13 +426,26 @@ func (h *Handler) ChangePassword(c echo.Context) error {
return err return err
} }
match, err := argon2id.ComparePasswordAndHash(
req.OldPassword,
*user[0].User.Password,
)
if err != nil {
return err
}
if !match {
return echo.NewHTTPError(http.StatusForbidden, "Invalid password")
}
pass, err := argon2id.CreateHash(req.NewPassword, argon2id.DefaultParams)
if err != nil {
return err
}
_, err = h.db.UpdateUser(context.Background(), dbc.UpdateUserParams{ _, err = h.db.UpdateUser(context.Background(), dbc.UpdateUserParams{
Id: uid, Id: uid,
Password: &req.Password, Password: &pass,
}) })
if err == pgx.ErrNoRows { if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "Invalid token, user not found")
} else if err != nil {
return err return err
} }

View File

@ -1,12 +1,14 @@
FROM oven/bun AS builder FROM oven/bun AS builder
WORKDIR /app WORKDIR /app
# idk why it doesnt' build without it # https://github.com/oven-sh/bun/issues/24538
# Remember to also remove `tsx` after this bug is fixed
RUN apt update && apt install -y nodejs && rm /usr/local/bun-node-fallback-bin/node RUN apt update && apt install -y nodejs && rm /usr/local/bun-node-fallback-bin/node
ENV NODE_ENV=production ENV NODE_ENV=production
COPY package.json bun.lock scripts . COPY package.json bun.lock .
COPY scripts scripts COPY scripts scripts
COPY public public
RUN bun install --production RUN bun install --production
COPY . . COPY . .

View File

@ -3,6 +3,7 @@ WORKDIR /app
COPY package.json bun.lock . COPY package.json bun.lock .
COPY scripts scripts COPY scripts scripts
COPY public public
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
COPY . . COPY . .

View File

@ -1,4 +1,6 @@
import "tsx/cjs";
import type { ExpoConfig } from "expo/config"; import type { ExpoConfig } from "expo/config";
import { supportedLanguages } from "./src/providers/translations.compile.ts";
const IS_DEV = process.env.APP_VARIANT === "development"; const IS_DEV = process.env.APP_VARIANT === "development";
@ -75,6 +77,12 @@ export const expo: ExpoConfig = {
}, },
}, },
], ],
[
"react-native-localization-settings",
{
languages: supportedLanguages,
}
]
], ],
experiments: { experiments: {
typedRoutes: true, typedRoutes: true,

View File

@ -1,6 +1,9 @@
{ {
"extends": "//", "extends": "//",
"files": { "files": {
"includes": ["src/**"] "includes": [
"src/**",
"scripts/**"
]
} }
} }

View File

@ -28,6 +28,7 @@
"expo-splash-screen": "^31.0.10", "expo-splash-screen": "^31.0.10",
"expo-status-bar": "~3.0.8", "expo-status-bar": "~3.0.8",
"expo-updates": "~29.0.11", "expo-updates": "~29.0.11",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"jassub": "^1.8.6", "jassub": "^1.8.6",
"langmap": "^0.0.16", "langmap": "^0.0.16",
@ -36,6 +37,7 @@
"react-i18next": "^16.1.0", "react-i18next": "^16.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-get-random-values": "^2.0.0", "react-native-get-random-values": "^2.0.0",
"react-native-localization-settings": "^1.2.0",
"react-native-mmkv": "^3.3.3", "react-native-mmkv": "^3.3.3",
"react-native-nitro-modules": "^0.30.2", "react-native-nitro-modules": "^0.30.2",
"react-native-reanimated": "~4.1.2", "react-native-reanimated": "~4.1.2",
@ -48,6 +50,7 @@
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"react-tooltip": "^5.29.1", "react-tooltip": "^5.29.1",
"sweetalert2": "^11.26.3", "sweetalert2": "^11.26.3",
"tsx": "^4.20.6",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"video.js": "^8.23.4", "video.js": "^8.23.4",
"yoshiki": "1.2.14", "yoshiki": "1.2.14",
@ -271,6 +274,58 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yx0CqeOhPjYQ5ZXgPfu8QYkgBhVJyvWe36as7jRuPrKPO5ylVDfwVtPQ+K/mooNTADW0IhxOZm3aPu16dP8yNQ=="], "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yx0CqeOhPjYQ5ZXgPfu8QYkgBhVJyvWe36as7jRuPrKPO5ylVDfwVtPQ+K/mooNTADW0IhxOZm3aPu16dP8yNQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@expo/cli": ["@expo/cli@54.0.12", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devcert": "^1.1.2", "@expo/env": "~2.0.7", "@expo/image-utils": "^0.8.7", "@expo/json-file": "^10.0.7", "@expo/mcp-tunnel": "~0.0.7", "@expo/metro": "~54.1.0", "@expo/metro-config": "~54.0.7", "@expo/osascript": "^2.3.7", "@expo/package-manager": "^1.9.8", "@expo/plist": "^0.4.7", "@expo/prebuild-config": "^54.0.5", "@expo/schema-utils": "^0.1.7", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.81.4", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "expo-server": "^1.0.2", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.4.3", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "build/bin/cli" } }, "sha512-aBwpzG8z5U4b51S3T5MRIRe+NOOW2KdJ7cvJD8quL2Ba9gZRw8UVb+pmL28tS9yL3r1r3n8b1COSaJ8Y0eRTFA=="], "@expo/cli": ["@expo/cli@54.0.12", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devcert": "^1.1.2", "@expo/env": "~2.0.7", "@expo/image-utils": "^0.8.7", "@expo/json-file": "^10.0.7", "@expo/mcp-tunnel": "~0.0.7", "@expo/metro": "~54.1.0", "@expo/metro-config": "~54.0.7", "@expo/osascript": "^2.3.7", "@expo/package-manager": "^1.9.8", "@expo/plist": "^0.4.7", "@expo/prebuild-config": "^54.0.5", "@expo/schema-utils": "^0.1.7", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.81.4", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "expo-server": "^1.0.2", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.4.3", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "build/bin/cli" } }, "sha512-aBwpzG8z5U4b51S3T5MRIRe+NOOW2KdJ7cvJD8quL2Ba9gZRw8UVb+pmL28tS9yL3r1r3n8b1COSaJ8Y0eRTFA=="],
"@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.5", "", { "dependencies": { "node-forge": "^1.2.1", "nullthrows": "^1.1.1" } }, "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw=="], "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.5", "", { "dependencies": { "node-forge": "^1.2.1", "nullthrows": "^1.1.1" } }, "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw=="],
@ -781,6 +836,8 @@
"error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@ -897,6 +954,8 @@
"get-package-type": ["get-package-type@0.1.0", "", {}, "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="], "get-package-type": ["get-package-type@0.1.0", "", {}, "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="],
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
"getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], "getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
"glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], "glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
@ -927,6 +986,8 @@
"i18next": ["i18next@25.6.0", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw=="], "i18next": ["i18next@25.6.0", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw=="],
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="],
"i18next-http-backend": ["i18next-http-backend@3.0.2", "", { "dependencies": { "cross-fetch": "4.0.0" } }, "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g=="], "i18next-http-backend": ["i18next-http-backend@3.0.2", "", { "dependencies": { "cross-fetch": "4.0.0" } }, "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@ -1259,6 +1320,8 @@
"react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="],
"react-native-localization-settings": ["react-native-localization-settings@1.2.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-pxX/mfokqjwIdb1zINuN6DLL4PeVHTaIGz2Tk833tS94fmpsSuPoYnkCmtXsfvZjxhDOSsRceao/JutJbIlpIQ=="],
"react-native-mmkv": ["react-native-mmkv@3.3.3", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-GMsfOmNzx0p5+CtrCFRVtpOOMYNJXuksBVARSQrCFaZwjUyHJdQzcN900GGaFFNTxw2fs8s5Xje//RDKj9+PZA=="], "react-native-mmkv": ["react-native-mmkv@3.3.3", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-GMsfOmNzx0p5+CtrCFRVtpOOMYNJXuksBVARSQrCFaZwjUyHJdQzcN900GGaFFNTxw2fs8s5Xje//RDKj9+PZA=="],
"react-native-nitro-modules": ["react-native-nitro-modules@0.30.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+/uVS7FQwOiKYZQERMIvBRv5/X3CVHrFG6Nr/kIhVfVxGeUimHnBz7cgA97lJKIn7AKDRWL+UjLedW8pGOt0dg=="], "react-native-nitro-modules": ["react-native-nitro-modules@0.30.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+/uVS7FQwOiKYZQERMIvBRv5/X3CVHrFG6Nr/kIhVfVxGeUimHnBz7cgA97lJKIn7AKDRWL+UjLedW8pGOt0dg=="],
@ -1313,6 +1376,8 @@
"resolve-global": ["resolve-global@1.0.0", "", { "dependencies": { "global-dirs": "^0.1.1" } }, "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw=="], "resolve-global": ["resolve-global@1.0.0", "", { "dependencies": { "global-dirs": "^0.1.1" } }, "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve-workspace-root": ["resolve-workspace-root@2.0.0", "", {}, "sha512-IsaBUZETJD5WsI11Wt8PKHwaIe45or6pwNc8yflvLJ4DWtImK9kuLoH5kUva/2Mmx/RdIyr4aONNSa2v9LTJsw=="], "resolve-workspace-root": ["resolve-workspace-root@2.0.0", "", {}, "sha512-IsaBUZETJD5WsI11Wt8PKHwaIe45or6pwNc8yflvLJ4DWtImK9kuLoH5kUva/2Mmx/RdIyr4aONNSa2v9LTJsw=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="], "resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
@ -1447,6 +1512,8 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="],
"type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="],
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], "type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],

View File

@ -39,6 +39,7 @@
"expo-splash-screen": "^31.0.10", "expo-splash-screen": "^31.0.10",
"expo-status-bar": "~3.0.8", "expo-status-bar": "~3.0.8",
"expo-updates": "~29.0.11", "expo-updates": "~29.0.11",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"jassub": "^1.8.6", "jassub": "^1.8.6",
"langmap": "^0.0.16", "langmap": "^0.0.16",
@ -47,6 +48,7 @@
"react-i18next": "^16.1.0", "react-i18next": "^16.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-get-random-values": "^2.0.0", "react-native-get-random-values": "^2.0.0",
"react-native-localization-settings": "^1.2.0",
"react-native-mmkv": "^3.3.3", "react-native-mmkv": "^3.3.3",
"react-native-nitro-modules": "^0.30.2", "react-native-nitro-modules": "^0.30.2",
"react-native-reanimated": "~4.1.2", "react-native-reanimated": "~4.1.2",
@ -59,6 +61,7 @@
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"react-tooltip": "^5.29.1", "react-tooltip": "^5.29.1",
"sweetalert2": "^11.26.3", "sweetalert2": "^11.26.3",
"tsx": "^4.20.6",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"video.js": "^8.23.4", "video.js": "^8.23.4",
"yoshiki": "1.2.14", "yoshiki": "1.2.14",

View File

@ -1,103 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { deleteData, setUserTheme, storeData, useUserTheme } from "@kyoo/models";
import { Link, Select } from "@kyoo/primitives";
import { useTranslation } from "react-i18next";
import { Preference, SettingsContainer } from "./base";
import Theme from "@material-symbols/svg-400/outlined/dark_mode.svg";
import Language from "@material-symbols/svg-400/outlined/language.svg";
import Android from "@material-symbols/svg-400/rounded/android.svg";
import Public from "@material-symbols/svg-400/rounded/public.svg";
import { useLanguageName } from "../utils";
export const GeneralSettings = () => {
const { t, i18n } = useTranslation();
const theme = useUserTheme("auto");
const getLanguageName = useLanguageName();
const changeLanguage = (lang: string) => {
if (lang === "system") {
i18n.changeLanguage(i18n.systemLanguage);
deleteData("language");
return;
}
storeData("language", lang);
i18n.changeLanguage(lang);
};
return (
<SettingsContainer title={t("settings.general.label")}>
<Preference
icon={Theme}
label={t("settings.general.theme.label")}
description={t("settings.general.theme.description")}
>
<Select
label={t("settings.general.theme.label")}
value={theme}
onValueChange={(value) => setUserTheme(value)}
values={["auto", "light", "dark"]}
getLabel={(key) => t(`settings.general.theme.${key}`)}
/>
</Preference>
<Preference
icon={Language}
label={t("settings.general.language.label")}
description={t("settings.general.language.description")}
>
<Select
label={t("settings.general.language.label")}
value={i18n.resolvedLanguage! === i18n.systemLanguage ? "system" : i18n.resolvedLanguage!}
onValueChange={(value) => changeLanguage(value)}
values={["system", ...Object.keys(i18n.options.resources!)]}
getLabel={(key) =>
key === "system" ? t("settings.general.language.system") : (getLanguageName(key) ?? key)
}
/>
</Preference>
</SettingsContainer>
);
};
export const About = () => {
const { t } = useTranslation();
return (
<SettingsContainer title={t("settings.about.label")}>
<Link href="https://github.com/zoriya/kyoo/releases/latest/download/kyoo.apk" target="_blank">
<Preference
icon={Android}
label={t("settings.about.android-app.label")}
description={t("settings.about.android-app.description")}
/>
</Link>
<Link href="https://github.com/zoriya/kyoo" target="_blank">
<Preference
icon={Public}
label={t("settings.about.git.label")}
description={t("settings.about.git.description")}
/>
</Link>
</SettingsContainer>
);
};

View File

@ -1,44 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { type QueryPage, useAccount } from "@kyoo/models";
import { ts } from "@kyoo/primitives";
import { ScrollView } from "react-native";
import { DefaultLayout } from "../layout";
import { AccountSettings } from "./account";
import { About, GeneralSettings } from "./general";
import { OidcSettings } from "./oidc";
import { PlaybackSettings } from "./playback";
export const SettingsPage: QueryPage = () => {
const account = useAccount();
return (
<ScrollView contentContainerStyle={{ gap: ts(4), paddingBottom: ts(4) }}>
<GeneralSettings />
{account && <PlaybackSettings />}
{account && <AccountSettings />}
{account && <OidcSettings />}
<About />
</ScrollView>
);
};
SettingsPage.getLayout = DefaultLayout;
SettingsPage.getFetchUrls = () => [OidcSettings.query()];

View File

@ -1,128 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import {
type QueryIdentifier,
type ServerInfo,
ServerInfoP,
queryFn,
useAccount,
useFetch,
} from "@kyoo/models";
import { Button, IconButton, Link, Skeleton, tooltip, ts } from "@kyoo/primitives";
import { useTranslation } from "react-i18next";
import { ImageBackground } from "react-native";
import { rem, useYoshiki } from "yoshiki/native";
import { ErrorView } from "../../../../src/ui/errors";
import { Preference, SettingsContainer } from "./base";
import Badge from "@material-symbols/svg-400/outlined/badge.svg";
import Remove from "@material-symbols/svg-400/outlined/close.svg";
import OpenProfile from "@material-symbols/svg-400/outlined/open_in_new.svg";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export const OidcSettings = () => {
const account = useAccount()!;
const { css } = useYoshiki();
const { t } = useTranslation();
const { data, error } = useFetch(OidcSettings.query());
const queryClient = useQueryClient();
const { mutateAsync: unlinkAccount } = useMutation({
mutationFn: async (provider: string) =>
await queryFn({
path: ["auth", "login", provider],
method: "DELETE",
}),
onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }),
});
return (
<SettingsContainer title={t("settings.oidc.label")}>
{error ? (
<ErrorView error={error} />
) : data ? (
Object.entries(data.oidc).map(([id, x]) => {
const acc = account.externalId[id];
return (
<Preference
key={x.displayName}
icon={Badge}
label={x.displayName}
description={
acc
? t("settings.oidc.connected", { username: acc.username })
: t("settings.oidc.not-connected")
}
customIcon={
x.logoUrl != null && (
<ImageBackground
source={{ uri: x.logoUrl }}
{...css({ width: ts(3), height: ts(3), marginRight: ts(2) })}
/>
)
}
>
{acc ? (
<>
{acc.profileUrl && (
<IconButton
icon={OpenProfile}
as={Link}
href={acc.profileUrl}
target="_blank"
{...tooltip(t("settings.oidc.open-profile", { provider: x.displayName }))}
/>
)}
<IconButton
icon={Remove}
onPress={() => unlinkAccount(id)}
{...tooltip(t("settings.oidc.delete", { provider: x.displayName }))}
/>
</>
) : (
<Button
text={t("settings.oidc.link")}
as={Link}
href={x.link}
{...css({ minWidth: rem(6) })}
/>
)}
</Preference>
);
})
) : (
[...Array(3)].map((_, i) => (
<Preference
key={i}
customIcon={<Skeleton {...css({ width: ts(3), height: ts(3) })} />}
icon={null!}
label={<Skeleton {...css({ width: rem(6) })} />}
description={<Skeleton {...css({ width: rem(7), height: rem(0.8) })} />}
/>
))
)}
</SettingsContainer>
);
};
OidcSettings.query = (): QueryIdentifier<ServerInfo> => ({
path: ["info"],
parser: ServerInfoP,
});

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "ያጫውቱ", "play": "ያጫውቱ",
"trailer": "ትሬለሩን አጫውት", "trailer": "ትሬለሩን አጫውት",
"studio": "ስቱዲዮ", "studios": "ስቱዲዮ",
"genre": "ዘውግ", "genre": "ዘውግ",
"genre-none": "ዘውግ የለውም", "genre-none": "ዘውግ የለውም",
"staff": "ስታፍ", "staff": "ስታፍ",
@ -70,30 +70,29 @@
"switchToList": "" "switchToList": ""
}, },
"genres": { "genres": {
"Action": "", "action": "",
"Adventure": "", "adventure": "",
"Animation": "", "animation": "",
"Comedy": "", "comedy": "",
"Crime": "", "crime": "",
"Documentary": "", "documentary": "",
"Drama": "", "drama": "",
"Family": "", "family": "",
"Fantasy": "", "fantasy": "",
"History": "", "history": "",
"Horror": "", "horror": "",
"Music": "", "music": "",
"Mystery": "", "mystery": "",
"Romance": "", "romance": "",
"ScienceFiction": "", "science-fiction": "",
"Thriller": "", "thriller": "",
"War": "", "war": "",
"Western": "", "western": "",
"Kids": "", "kids": "",
"News": "", "reality": "",
"Reality": "", "soap": "",
"Soap": "", "talk": "",
"Talk": "", "politics": ""
"Politics": ""
}, },
"misc": { "misc": {
"settings": "", "settings": "",

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "تشغيل", "play": "تشغيل",
"trailer": "تشغيل المقطع الدعائي", "trailer": "تشغيل المقطع الدعائي",
"studio": "استوديو", "studios": "استوديو",
"genre": "الأنواع", "genre": "الأنواع",
"genre-none": "لا توجد أنواع", "genre-none": "لا توجد أنواع",
"staff": "الطاقم", "staff": "الطاقم",
@ -70,30 +70,29 @@
"switchToList": "التبديل إلى عرض القائمة" "switchToList": "التبديل إلى عرض القائمة"
}, },
"genres": { "genres": {
"Action": "أكشن", "action": "أكشن",
"Adventure": "مغامرات", "adventure": "مغامرات",
"Animation": "أنميشن", "animation": "أنميشن",
"Comedy": "كوميدي", "comedy": "كوميدي",
"Crime": "جريمة", "crime": "جريمة",
"Documentary": "وثائقي", "documentary": "وثائقي",
"Drama": "دراما", "drama": "دراما",
"Family": "عائلي", "family": "عائلي",
"Fantasy": "خيال", "fantasy": "خيال",
"History": "تاريخي", "history": "تاريخي",
"Horror": "رعب", "horror": "رعب",
"Music": "موسيقى", "music": "موسيقى",
"Mystery": "غموض", "mystery": "غموض",
"Romance": "رومانسي", "romance": "رومانسي",
"ScienceFiction": "خيال علمي", "science-fiction": "خيال علمي",
"Thriller": "إثارة", "thriller": "إثارة",
"War": "حرب", "war": "حرب",
"Western": "غربي", "western": "غربي",
"Kids": "أطفال", "kids": "أطفال",
"News": "أخبار", "reality": "واقع",
"Reality": "واقع", "soap": "دراما طويلة",
"Soap": "دراما طويلة", "talk": "حوار",
"Talk": "حوار", "politics": "سياسة"
"Politics": "سياسة"
}, },
"misc": { "misc": {
"settings": "إعدادات", "settings": "إعدادات",

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "Abspielen", "play": "Abspielen",
"trailer": "Trailer abspielen", "trailer": "Trailer abspielen",
"studio": "Studio", "studios": "Studio",
"genre": "Genres", "genre": "Genres",
"genre-none": "Keine Genres", "genre-none": "Keine Genres",
"staff": "Besetzung", "staff": "Besetzung",
@ -259,29 +259,28 @@
} }
}, },
"genres": { "genres": {
"Family": "Familienfilm", "family": "Familienfilm",
"Animation": "Animation", "animation": "Animation",
"Comedy": "Komödie", "comedy": "Komödie",
"Crime": "Krimi", "crime": "Krimi",
"Documentary": "Dokumentation", "documentary": "Dokumentation",
"Drama": "Drama", "drama": "Drama",
"Fantasy": "Fantasy", "fantasy": "Fantasy",
"Horror": "Horror", "horror": "Horror",
"Mystery": "Mystery", "mystery": "Mystery",
"Romance": "Liebesfilm", "romance": "Liebesfilm",
"ScienceFiction": "Science-Fiction", "science-fiction": "Science-Fiction",
"Thriller": "Thriller", "thriller": "Thriller",
"War": "Kriegsfilm", "war": "Kriegsfilm",
"Western": "Western", "western": "Western",
"Kids": "Kinderfilm", "kids": "Kinderfilm",
"News": "Neu", "reality": "Reality-TV",
"Reality": "Reality-TV", "soap": "Soap",
"Soap": "Soap", "talk": "Talkshow",
"Talk": "Talkshow", "politics": "Politik",
"Politics": "Politik", "adventure": "Abenteuer",
"Adventure": "Abenteuer", "history": "Geschichte",
"History": "Geschichte", "music": "Musikfilm",
"Music": "Musikfilm", "action": "Action"
"Action": "Action"
} }
} }

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "Play", "play": "Play",
"trailer": "Play Trailer", "trailer": "Play Trailer",
"studio": "Studio", "studios": "Studios",
"genre": "Genres", "genre": "Genres",
"genre-none": "No genres", "genre-none": "No genres",
"staff": "Staff", "staff": "Staff",
@ -71,30 +71,29 @@
"switchToList": "Switch to list view" "switchToList": "Switch to list view"
}, },
"genres": { "genres": {
"Action": "Action", "action": "Action",
"Adventure": "Adventure", "adventure": "Adventure",
"Animation": "Animation", "animation": "Animation",
"Comedy": "Comedy", "comedy": "Comedy",
"Crime": "Crime", "crime": "Crime",
"Documentary": "Documentary", "documentary": "Documentary",
"Drama": "Drama", "drama": "Drama",
"Family": "Family", "family": "Family",
"Fantasy": "Fantasy", "fantasy": "Fantasy",
"History": "History", "history": "History",
"Horror": "Horror", "horror": "Horror",
"Music": "Music", "music": "Music",
"Mystery": "Mystery", "mystery": "Mystery",
"Romance": "Romance", "romance": "Romance",
"ScienceFiction": "Science Fiction", "science-fiction": "Science Fiction",
"Thriller": "Thriller", "thriller": "Thriller",
"War": "War", "war": "War",
"Western": "Western", "western": "Western",
"Kids": "Kids", "kids": "Kids",
"News": "News", "reality": "Reality",
"Reality": "Reality", "politics": "Politics",
"Soap": "Soap", "soap": "Soap",
"Talk": "Talk", "talk": "Talk"
"Politics": "Politics"
}, },
"misc": { "misc": {
"settings": "Settings", "settings": "Settings",
@ -129,8 +128,7 @@
}, },
"language": { "language": {
"label": "Language", "label": "Language",
"description": "Set the language of your application", "description": "Set the language of your application"
"system": "System"
} }
}, },
"playback": { "playback": {

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "Reproducir", "play": "Reproducir",
"trailer": "Ver el tráiler", "trailer": "Ver el tráiler",
"studio": "Estudio", "studios": "Estudio",
"genre": "Géneros", "genre": "Géneros",
"genre-none": "Sin géneros", "genre-none": "Sin géneros",
"staff": "Equipo", "staff": "Equipo",
@ -70,30 +70,29 @@
"switchToList": "Cambiar a vista de lista" "switchToList": "Cambiar a vista de lista"
}, },
"genres": { "genres": {
"Action": "Acción", "action": "Acción",
"Adventure": "Aventura", "adventure": "Aventura",
"Animation": "Animación", "animation": "Animación",
"Comedy": "Comedia", "comedy": "Comedia",
"Crime": "Crimen", "crime": "Crimen",
"Documentary": "Documental", "documentary": "Documental",
"Drama": "Drama", "drama": "Drama",
"Family": "Familia", "family": "Familia",
"Fantasy": "Fantasía", "fantasy": "Fantasía",
"History": "Historia", "history": "Historia",
"Horror": "Horror", "horror": "Horror",
"Music": "Musica", "music": "Musica",
"Mystery": "Misterio", "mystery": "Misterio",
"Romance": "Romance", "romance": "Romance",
"ScienceFiction": "Ciencia ficción", "science-fiction": "Ciencia ficción",
"Thriller": "Suspenso", "thriller": "Suspenso",
"War": "Bélica", "war": "Bélica",
"Western": "Del oeste", "western": "Del oeste",
"Kids": "Niños", "kids": "Niños",
"News": "Noticias", "reality": "Realidad",
"Reality": "Realidad", "soap": "Novela",
"Soap": "Novela", "talk": "Entrevista",
"Talk": "Entrevista", "politics": "Política"
"Politics": "Política"
}, },
"misc": { "misc": {
"settings": "Ajustes", "settings": "Ajustes",

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "Lecture", "play": "Lecture",
"trailer": "Lire la bande annonce", "trailer": "Lire la bande annonce",
"studio": "Studio", "studios": "Studio",
"genre": "Genres", "genre": "Genres",
"genre-none": "Aucun genres", "genre-none": "Aucun genres",
"staff": "Équipe", "staff": "Équipe",
@ -259,29 +259,28 @@
} }
}, },
"genres": { "genres": {
"Action": "Action", "action": "Action",
"Adventure": "Aventure", "adventure": "Aventure",
"Comedy": "Comédie", "comedy": "Comédie",
"Documentary": "Documentaire", "documentary": "Documentaire",
"Drama": "Drame", "drama": "Drame",
"Family": "Famille", "family": "Famille",
"Fantasy": "Fantastique", "fantasy": "Fantastique",
"History": "Histoire", "history": "Histoire",
"Crime": "Scène de crime", "crime": "Scène de crime",
"Horror": "Horreur", "horror": "Horreur",
"Music": "Musique", "music": "Musique",
"Mystery": "Mystère", "mystery": "Mystère",
"Romance": "Romance", "romance": "Romance",
"ScienceFiction": "Science-fiction", "science-fiction": "Science-fiction",
"War": "Guerre", "war": "Guerre",
"Kids": "Jeunesse", "kids": "Jeunesse",
"Thriller": "Thriller", "thriller": "Thriller",
"Western": "Western", "western": "Western",
"Politics": "Politique", "politics": "Politique",
"Soap": "Soap", "soap": "Soap",
"Talk": "Talkshow", "talk": "Talkshow",
"Animation": "Animation", "animation": "Animation",
"News": "Nouveautés", "reality": "Télé-réalité"
"Reality": "Télé-réalité"
} }
} }

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "Reproducir", "play": "Reproducir",
"trailer": "Reproducir tráiler", "trailer": "Reproducir tráiler",
"studio": "Estudio", "studios": "Estudio",
"genre": "Xéneros", "genre": "Xéneros",
"genre-none": "Sen xéneros", "genre-none": "Sen xéneros",
"staff": "Persoal", "staff": "Persoal",
@ -70,30 +70,29 @@
"switchToList": "" "switchToList": ""
}, },
"genres": { "genres": {
"Action": "", "action": "",
"Adventure": "", "adventure": "",
"Animation": "", "animation": "",
"Comedy": "", "comedy": "",
"Crime": "", "crime": "",
"Documentary": "", "documentary": "",
"Drama": "", "drama": "",
"Family": "", "family": "",
"Fantasy": "", "fantasy": "",
"History": "", "history": "",
"Horror": "", "horror": "",
"Music": "", "music": "",
"Mystery": "", "mystery": "",
"Romance": "", "romance": "",
"ScienceFiction": "", "science-fiction": "",
"Thriller": "", "thriller": "",
"War": "", "war": "",
"Western": "", "western": "",
"Kids": "", "kids": "",
"News": "", "reality": "",
"Reality": "", "soap": "",
"Soap": "", "talk": "",
"Talk": "", "politics": ""
"Politics": ""
}, },
"misc": { "misc": {
"settings": "", "settings": "",

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "", "play": "",
"trailer": "", "trailer": "",
"studio": "", "studios": "",
"genre": "", "genre": "",
"genre-none": "", "genre-none": "",
"staff": "", "staff": "",
@ -70,30 +70,29 @@
"switchToList": "" "switchToList": ""
}, },
"genres": { "genres": {
"Action": "", "action": "",
"Adventure": "", "adventure": "",
"Animation": "", "animation": "",
"Comedy": "", "comedy": "",
"Crime": "", "crime": "",
"Documentary": "", "documentary": "",
"Drama": "", "drama": "",
"Family": "", "family": "",
"Fantasy": "", "fantasy": "",
"History": "", "history": "",
"Horror": "", "horror": "",
"Music": "", "music": "",
"Mystery": "", "mystery": "",
"Romance": "", "romance": "",
"ScienceFiction": "", "science-fiction": "",
"Thriller": "", "thriller": "",
"War": "", "war": "",
"Western": "", "western": "",
"Kids": "", "kids": "",
"News": "", "reality": "",
"Reality": "", "soap": "",
"Soap": "", "talk": "",
"Talk": "", "politics": ""
"Politics": ""
}, },
"misc": { "misc": {
"settings": "", "settings": "",

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "Riproduci", "play": "Riproduci",
"trailer": "Riproduci Trailer", "trailer": "Riproduci Trailer",
"studio": "Studio", "studios": "Studio",
"genre": "Generi", "genre": "Generi",
"genre-none": "Nessun genere", "genre-none": "Nessun genere",
"staff": "Staff", "staff": "Staff",
@ -252,29 +252,28 @@
} }
}, },
"genres": { "genres": {
"Mystery": "Mistero", "mystery": "Mistero",
"Kids": "Bambini", "kids": "Bambini",
"Western": "Western", "western": "Western",
"History": "Storico", "history": "Storico",
"Romance": "Romantico", "romance": "Romantico",
"ScienceFiction": "Fantascienza", "science-fiction": "Fantascienza",
"Thriller": "Thriller", "thriller": "Thriller",
"War": "Guerra", "war": "Guerra",
"Animation": "Animazione", "animation": "Animazione",
"Action": "Azione", "action": "Azione",
"Adventure": "Avventura", "adventure": "Avventura",
"Comedy": "Commedia", "comedy": "Commedia",
"Crime": "Criminale", "crime": "Criminale",
"Documentary": "Documentario", "documentary": "Documentario",
"Drama": "Drammatico", "drama": "Drammatico",
"Family": "Per famiglie", "family": "Per famiglie",
"Horror": "Orrore", "horror": "Orrore",
"Music": "Musica", "music": "Musica",
"News": "Notizie", "reality": "Reality",
"Reality": "Reality", "soap": "Telenovela",
"Soap": "Telenovela", "talk": "Talk Show",
"Talk": "Talk Show", "politics": "Politica",
"Politics": "Politica", "fantasy": "Fantasia"
"Fantasy": "Fantasia"
} }
} }

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "재생", "play": "재생",
"trailer": "예고편 재생", "trailer": "예고편 재생",
"studio": "스튜디오", "studios": "스튜디오",
"genre": "장르", "genre": "장르",
"genre-none": "장르 없음", "genre-none": "장르 없음",
"staff": "스태프", "staff": "스태프",
@ -70,30 +70,29 @@
"switchToList": "리스트 뷰로 전환하기" "switchToList": "리스트 뷰로 전환하기"
}, },
"genres": { "genres": {
"Action": "액션", "action": "액션",
"Adventure": "모험", "adventure": "모험",
"Animation": "애니메이션", "animation": "애니메이션",
"Comedy": "코미디", "comedy": "코미디",
"Crime": "범죄", "crime": "범죄",
"Documentary": "다큐멘터리", "documentary": "다큐멘터리",
"Drama": "드라마", "drama": "드라마",
"Family": "가족", "family": "가족",
"Fantasy": "판타지", "fantasy": "판타지",
"History": "역사", "history": "역사",
"Horror": "호러", "horror": "호러",
"Music": "음악", "music": "음악",
"Mystery": "미스터리", "mystery": "미스터리",
"Romance": "로맨스", "romance": "로맨스",
"ScienceFiction": "SF", "science-fiction": "SF",
"Thriller": "스릴러", "thriller": "스릴러",
"War": "전쟁", "war": "전쟁",
"Western": "서부", "western": "서부",
"Kids": "키즈", "kids": "키즈",
"News": "뉴스", "reality": "리얼리티",
"Reality": "리얼리티", "soap": "신파",
"Soap": "신파", "talk": "토크",
"Talk": "토크", "politics": "정치"
"Politics": "정치"
}, },
"misc": { "misc": {
"settings": "설정", "settings": "설정",

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "", "play": "",
"trailer": "", "trailer": "",
"studio": "", "studios": "",
"genre": "", "genre": "",
"genre-none": "", "genre-none": "",
"staff": "", "staff": "",
@ -70,30 +70,29 @@
"switchToList": "" "switchToList": ""
}, },
"genres": { "genres": {
"Action": "", "action": "",
"Adventure": "", "adventure": "",
"Animation": "", "animation": "",
"Comedy": "", "comedy": "",
"Crime": "", "crime": "",
"Documentary": "", "documentary": "",
"Drama": "", "drama": "",
"Family": "", "family": "",
"Fantasy": "", "fantasy": "",
"History": "", "history": "",
"Horror": "", "horror": "",
"Music": "", "music": "",
"Mystery": "", "mystery": "",
"Romance": "", "romance": "",
"ScienceFiction": "", "science-fiction": "",
"Thriller": "", "thriller": "",
"War": "", "war": "",
"Western": "", "western": "",
"Kids": "", "kids": "",
"News": "", "reality": "",
"Reality": "", "soap": "",
"Soap": "", "talk": "",
"Talk": "", "politics": ""
"Politics": ""
}, },
"misc": { "misc": {
"settings": "", "settings": "",

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "Speel af", "play": "Speel af",
"trailer": "Speel trailer af", "trailer": "Speel trailer af",
"studio": "Studio", "studios": "Studio",
"genre": "Genres", "genre": "Genres",
"genre-none": "Geen genres", "genre-none": "Geen genres",
"staff": "Personeel", "staff": "Personeel",
@ -70,30 +70,29 @@
"switchToList": "Wissel naar lijstweergave" "switchToList": "Wissel naar lijstweergave"
}, },
"genres": { "genres": {
"Action": "Actie", "action": "Actie",
"Adventure": "Aventuur", "adventure": "Aventuur",
"Animation": "Animatie", "animation": "Animatie",
"Comedy": "Komedie", "comedy": "Komedie",
"Crime": "Crime", "crime": "Crime",
"Documentary": "Documantaire", "documentary": "Documantaire",
"Drama": "Drama", "drama": "Drama",
"Family": "Famillie", "family": "Famillie",
"Fantasy": "Fantasie", "fantasy": "Fantasie",
"History": "Geschiedenis", "history": "Geschiedenis",
"Horror": "Horror", "horror": "Horror",
"Music": "Muziek", "music": "Muziek",
"Mystery": "Mysterie", "mystery": "Mysterie",
"Romance": "Romantiek", "romance": "Romantiek",
"ScienceFiction": "Science Fiction", "science-fiction": "Science Fiction",
"Thriller": "", "thriller": "",
"War": "Oorlog", "war": "Oorlog",
"Western": "Western", "western": "Western",
"Kids": "Kinderen", "kids": "Kinderen",
"News": "Nieuws", "reality": "",
"Reality": "", "soap": "Soap",
"Soap": "Soap", "talk": "Talk",
"Talk": "Talk", "politics": "Politiek"
"Politics": "Politiek"
}, },
"misc": { "misc": {
"settings": "Instellingen", "settings": "Instellingen",

View File

@ -27,7 +27,7 @@
"null": "Oznacz jako nieobejrzane" "null": "Oznacz jako nieobejrzane"
}, },
"season": "Sezon {{number}}", "season": "Sezon {{number}}",
"studio": "Studio", "studios": "Studio",
"genre": "Gatunek", "genre": "Gatunek",
"staff": "Obsada", "staff": "Obsada",
"noOverview": "Brak dostępnego podsumowania", "noOverview": "Brak dostępnego podsumowania",
@ -210,30 +210,29 @@
"empty": "Nie znaleziono wyniku. Spróbuj użyć innego zapytania." "empty": "Nie znaleziono wyniku. Spróbuj użyć innego zapytania."
}, },
"genres": { "genres": {
"Action": "Akcja", "action": "Akcja",
"Adventure": "Przygodowy", "adventure": "Przygodowy",
"Animation": "Animacja", "animation": "Animacja",
"Comedy": "Komedia", "comedy": "Komedia",
"Crime": "Kryminał", "crime": "Kryminał",
"Documentary": "Dokument", "documentary": "Dokument",
"Drama": "Dramat", "drama": "Dramat",
"Family": "Rodzinny", "family": "Rodzinny",
"Fantasy": "Fantastyka", "fantasy": "Fantastyka",
"History": "Historyczny", "history": "Historyczny",
"Horror": "Horror", "horror": "Horror",
"Music": "Muzyczny", "music": "Muzyczny",
"Mystery": "Tajemnica", "mystery": "Tajemnica",
"Romance": "Romans", "romance": "Romans",
"ScienceFiction": "Sci-Fi", "science-fiction": "Sci-Fi",
"Thriller": "Dreszczowiec", "thriller": "Dreszczowiec",
"War": "Wojenny", "war": "Wojenny",
"Western": "Dziki Zachód", "western": "Dziki Zachód",
"Kids": "Dziecięcy", "kids": "Dziecięcy",
"News": "Nowy", "reality": "Realny",
"Reality": "Realny", "soap": "Opera mydlana",
"Soap": "Opera mydlana", "talk": "Dyskusja",
"Talk": "Dyskusja", "politics": "Polityczny"
"Politics": "Polityczny"
}, },
"navbar": { "navbar": {
"home": "Ekran Główny", "home": "Ekran Główny",

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "Reproduzir", "play": "Reproduzir",
"trailer": "Reproduzir Trailer", "trailer": "Reproduzir Trailer",
"studio": "Estúdio", "studios": "Estúdio",
"genre": "Gêneros", "genre": "Gêneros",
"genre-none": "Nenhum gênero", "genre-none": "Nenhum gênero",
"staff": "Equipe", "staff": "Equipe",
@ -70,30 +70,29 @@
"switchToList": "Mudara para visualização de lista" "switchToList": "Mudara para visualização de lista"
}, },
"genres": { "genres": {
"Action": "Ação", "action": "Ação",
"Adventure": "Aventura", "adventure": "Aventura",
"Animation": "Animação", "animation": "Animação",
"Comedy": "Comédia", "comedy": "Comédia",
"Crime": "Crime", "crime": "Crime",
"Documentary": "Documentário", "documentary": "Documentário",
"Drama": "Drama", "drama": "Drama",
"Family": "Família", "family": "Família",
"Fantasy": "Fantasia", "fantasy": "Fantasia",
"History": "História", "history": "História",
"Horror": "Terror", "horror": "Terror",
"Music": "Música", "music": "Música",
"Mystery": "Mistério", "mystery": "Mistério",
"Romance": "Romance", "romance": "Romance",
"ScienceFiction": "Ficção cientifica", "science-fiction": "Ficção cientifica",
"Thriller": "Suspense", "thriller": "Suspense",
"War": "Guerra", "war": "Guerra",
"Western": "Faroeste", "western": "Faroeste",
"Kids": "Infantil", "kids": "Infantil",
"News": "Notícias", "reality": "Realidade",
"Reality": "Realidade", "soap": "Novela",
"Soap": "Novela", "talk": "Entrevista",
"Talk": "Entrevista", "politics": "Política"
"Politics": "Política"
}, },
"misc": { "misc": {
"settings": "Configurações", "settings": "Configurações",

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "Reproduzir", "play": "Reproduzir",
"trailer": "Reproduzir trailer", "trailer": "Reproduzir trailer",
"studio": "Estúdio", "studios": "Estúdio",
"genre": "Géneros", "genre": "Géneros",
"genre-none": "Nenhum género", "genre-none": "Nenhum género",
"staff": "Equipa", "staff": "Equipa",
@ -70,30 +70,29 @@
"switchToList": "Mudara para visualização de lista" "switchToList": "Mudara para visualização de lista"
}, },
"genres": { "genres": {
"Action": "Ação", "action": "Ação",
"Adventure": "Aventura", "adventure": "Aventura",
"Animation": "Animação", "animation": "Animação",
"Comedy": "Comédia", "comedy": "Comédia",
"Crime": "Crime", "crime": "Crime",
"Documentary": "Documentário", "documentary": "Documentário",
"Drama": "Drama", "drama": "Drama",
"Family": "Família", "family": "Família",
"Fantasy": "Fantasia", "fantasy": "Fantasia",
"History": "História", "history": "História",
"Horror": "Terror", "horror": "Terror",
"Music": "Música", "music": "Música",
"Mystery": "Mistério", "mystery": "Mistério",
"Romance": "Romance", "romance": "Romance",
"ScienceFiction": "Ficção cientifica", "science-fiction": "Ficção cientifica",
"Thriller": "Suspense", "thriller": "Suspense",
"War": "Guerra", "war": "Guerra",
"Western": "Faroeste", "western": "Faroeste",
"Kids": "Infantil", "kids": "Infantil",
"News": "Notícias", "reality": "Realidade",
"Reality": "Realidade", "soap": "Novela",
"Soap": "Novela", "talk": "Entrevista",
"Talk": "Entrevista", "politics": "Política"
"Politics": "Política"
}, },
"misc": { "misc": {
"settings": "Configurações", "settings": "Configurações",

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "Redați", "play": "Redați",
"trailer": "Redați trailerul", "trailer": "Redați trailerul",
"studio": "Studio", "studios": "Studio",
"genre": "Genuri", "genre": "Genuri",
"genre-none": "Fără genuri", "genre-none": "Fără genuri",
"staff": "Personalul", "staff": "Personalul",
@ -70,30 +70,29 @@
"switchToList": "Comutați la vizualizarea listă" "switchToList": "Comutați la vizualizarea listă"
}, },
"genres": { "genres": {
"Action": "Acţiune", "action": "Acţiune",
"Adventure": "Aventură", "adventure": "Aventură",
"Animation": "Animaţie", "animation": "Animaţie",
"Comedy": "Comedie", "comedy": "Comedie",
"Crime": "Crima", "crime": "Crima",
"Documentary": "Documentar", "documentary": "Documentar",
"Drama": "Dramă", "drama": "Dramă",
"Family": "Familial", "family": "Familial",
"Fantasy": "Fantezie", "fantasy": "Fantezie",
"History": "Istorie", "history": "Istorie",
"Horror": "Groază", "horror": "Groază",
"Music": "Muzică", "music": "Muzică",
"Mystery": "Mister", "mystery": "Mister",
"Romance": "Romantism", "romance": "Romantism",
"ScienceFiction": "Operă științifico-fantastică", "science-fiction": "Operă științifico-fantastică",
"Thriller": "Thriller", "thriller": "Thriller",
"War": "Război", "war": "Război",
"Western": "de vest", "western": "de vest",
"Kids": "Copii", "kids": "Copii",
"News": "Ştiri", "reality": "Realitate",
"Reality": "Realitate", "soap": "Novela",
"Soap": "Novela", "talk": "Vorbi",
"Talk": "Vorbi", "politics": "Politică"
"Politics": "Politică"
}, },
"misc": { "misc": {
"settings": "Setări", "settings": "Setări",

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "Начать просмотр", "play": "Начать просмотр",
"trailer": "Просмотр трейлера", "trailer": "Просмотр трейлера",
"studio": "Студия", "studios": "Студия",
"genre": "Жанры", "genre": "Жанры",
"genre-none": "Жанры отсутствуют", "genre-none": "Жанры отсутствуют",
"staff": "Команда", "staff": "Команда",
@ -70,30 +70,29 @@
"switchToList": "Перейти в режим списка" "switchToList": "Перейти в режим списка"
}, },
"genres": { "genres": {
"Action": "Экшн", "action": "Экшн",
"Adventure": "Приключение", "adventure": "Приключение",
"Animation": "Мультфильм", "animation": "Мультфильм",
"Comedy": "Комедия", "comedy": "Комедия",
"Crime": "Криминал", "crime": "Криминал",
"Documentary": "Документальный", "documentary": "Документальный",
"Drama": "Драма", "drama": "Драма",
"Family": "Семейный", "family": "Семейный",
"Fantasy": "Фэнтези", "fantasy": "Фэнтези",
"History": "Исторический", "history": "Исторический",
"Horror": "Ужасы", "horror": "Ужасы",
"Music": "Музыкальный", "music": "Музыкальный",
"Mystery": "Мистический", "mystery": "Мистический",
"Romance": "Романтический", "romance": "Романтический",
"ScienceFiction": "Научная фантастика", "science-fiction": "Научная фантастика",
"Thriller": "Триллер", "thriller": "Триллер",
"War": "Военный", "war": "Военный",
"Western": "Вестерн", "western": "Вестерн",
"Kids": "Детский", "kids": "Детский",
"News": "Новости", "reality": "Реалити-шоу",
"Reality": "Реалити-шоу", "soap": "Мыльная опера",
"Soap": "Мыльная опера", "talk": "Ток-шоу",
"Talk": "Ток-шоу", "politics": "Политика"
"Politics": "Политика"
}, },
"misc": { "misc": {
"settings": "Настройки", "settings": "Настройки",

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "விளையாடுங்கள்", "play": "விளையாடுங்கள்",
"trailer": "டிரெய்லர் விளையாடுங்கள்", "trailer": "டிரெய்லர் விளையாடுங்கள்",
"studio": "ச்டுடியோ", "studios": "ச்டுடியோ",
"genre": "வகைகள்", "genre": "வகைகள்",
"genre-none": "வகைகள் இல்லை", "genre-none": "வகைகள் இல்லை",
"staff": "பணியாளர்", "staff": "பணியாளர்",
@ -70,30 +70,29 @@
"switchToList": "பட்டியல் பார்வைக்கு மாறவும்" "switchToList": "பட்டியல் பார்வைக்கு மாறவும்"
}, },
"genres": { "genres": {
"Action": "செயல்", "action": "செயல்",
"Adventure": "துணிவு", "adventure": "துணிவு",
"Animation": "அனிமேசன்", "animation": "அனிமேசன்",
"Comedy": "நகைச்சுவை", "comedy": "நகைச்சுவை",
"Crime": "குற்றம்", "crime": "குற்றம்",
"Documentary": "ஆவணப்படம்", "documentary": "ஆவணப்படம்",
"Drama": "நாடகம்", "drama": "நாடகம்",
"Family": "குடும்பம்", "family": "குடும்பம்",
"Fantasy": "கற்பனை", "fantasy": "கற்பனை",
"History": "வரலாறு", "history": "வரலாறு",
"Horror": "அதிர்ச்சி", "horror": "அதிர்ச்சி",
"Music": "இசை", "music": "இசை",
"Mystery": "மர்மம்", "mystery": "மர்மம்",
"Romance": "காதல்", "romance": "காதல்",
"ScienceFiction": "அறிவியல் புனைகதை", "science-fiction": "அறிவியல் புனைகதை",
"Thriller": "த்ரில்லர்", "thriller": "த்ரில்லர்",
"War": "போர்", "war": "போர்",
"Western": "மேற்கு", "western": "மேற்கு",
"Kids": "குழந்தைகள்", "kids": "குழந்தைகள்",
"News": "செய்தி", "reality": "உண்மை",
"Reality": "உண்மை", "soap": "சோப்பு",
"Soap": "சோப்பு", "talk": "பேச்சு",
"Talk": "பேச்சு", "politics": "அரசியல்"
"Politics": "அரசியல்"
}, },
"misc": { "misc": {
"settings": "அமைப்புகள்", "settings": "அமைப்புகள்",

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "İzle", "play": "İzle",
"trailer": "Fragmanı İzle", "trailer": "Fragmanı İzle",
"studio": "Stüdyo", "studios": "Stüdyo",
"genre": "Kategoriler", "genre": "Kategoriler",
"genre-none": "Kategori bilgisi mevcut değil", "genre-none": "Kategori bilgisi mevcut değil",
"staff": "Kadro", "staff": "Kadro",
@ -259,29 +259,28 @@
} }
}, },
"genres": { "genres": {
"Adventure": "Macera", "adventure": "Macera",
"Comedy": "Komedi", "comedy": "Komedi",
"Crime": "Suç", "crime": "Suç",
"Documentary": "Belgesel", "documentary": "Belgesel",
"Drama": "Dram", "drama": "Dram",
"Family": "Aile", "family": "Aile",
"Fantasy": "Fantezi", "fantasy": "Fantezi",
"Horror": "Korku", "horror": "Korku",
"Music": "Müzikal", "music": "Müzikal",
"Mystery": "Gizem", "mystery": "Gizem",
"Romance": "Romantik", "romance": "Romantik",
"ScienceFiction": "Bilim Kurgu", "science-fiction": "Bilim Kurgu",
"Thriller": "Gerilim", "thriller": "Gerilim",
"War": "Savaş", "war": "Savaş",
"Western": "Kovboy", "western": "Kovboy",
"Kids": "Çocuk", "kids": "Çocuk",
"News": "Haber", "reality": "Reality",
"Reality": "Reality", "soap": "Melodrama",
"Soap": "Melodrama", "talk": "Söyleşi",
"Talk": "Söyleşi", "action": "Aksiyon",
"Action": "Aksiyon", "animation": "Animasyon",
"Animation": "Animasyon", "history": "Tarih",
"History": "Tarih", "politics": "Siyaset"
"Politics": "Siyaset"
} }
} }

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "Відтворити", "play": "Відтворити",
"trailer": "Відтворити трейлер", "trailer": "Відтворити трейлер",
"studio": "Студія", "studios": "Студія",
"genre": "Жанри", "genre": "Жанри",
"genre-none": "Жанри відсутні", "genre-none": "Жанри відсутні",
"staff": "Команда", "staff": "Команда",
@ -261,29 +261,28 @@
} }
}, },
"genres": { "genres": {
"Horror": "Жахи", "horror": "Жахи",
"War": "Війна", "war": "Війна",
"Comedy": "Комедія", "comedy": "Комедія",
"Crime": "Кримінал", "crime": "Кримінал",
"Documentary": "Документальний", "documentary": "Документальний",
"Family": "Сімейний", "family": "Сімейний",
"Fantasy": "Фентезі", "fantasy": "Фентезі",
"History": "Історія", "history": "Історія",
"Music": "Музика", "music": "Музика",
"Mystery": "Містика", "mystery": "Містика",
"Romance": "Романтика", "romance": "Романтика",
"ScienceFiction": "Наукова фантастика", "science-fiction": "Наукова фантастика",
"Thriller": "Трилер", "thriller": "Трилер",
"Western": "Вестерн", "western": "Вестерн",
"Kids": "Діти", "kids": "Діти",
"News": "Новини", "reality": "Реаліті",
"Reality": "Реаліті", "soap": "Мильна опера",
"Soap": "Мильна опера", "talk": "Ток-шоу",
"Talk": "Ток-шоу", "politics": "Політика",
"Politics": "Політика", "action": "Екшн",
"Action": "Екшн", "adventure": "Пригода",
"Adventure": "Пригода", "animation": "Мультфільм",
"Animation": "Мультфільм", "drama": "Драма"
"Drama": "Драма"
} }
} }

View File

@ -16,7 +16,7 @@
"show": { "show": {
"play": "播放", "play": "播放",
"trailer": "播放预告片", "trailer": "播放预告片",
"studio": "制作公司", "studios": "制作公司",
"genre": "类型", "genre": "类型",
"genre-none": "无类型", "genre-none": "无类型",
"staff": "制作人员", "staff": "制作人员",
@ -257,29 +257,28 @@
} }
}, },
"genres": { "genres": {
"Action": "动作片", "action": "动作片",
"Adventure": "冒险", "adventure": "冒险",
"Animation": "动漫", "animation": "动漫",
"Comedy": "喜剧片", "comedy": "喜剧片",
"Crime": "犯罪片", "crime": "犯罪片",
"Documentary": "纪录片", "documentary": "纪录片",
"Drama": "戏剧", "drama": "戏剧",
"Family": "家庭", "family": "家庭",
"Fantasy": "奇幻", "fantasy": "奇幻",
"History": "历史", "history": "历史",
"Horror": "恐怖", "horror": "恐怖",
"Music": "音乐", "music": "音乐",
"Mystery": "悬疑", "mystery": "悬疑",
"Romance": "浪漫", "romance": "浪漫",
"ScienceFiction": "科幻", "science-fiction": "科幻",
"Thriller": "惊悚", "thriller": "惊悚",
"War": "战争", "war": "战争",
"Western": "西部", "western": "西部",
"Kids": "儿童", "kids": "儿童",
"News": "新闻", "reality": "现实",
"Reality": "现实", "soap": "肥皂剧",
"Soap": "肥皂剧", "talk": "访谈",
"Talk": "访谈", "politics": "政治"
"Politics": "政治"
} }
} }

View File

@ -1,5 +1,6 @@
import { readdir , mkdir } from 'node:fs/promises'; import { mkdir, readdir } from "node:fs/promises";
async function jassub() {
const srcDir = new URL("../node_modules/jassub/dist/", import.meta.url); const srcDir = new URL("../node_modules/jassub/dist/", import.meta.url);
const destDir = new URL("../public/jassub/", import.meta.url); const destDir = new URL("../public/jassub/", import.meta.url);
@ -10,3 +11,46 @@ for (const file of files) {
const src = await Bun.file(new URL(file, srcDir)).arrayBuffer(); const src = await Bun.file(new URL(file, srcDir)).arrayBuffer();
await Bun.write(new URL(file, destDir), src); await Bun.write(new URL(file, destDir), src);
} }
}
async function translations() {
const srcDir = new URL("../public/translations/", import.meta.url);
const dest = new URL(
"../src/providers/translations.compile.ts",
import.meta.url,
);
const translations = (await readdir(srcDir))
.map((x) => ({
file: x,
lang: x.replace(".json", ""),
var: x.replace(".json", "").replace("-", "_"),
}))
.map((x) => ({
...x,
quotedLang: x.lang.includes("-") ? `"${x.lang}"` : x.lang,
}))
.sort((a, b) => a.lang.localeCompare(b.lang));
await Bun.write(
dest,
`// this file is auto-generated via a postinstall script.
${translations
.map((x) => `import ${x.var} from "../../public/translations/${x.file}";`)
.join("\n")}
export const resources = {
${translations
.map((x) => `${x.quotedLang}: { translation: ${x.var} },`)
.join("\n\t")}
};
export const supportedLanguages = [
${translations.map((x) => `"${x.lang}",`).join("\n\t")}
];
`,
);
}
await jassub();
await translations();

View File

@ -0,0 +1,3 @@
import { SettingsPage } from "~/ui/settings";
export default SettingsPage;

View File

@ -2,7 +2,7 @@ import { Stack } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTheme } from "yoshiki/native"; import { useTheme } from "yoshiki/native";
import { ErrorConsumer } from "~/providers/error-consumer"; import { ErrorConsumer } from "~/providers/error-consumer";
import { NavbarTitle } from "~/ui/navbar"; import { NavbarProfile, NavbarTitle } from "~/ui/navbar";
export default function Layout() { export default function Layout() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@ -13,6 +13,7 @@ export default function Layout() {
<Stack <Stack
screenOptions={{ screenOptions={{
headerTitle: () => <NavbarTitle />, headerTitle: () => <NavbarTitle />,
headerRight: () => <NavbarProfile />,
contentStyle: { contentStyle: {
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,

View File

@ -0,0 +1,3 @@
import { SettingsPage } from "~/ui/settings";
export default SettingsPage;

View File

@ -2,32 +2,37 @@ import { z } from "zod/v4";
export const User = z export const User = z
.object({ .object({
// // keep a default for older versions of the api
// .default({}),
id: z.string(), id: z.string(),
username: z.string(), username: z.string(),
email: z.string(), email: z.string(),
// permissions: z.array(z.string()), claims: z.object({
permissions: z.array(z.string()),
// hasPassword: z.boolean().default(true), // hasPassword: z.boolean().default(true),
// settings: z settings: z
// .object({ .object({
// downloadQuality: z downloadQuality: z
// .union([ .union([
// z.literal("original"), z.literal("original"),
// z.literal("8k"), z.literal("8k"),
// z.literal("4k"), z.literal("4k"),
// z.literal("1440p"), z.literal("1440p"),
// z.literal("1080p"), z.literal("1080p"),
// z.literal("720p"), z.literal("720p"),
// z.literal("480p"), z.literal("480p"),
// z.literal("360p"), z.literal("360p"),
// z.literal("240p"), z.literal("240p"),
// ]) ])
// .default("original") .catch("original"),
// .catch("original"), audioLanguage: z.string().catch("default"),
// audioLanguage: z.string().default("default").catch("default"), subtitleLanguage: z.string().nullable().catch(null),
// subtitleLanguage: z.string().nullable().default(null).catch(null), })
// }) .default({
// // keep a default for older versions of the api downloadQuality: "original",
// .default({}), audioLanguage: "default",
subtitleLanguage: null,
}),
// externalId: z // externalId: z
// .record( // .record(
// z.string(), // z.string(),
@ -38,6 +43,7 @@ export const User = z
// }), // }),
// ) // )
// .default({}), // .default({}),
}),
}) })
.transform((x) => ({ .transform((x) => ({
...x, ...x,

View File

@ -1,23 +1,3 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
// Stolen from https://github.com/necolas/react-native-web/issues/1026#issuecomment-1458279681 // Stolen from https://github.com/necolas/react-native-web/issues/1026#issuecomment-1458279681
import type { AlertButton, AlertOptions } from "react-native"; import type { AlertButton, AlertOptions } from "react-native";

View File

@ -1,4 +1,6 @@
export { Footer, Header, Main, Nav, UL } from "@expo/html-elements"; export { Footer, Header, Main, Nav, UL } from "@expo/html-elements";
// export * from "./snackbar";
export * from "./alert";
export * from "./avatar"; export * from "./avatar";
export * from "./button"; export * from "./button";
export * from "./chip"; export * from "./chip";
@ -9,11 +11,9 @@ export * from "./image";
export * from "./image-background"; export * from "./image-background";
export * from "./input"; export * from "./input";
export * from "./links"; export * from "./links";
// export * from "./snackbar";
// export * from "./alert";
export * from "./menu"; export * from "./menu";
export * from "./popup";
export * from "./progress"; export * from "./progress";
// export * from "./popup";
export * from "./select"; export * from "./select";
export * from "./skeleton"; export * from "./skeleton";
export * from "./slider"; export * from "./slider";

View File

@ -1,30 +1,9 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { usePortal } from "@gorhom/portal"; import { usePortal } from "@gorhom/portal";
import { type ReactNode, useCallback, useEffect, useState } from "react"; import { type ReactNode, useCallback, useEffect, useState } from "react";
import { ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { px, vh } from "yoshiki/native"; import { px, vh } from "yoshiki/native";
import { imageBorderRadius } from "./constants";
import { Container } from "./container"; import { Container } from "./container";
import { ContrastArea, SwitchVariant, type YoshikiFunc } from "./themes"; import { ContrastArea, SwitchVariant, type YoshikiFunc } from "./theme";
import { ts } from "./utils"; import { ts } from "./utils";
export const Popup = ({ export const Popup = ({
@ -52,7 +31,7 @@ export const Popup = ({
<Container <Container
{...css( {...css(
{ {
borderRadius: px(imageBorderRadius), borderRadius: px(6),
paddingHorizontal: 0, paddingHorizontal: 0,
bg: (theme) => theme.background, bg: (theme) => theme.background,
overflow: "hidden", overflow: "hidden",

View File

@ -13,8 +13,12 @@ const writeAccounts = (accounts: Account[]) => {
} }
}; };
export const readAccounts = () => {
return readValue("accounts", z.array(Account)) ?? [];
};
export const addAccount = (account: Account) => { export const addAccount = (account: Account) => {
const accounts = readValue("accounts", z.array(Account)) ?? []; const accounts = readAccounts();
// Prevent the user from adding the same account twice. // Prevent the user from adding the same account twice.
if (accounts.find((x) => x.id === account.id)) { if (accounts.find((x) => x.id === account.id)) {
@ -29,7 +33,7 @@ export const addAccount = (account: Account) => {
}; };
export const removeAccounts = (filter: (acc: Account) => boolean) => { export const removeAccounts = (filter: (acc: Account) => boolean) => {
let accounts = readValue("accounts", z.array(Account)) ?? []; let accounts = readAccounts();
accounts = accounts.filter((x) => !filter(x)); accounts = accounts.filter((x) => !filter(x));
if (!accounts.find((x) => x.selected) && accounts.length > 0) { if (!accounts.find((x) => x.selected) && accounts.length > 0) {
accounts[0].selected = true; accounts[0].selected = true;
@ -38,7 +42,7 @@ export const removeAccounts = (filter: (acc: Account) => boolean) => {
}; };
export const updateAccount = (id: string, account: Account) => { export const updateAccount = (id: string, account: Account) => {
const accounts = readValue("accounts", z.array(Account)) ?? []; const accounts = readAccounts();
const idx = accounts.findIndex((x) => x.id === id); const idx = accounts.findIndex((x) => x.id === id);
if (idx === -1) return; if (idx === -1) return;

View File

@ -1,5 +1,6 @@
import { PortalProvider } from "@gorhom/portal";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export const NativeProviders = ({ children }: { children: ReactNode }) => { export const NativeProviders = ({ children }: { children: ReactNode }) => {
return children; return <PortalProvider>{children}</PortalProvider>;
}; };

View File

@ -0,0 +1,3 @@
import { createLanguageDetector } from "react-native-localization-settings";
export const languageDetector = createLanguageDetector({});

View File

@ -0,0 +1,7 @@
import LanguageDetector from "i18next-browser-languagedetector";
export const languageDetector = new LanguageDetector(null, {
order: ["querystring", "cookie", "navigator", "path", "subdomain"],
caches: ["cookie"],
cookieMinutes: 525600, // 1 years
});

View File

@ -1,22 +1,71 @@
// this file is run at compile time thanks to a vite plugin // this file is auto-generated via a postinstall script.
// import { readFile, readdir } from "node:fs/promises";
// import type { Resource } from "i18next";
// const translationDir = new URL("../../public/translations/", import.meta.url);
// const langs = await readdir(translationDir);
// export const resources: Resource = Object.fromEntries(
// await Promise.all(
// langs.map(async (x) => [
// x.replace(".json", ""),
// { translation: JSON.parse(await readFile(new URL(x, translationDir), "utf8")) },
// ]),
// ),
// );
import am from "../../public/translations/am.json";
import ar from "../../public/translations/ar.json";
import de from "../../public/translations/de.json";
import en from "../../public/translations/en.json"; import en from "../../public/translations/en.json";
import es from "../../public/translations/es.json";
import fr from "../../public/translations/fr.json";
import gl from "../../public/translations/gl.json";
import is from "../../public/translations/is.json";
import it from "../../public/translations/it.json";
import ko from "../../public/translations/ko.json";
import ml from "../../public/translations/ml.json";
import nl from "../../public/translations/nl.json";
import pl from "../../public/translations/pl.json";
import pt from "../../public/translations/pt.json";
import pt_BR from "../../public/translations/pt-BR.json";
import ro from "../../public/translations/ro.json";
import ru from "../../public/translations/ru.json";
import ta from "../../public/translations/ta.json";
import tr from "../../public/translations/tr.json";
import uk from "../../public/translations/uk.json";
import zh from "../../public/translations/zh.json";
export const resources = { en }; export const resources = {
am: { translation: am },
ar: { translation: ar },
de: { translation: de },
en: { translation: en },
es: { translation: es },
fr: { translation: fr },
gl: { translation: gl },
is: { translation: is },
it: { translation: it },
ko: { translation: ko },
ml: { translation: ml },
nl: { translation: nl },
pl: { translation: pl },
pt: { translation: pt },
"pt-BR": { translation: pt_BR },
ro: { translation: ro },
ru: { translation: ru },
ta: { translation: ta },
tr: { translation: tr },
uk: { translation: uk },
zh: { translation: zh },
};
export const supportedLanguages = Object.keys(resources); export const supportedLanguages = [
"am",
"ar",
"de",
"en",
"es",
"fr",
"gl",
"is",
"it",
"ko",
"ml",
"nl",
"pl",
"pt",
"pt-BR",
"ro",
"ru",
"ta",
"tr",
"uk",
"zh",
];

View File

@ -3,11 +3,12 @@ import { type ReactNode, useMemo } from "react";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { setServerData } from "~/utils"; import { setServerData } from "~/utils";
import { resources, supportedLanguages } from "./translations.compile"; import { resources, supportedLanguages } from "./translations.compile";
import { languageDetector } from "./translations-detector";
export const TranslationsProvider = ({ children }: { children: ReactNode }) => { export const TranslationsProvider = ({ children }: { children: ReactNode }) => {
const val = useMemo(() => { const val = useMemo(() => {
const i18n = i18next.createInstance(); const i18n = i18next.createInstance();
i18n.init({ i18n.use(languageDetector).init({
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
}, },

View File

@ -4,12 +4,15 @@ import { type ReactNode, useMemo } from "react";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { getServerData } from "~/utils"; import { getServerData } from "~/utils";
import { supportedLanguages } from "./translations.compile"; import { supportedLanguages } from "./translations.compile";
import { languageDetector } from "./translations-detector";
export const TranslationsProvider = ({ children }: { children: ReactNode }) => { export const TranslationsProvider = ({ children }: { children: ReactNode }) => {
const val = useMemo(() => { const val = useMemo(() => {
const i18n = i18next.createInstance(); const i18n = i18next.createInstance();
// TODO: use https://github.com/i18next/i18next-browser-languageDetector i18n
i18n.use(HttpApi).init<HttpBackendOptions>({ .use(HttpApi)
.use(languageDetector)
.init<HttpBackendOptions>({
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
}, },

View File

@ -260,7 +260,7 @@ export const prefetch = async (...queries: QueryIdentifier[]) => {
}; };
type MutationParams = { type MutationParams = {
method?: "POST" | "PUT" | "DELETE"; method?: "POST" | "PUT" | "PATCH" | "DELETE";
path?: string[]; path?: string[];
params?: { params?: {
[query: string]: boolean | number | string | string[] | undefined; [query: string]: boolean | number | string | string[] | undefined;
@ -268,12 +268,14 @@ type MutationParams = {
body?: object; body?: object;
}; };
export const useMutation = <T = void>({ export const useMutation = <T = void, QueryRet = void>({
compute, compute,
invalidate, invalidate,
optimistic,
...queryParams ...queryParams
}: MutationParams & { }: MutationParams & {
compute?: (param: T) => MutationParams; compute?: (param: T) => MutationParams;
optimistic?: (param: T) => QueryRet;
invalidate: string[] | null; invalidate: string[] | null;
}) => { }) => {
const { apiUrl, authToken } = useContext(AccountContext); const { apiUrl, authToken } = useContext(AccountContext);
@ -293,14 +295,35 @@ export const useMutation = <T = void>({
parser: null, parser: null,
}); });
}, },
onSuccess: invalidate ...(invalidate && optimistic
? async () => ? {
onMutate: async (params) => {
const next = optimistic(params);
const queryKey = toQueryKey({ apiUrl, path: invalidate });
await queryClient.cancelQueries({
queryKey,
});
const previous = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, next);
return { previous, next };
},
onError: (_, __, context) => {
queryClient.setQueryData(
toQueryKey({ apiUrl, path: invalidate }),
context!.previous,
);
},
}
: {}),
...(invalidate
? {
onSettled: async () => {
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: toQueryKey({ apiUrl, path: invalidate }), queryKey: toQueryKey({ apiUrl, path: invalidate }),
}) });
: undefined, },
// TODO: Do something }
// onError: () => {} : {}),
}); });
return mutation; return mutation;
}; };

View File

@ -1,6 +1,10 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { type Account, type KyooError, User } from "~/models"; import { type Account, type KyooError, User } from "~/models";
import { addAccount, removeAccounts } from "~/providers/account-store"; import {
addAccount,
readAccounts,
removeAccounts,
} from "~/providers/account-store";
import { queryFn } from "~/query"; import { queryFn } from "~/query";
type Result<A, B> = type Result<A, B> =
@ -77,15 +81,30 @@ export const login = async (
// } // }
// }; // };
export const logout = () => { export const logout = async () => {
const accounts = readAccounts();
const account = accounts.find((x) => x.selected);
if (account) {
await queryFn({
method: "DELETE",
url: "auth/sessions/current",
authToken: account.token,
parser: null,
});
}
removeAccounts((x) => x.selected); removeAccounts((x) => x.selected);
}; };
export const deleteAccount = async () => { export const deleteAccount = async () => {
// await queryFn({ const accounts = readAccounts();
// method: "DELETE", const account = accounts.find((x) => x.selected);
// url: "auth/users/me", if (account) {
// parser: null, await queryFn({
// }); method: "DELETE",
url: "auth/users/me",
authToken: account.token,
parser: null,
});
}
logout(); logout();
}; };

View File

@ -66,7 +66,7 @@ export const LoginPage = () => {
{/* )} */} {/* )} */}
<P> <P>
<Trans i18nKey="login.or-register"> <Trans i18nKey="login.or-register">
Dont have an account?{" "} Dont have an account?
<A href={`/register?apiUrl=${apiUrl}`}>Register</A>. <A href={`/register?apiUrl=${apiUrl}`}>Register</A>.
</Trans> </Trans>
</P> </P>

View File

@ -46,14 +46,14 @@ export const RegisterPage = () => {
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.password")}</P> <P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.password")}</P>
<PasswordInput <PasswordInput
autoComplete="password-new" autoComplete="new-password"
variant="big" variant="big"
onChangeText={(value) => setPassword(value)} onChangeText={(value) => setPassword(value)}
/> />
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.confirm")}</P> <P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.confirm")}</P>
<PasswordInput <PasswordInput
autoComplete="password-new" autoComplete="new-password"
variant="big" variant="big"
onChangeText={(value) => setConfirm(value)} onChangeText={(value) => setConfirm(value)}
/> />
@ -90,7 +90,7 @@ export const RegisterPage = () => {
{/* )} */} {/* )} */}
<P> <P>
<Trans i18nKey="login.or-login"> <Trans i18nKey="login.or-login">
Have an account already?{" "} Have an account already?
<A href={`/login?apiUrl=${apiUrl}`}>Log in</A>. <A href={`/login?apiUrl=${apiUrl}`}>Log in</A>.
</Trans> </Trans>
</P> </P>

View File

@ -1,81 +1,67 @@
/* import Username from "@material-symbols/svg-400/outlined/badge.svg";
* Kyoo - A portable and vast media library solution. import Mail from "@material-symbols/svg-400/outlined/mail.svg";
* Copyright (c) Kyoo. import Password from "@material-symbols/svg-400/outlined/password.svg";
* // import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
* See AUTHORS.md and LICENSE file in the project root for full license information. import Delete from "@material-symbols/svg-400/rounded/delete.svg";
* import Logout from "@material-symbols/svg-400/rounded/logout.svg";
* Kyoo is free software: you can redistribute it and/or modify // import * as ImagePicker from "expo-image-picker";
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import {
type Account,
type KyooErrors,
deleteAccount,
logout,
queryFn,
useAccount,
} from "@kyoo/models";
import { Alert, Avatar, Button, H1, Icon, Input, P, Popup, ts, usePopup } from "@kyoo/primitives";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as ImagePicker from "expo-image-picker";
import { type ComponentProps, useState } from "react"; import { type ComponentProps, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { rem, useYoshiki } from "yoshiki/native"; import { rem, useYoshiki } from "yoshiki/native";
import { PasswordInput } from "../../../../src/ui/login/password-input"; import type { KyooError, User } from "~/models";
import {
Alert,
Button,
H1,
Icon,
Input,
P,
Popup,
ts,
usePopup,
} from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { useMutation } from "~/query";
import { deleteAccount, logout } from "../login/logic";
import { PasswordInput } from "../login/password-input";
import { Preference, SettingsContainer } from "./base"; import { Preference, SettingsContainer } from "./base";
import Username from "@material-symbols/svg-400/outlined/badge.svg"; // function dataURItoBlob(dataURI: string) {
import Mail from "@material-symbols/svg-400/outlined/mail.svg"; // const byteString = atob(dataURI.split(",")[1]);
import Password from "@material-symbols/svg-400/outlined/password.svg"; // const ab = new ArrayBuffer(byteString.length);
import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg"; // const ia = new Uint8Array(ab);
import Delete from "@material-symbols/svg-400/rounded/delete.svg"; // for (let i = 0; i < byteString.length; i++) {
import Logout from "@material-symbols/svg-400/rounded/logout.svg"; // ia[i] = byteString.charCodeAt(i);
// }
function dataURItoBlob(dataURI: string) { // return new Blob([ab], { type: "image/jpeg" });
const byteString = atob(dataURI.split(",")[1]); // }
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ab], { type: "image/jpeg" });
}
export const AccountSettings = () => { export const AccountSettings = () => {
const account = useAccount()!; const account = useAccount()!;
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
const { t } = useTranslation();
const [setPopup, close] = usePopup(); const [setPopup, close] = usePopup();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { mutateAsync } = useMutation({ const { mutateAsync } = useMutation({
mutationFn: async (update: Partial<Account>) =>
await queryFn({
path: ["auth", "me"],
method: "PATCH", method: "PATCH",
body: update, path: ["auth", "users", "me"],
compute: (update: Partial<User>) => ({ body: update }),
optimistic: (update) => ({
...account,
...update,
claims: { ...account.claims, ...update.claims },
}), }),
onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }), invalidate: ["auth", "users", "me"],
}); });
const { mutateAsync: editPassword } = useMutation({ const { mutateAsync: editPassword } = useMutation({
mutationFn: async (request: { newPassword: string; oldPassword: string }) => method: "PATCH",
await queryFn({ path: ["auth", "users", "me", "password"],
path: ["auth", "password-reset"], compute: (body: { oldPassword: string; newPassword: string }) => ({
method: "POST", body,
body: request,
}), }),
invalidate: null,
}); });
return ( return (
@ -106,7 +92,8 @@ export const AccountSettings = () => {
], ],
{ {
cancelable: true, cancelable: true,
userInterfaceStyle: theme.mode === "auto" ? "light" : theme.mode, userInterfaceStyle:
theme.mode === "auto" ? "light" : theme.mode,
icon: "warning", icon: "warning",
}, },
); );
@ -137,43 +124,47 @@ export const AccountSettings = () => {
} }
/> />
</Preference> </Preference>
{/* <Preference */}
{/* icon={AccountCircle} */}
{/* customIcon={<Avatar src={account.logo} />} */}
{/* label={t("settings.account.avatar.label")} */}
{/* description={t("settings.account.avatar.description")} */}
{/* > */}
{/* <Button */}
{/* text={t("misc.edit")} */}
{/* onPress={async () => { */}
{/* const img = await ImagePicker.launchImageLibraryAsync({ */}
{/* mediaTypes: ImagePicker.MediaTypeOptions.Images, */}
{/* aspect: [1, 1], */}
{/* quality: 1, */}
{/* base64: true, */}
{/* }); */}
{/* if (img.canceled || img.assets.length !== 1) return; */}
{/* const data = dataURItoBlob(img.assets[0].uri); */}
{/* const formData = new FormData(); */}
{/* formData.append("picture", data); */}
{/* await queryFn({ */}
{/* method: "POST", */}
{/* path: ["auth", "me", "logo"], */}
{/* formData, */}
{/* }); */}
{/* }} */}
{/* /> */}
{/* <Button */}
{/* text={t("misc.delete")} */}
{/* onPress={async () => { */}
{/* await queryFn({ */}
{/* method: "DELETE", */}
{/* path: ["auth", "me", "logo"], */}
{/* }); */}
{/* }} */}
{/* /> */}
{/* </Preference> */}
<Preference <Preference
icon={AccountCircle} icon={Mail}
customIcon={<Avatar src={account.logo} />} label={t("settings.account.email.label")}
label={t("settings.account.avatar.label")} description={account.email}
description={t("settings.account.avatar.description")}
> >
<Button
text={t("misc.edit")}
onPress={async () => {
const img = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
aspect: [1, 1],
quality: 1,
base64: true,
});
if (img.canceled || img.assets.length !== 1) return;
const data = dataURItoBlob(img.assets[0].uri);
const formData = new FormData();
formData.append("picture", data);
await queryFn({
method: "POST",
path: ["auth", "me", "logo"],
formData,
});
}}
/>
<Button
text={t("misc.delete")}
onPress={async () => {
await queryFn({
method: "DELETE",
path: ["auth", "me", "logo"],
});
}}
/>
</Preference>
<Preference icon={Mail} label={t("settings.account.email.label")} description={account.email}>
<Button <Button
text={t("misc.edit")} text={t("misc.edit")}
onPress={() => onPress={() =>
@ -202,8 +193,10 @@ export const AccountSettings = () => {
<ChangePasswordPopup <ChangePasswordPopup
icon={Password} icon={Password}
label={t("settings.account.password.label")} label={t("settings.account.password.label")}
hasPassword={account.hasPassword} hasPassword={true}
apply={async (op, np) => await editPassword({ oldPassword: op, newPassword: np })} apply={async (op, np) =>
await editPassword({ oldPassword: op, newPassword: np })
}
close={close} close={close}
/>, />,
) )
@ -236,7 +229,9 @@ const ChangePopup = ({
<Popup> <Popup>
{({ css }) => ( {({ css }) => (
<> <>
<View {...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}> <View
{...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}
>
<Icon icon={icon} /> <Icon icon={icon} />
<H1 {...css({ fontSize: rem(2) })}>{label}</H1> <H1 {...css({ fontSize: rem(2) })}>{label}</H1>
</View> </View>
@ -246,7 +241,13 @@ const ChangePopup = ({
value={value} value={value}
onChangeText={(v) => setValue(v)} onChangeText={(v) => setValue(v)}
/> />
<View {...css({ flexDirection: "row", alignSelf: "flex-end", gap: ts(1) })}> <View
{...css({
flexDirection: "row",
alignSelf: "flex-end",
gap: ts(1),
})}
>
<Button <Button
text={t("misc.cancel")} text={t("misc.cancel")}
onPress={() => close()} onPress={() => close()}
@ -289,7 +290,9 @@ const ChangePasswordPopup = ({
<Popup> <Popup>
{({ css }) => ( {({ css }) => (
<> <>
<View {...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}> <View
{...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}
>
<Icon icon={icon} /> <Icon icon={icon} />
<H1 {...css({ fontSize: rem(2) })}>{label}</H1> <H1 {...css({ fontSize: rem(2) })}>{label}</H1>
</View> </View>
@ -303,14 +306,22 @@ const ChangePasswordPopup = ({
/> />
)} )}
<PasswordInput <PasswordInput
autoComplete="password-new" autoComplete="new-password"
variant="big" variant="big"
value={newValue} value={newValue}
onChangeText={(v) => setNewValue(v)} onChangeText={(v) => setNewValue(v)}
placeholder={t("settings.account.password.newPassword")} placeholder={t("settings.account.password.newPassword")}
/> />
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>} {error && (
<View {...css({ flexDirection: "row", alignSelf: "flex-end", gap: ts(1) })}> <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>
)}
<View
{...css({
flexDirection: "row",
alignSelf: "flex-end",
gap: ts(1),
})}
>
<Button <Button
text={t("misc.cancel")} text={t("misc.cancel")}
onPress={() => close()} onPress={() => close()}
@ -323,7 +334,7 @@ const ChangePasswordPopup = ({
await apply(oldValue, newValue); await apply(oldValue, newValue);
close(); close();
} catch (e) { } catch (e) {
setError((e as KyooErrors).errors[0]); setError((e as KyooError).message);
} }
}} }}
{...css({ minWidth: rem(6) })} {...css({ minWidth: rem(6) })}

View File

@ -1,24 +1,7 @@
/* import { Children, type ReactElement, type ReactNode } from "react";
* Kyoo - A portable and vast media library solution. import { type Falsy, View } from "react-native";
* Copyright (c) Kyoo. import { percent, px, rem, useYoshiki } from "yoshiki/native";
* import type { User } from "~/models";
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { type User, queryFn, useAccount } from "@kyoo/models";
import { import {
Container, Container,
H1, H1,
@ -27,13 +10,10 @@ import {
P, P,
SubP, SubP,
SwitchVariant, SwitchVariant,
imageBorderRadius,
ts, ts,
} from "@kyoo/primitives"; } from "~/primitives";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useAccount } from "~/providers/account-context";
import { Children, type ReactElement, type ReactNode } from "react"; import { useMutation } from "~/query";
import { type Falsy, View } from "react-native";
import { percent, px, rem, useYoshiki } from "yoshiki/native";
export const Preference = ({ export const Preference = ({
customIcon, customIcon,
@ -75,7 +55,7 @@ export const Preference = ({
> >
{customIcon ?? <Icon icon={icon} />} {customIcon ?? <Icon icon={icon} />}
<View {...css({ flexShrink: 1 })}> <View {...css({ flexShrink: 1 })}>
<P {...css({ marginBottom: 0 })}>{label}</P> <P {...(css({ marginBottom: 0 }) as any)}>{label}</P>
<SubP>{description}</SubP> <SubP>{description}</SubP>
</View> </View>
</View> </View>
@ -118,7 +98,7 @@ export const SettingsContainer = ({
<View <View
{...css({ {...css({
bg: (theme) => theme.background, bg: (theme) => theme.background,
borderRadius: px(imageBorderRadius), borderRadius: px(6),
})} })}
> >
{Children.map(children, (x, i) => ( {Children.map(children, (x, i) => (
@ -135,34 +115,34 @@ export const SettingsContainer = ({
); );
}; };
export const useSetting = <Setting extends keyof User["settings"]>(setting: Setting) => { export const useSetting = <Setting extends keyof User["claims"]["settings"]>(
setting: Setting,
) => {
const account = useAccount(); const account = useAccount();
const queryClient = useQueryClient();
const { mutateAsync } = useMutation({ const { mutateAsync } = useMutation({
mutationFn: async (update: Partial<User["settings"]>) =>
await queryFn({
path: ["auth", "me"],
method: "PATCH", method: "PATCH",
body: { settings: { ...account!.settings, ...update } }, path: ["auth", "users", "me"],
compute: (update: Partial<User["claims"]["settings"]>) => ({
body: {
claims: {
settings: { ...account!.claims.settings, ...update },
},
},
}), }),
onMutate: async (newSettings) => { optimistic: (update) => ({
const next = { ...account!, settings: { ...account!.settings, ...newSettings } }; ...account,
await queryClient.cancelQueries({ queryKey: ["auth", "me"] }); claims: {
const previous = queryClient.getQueryData(["auth", "me"]); ...account!.claims,
queryClient.setQueryData(["auth", "me"], next); settings: { ...account!.claims.settings, ...update },
return { previous, next };
}, },
onError: (_, __, context) => { }),
queryClient.setQueryData(["auth", "me"], context!.previous); invalidate: ["auth", "users", "me"],
},
onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }),
}); });
if (!account) return null; if (!account) return null;
return [ return [
account.settings[setting], account.claims.settings[setting],
async (value: User["settings"][Setting]) => { async (value: User["claims"]["settings"][Setting]) => {
await mutateAsync({ [setting]: value }); await mutateAsync({ [setting]: value });
}, },
] as const; ] as const;

View File

@ -0,0 +1,72 @@
// import Theme from "@material-symbols/svg-400/outlined/dark_mode.svg";
import Language from "@material-symbols/svg-400/outlined/language.svg";
import Android from "@material-symbols/svg-400/rounded/android.svg";
import Public from "@material-symbols/svg-400/rounded/public.svg";
import { useTranslation } from "react-i18next";
import { Link, Select } from "~/primitives";
import { supportedLanguages } from "~/providers/translations.compile";
import { useLanguageName } from "~/track-utils";
import { Preference, SettingsContainer } from "./base";
export const GeneralSettings = () => {
const { t, i18n } = useTranslation();
// const theme = useUserTheme("auto");
const getLanguageName = useLanguageName();
return (
<SettingsContainer title={t("settings.general.label")}>
{/* <Preference */}
{/* icon={Theme} */}
{/* label={t("settings.general.theme.label")} */}
{/* description={t("settings.general.theme.description")} */}
{/* > */}
{/* <Select */}
{/* label={t("settings.general.theme.label")} */}
{/* value={theme} */}
{/* onValueChange={(value) => setUserTheme(value)} */}
{/* values={["auto", "light", "dark"]} */}
{/* getLabel={(key) => t(`settings.general.theme.${key}`)} */}
{/* /> */}
{/* </Preference> */}
<Preference
icon={Language}
label={t("settings.general.language.label")}
description={t("settings.general.language.description")}
>
<Select
label={t("settings.general.language.label")}
value={i18n.resolvedLanguage!}
onValueChange={(value) => i18n.changeLanguage(value)}
values={supportedLanguages}
getLabel={(key) => getLanguageName(key) ?? key}
/>
</Preference>
</SettingsContainer>
);
};
export const About = () => {
const { t } = useTranslation();
return (
<SettingsContainer title={t("settings.about.label")}>
<Link
href="https://github.com/zoriya/kyoo/releases/latest/download/kyoo.apk"
target="_blank"
>
<Preference
icon={Android}
label={t("settings.about.android-app.label")}
description={t("settings.about.android-app.description")}
/>
</Link>
<Link href="https://github.com/zoriya/kyoo" target="_blank">
<Preference
icon={Public}
label={t("settings.about.git.label")}
description={t("settings.about.git.description")}
/>
</Link>
</SettingsContainer>
);
};

View File

@ -0,0 +1,20 @@
import { ScrollView } from "react-native";
import { ts } from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { AccountSettings } from "./account";
import { About, GeneralSettings } from "./general";
// import { OidcSettings } from "./oidc";
import { PlaybackSettings } from "./playback";
export const SettingsPage = () => {
const account = useAccount();
return (
<ScrollView contentContainerStyle={{ gap: ts(4), paddingBottom: ts(4) }}>
<GeneralSettings />
{account && <PlaybackSettings />}
{account && <AccountSettings />}
{/* {account && <OidcSettings />} */}
<About />
</ScrollView>
);
};

View File

@ -0,0 +1,108 @@
// import {
// type QueryIdentifier,
// type ServerInfo,
// ServerInfoP,
// queryFn,
// useAccount,
// useFetch,
// } from "@kyoo/models";
// import { Button, IconButton, Link, Skeleton, tooltip, ts } from "@kyoo/primitives";
// import { useTranslation } from "react-i18next";
// import { ImageBackground } from "react-native";
// import { rem, useYoshiki } from "yoshiki/native";
// import { ErrorView } from "../errors";
// import { Preference, SettingsContainer } from "./base";
//
// import Badge from "@material-symbols/svg-400/outlined/badge.svg";
// import Remove from "@material-symbols/svg-400/outlined/close.svg";
// import OpenProfile from "@material-symbols/svg-400/outlined/open_in_new.svg";
// import { useMutation, useQueryClient } from "@tanstack/react-query";
//
// export const OidcSettings = () => {
// const account = useAccount()!;
// const { css } = useYoshiki();
// const { t } = useTranslation();
// const { data, error } = useFetch(OidcSettings.query());
// const queryClient = useQueryClient();
// const { mutateAsync: unlinkAccount } = useMutation({
// mutationFn: async (provider: string) =>
// await queryFn({
// path: ["auth", "login", provider],
// method: "DELETE",
// }),
// onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }),
// });
//
// return (
// <SettingsContainer title={t("settings.oidc.label")}>
// {error ? (
// <ErrorView error={error} />
// ) : data ? (
// Object.entries(data.oidc).map(([id, x]) => {
// const acc = account.externalId[id];
// return (
// <Preference
// key={x.displayName}
// icon={Badge}
// label={x.displayName}
// description={
// acc
// ? t("settings.oidc.connected", { username: acc.username })
// : t("settings.oidc.not-connected")
// }
// customIcon={
// x.logoUrl != null && (
// <ImageBackground
// source={{ uri: x.logoUrl }}
// {...css({ width: ts(3), height: ts(3), marginRight: ts(2) })}
// />
// )
// }
// >
// {acc ? (
// <>
// {acc.profileUrl && (
// <IconButton
// icon={OpenProfile}
// as={Link}
// href={acc.profileUrl}
// target="_blank"
// {...tooltip(t("settings.oidc.open-profile", { provider: x.displayName }))}
// />
// )}
// <IconButton
// icon={Remove}
// onPress={() => unlinkAccount(id)}
// {...tooltip(t("settings.oidc.delete", { provider: x.displayName }))}
// />
// </>
// ) : (
// <Button
// text={t("settings.oidc.link")}
// as={Link}
// href={x.link}
// {...css({ minWidth: rem(6) })}
// />
// )}
// </Preference>
// );
// })
// ) : (
// [...Array(3)].map((_, i) => (
// <Preference
// key={i}
// customIcon={<Skeleton {...css({ width: ts(3), height: ts(3) })} />}
// icon={null!}
// label={<Skeleton {...css({ width: rem(6) })} />}
// description={<Skeleton {...css({ width: rem(7), height: rem(0.8) })} />}
// />
// ))
// )}
// </SettingsContainer>
// );
// };
//
// OidcSettings.query = (): QueryIdentifier<ServerInfo> => ({
// path: ["info"],
// parser: ServerInfoP,
// });

View File

@ -1,32 +1,26 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { languageCodes, useLanguageName } from "../utils";
import { Preference, SettingsContainer, useSetting } from "./base";
import { useLocalSetting } from "@kyoo/models";
import { Select } from "@kyoo/primitives";
import SubtitleLanguage from "@material-symbols/svg-400/rounded/closed_caption-fill.svg"; import SubtitleLanguage from "@material-symbols/svg-400/rounded/closed_caption-fill.svg";
import PlayModeI from "@material-symbols/svg-400/rounded/display_settings-fill.svg"; import PlayModeI from "@material-symbols/svg-400/rounded/display_settings-fill.svg";
import AudioLanguage from "@material-symbols/svg-400/rounded/music_note-fill.svg"; import AudioLanguage from "@material-symbols/svg-400/rounded/music_note-fill.svg";
import langmap from "langmap";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Select } from "~/primitives";
import { useLocalSetting } from "~/providers/settings";
import { useLanguageName } from "~/track-utils";
import { Preference, SettingsContainer, useSetting } from "./base";
const seenNativeNames = new Set();
export const languageCodes = Object.keys(langmap)
.filter((x) => {
const nativeName = langmap[x]?.nativeName;
// Only include if nativeName is unique and defined
if (nativeName && !seenNativeNames.has(nativeName)) {
seenNativeNames.add(nativeName);
return true;
}
return false;
})
.filter((x) => !x.includes("@"));
export const PlaybackSettings = () => { export const PlaybackSettings = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -61,7 +55,9 @@ export const PlaybackSettings = () => {
onValueChange={(value) => setAudio(value)} onValueChange={(value) => setAudio(value)}
values={["default", ...languageCodes]} values={["default", ...languageCodes]}
getLabel={(key) => getLabel={(key) =>
key === "default" ? t("mediainfo.default") : (getLanguageName(key) ?? key) key === "default"
? t("mediainfo.default")
: (getLanguageName(key) ?? key)
} }
/> />
</Preference> </Preference>
@ -73,7 +69,9 @@ export const PlaybackSettings = () => {
<Select <Select
label={t("settings.playback.subtitleLanguage.label")} label={t("settings.playback.subtitleLanguage.label")}
value={subtitle ?? "none"} value={subtitle ?? "none"}
onValueChange={(value) => setSubtitle(value === "none" ? null : value)} onValueChange={(value) =>
setSubtitle(value === "none" ? null : value)
}
values={["none", "default", ...languageCodes]} values={["none", "default", ...languageCodes]}
getLabel={(key) => getLabel={(key) =>
key === "none" key === "none"

View File

@ -16,6 +16,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"allowImportingTsExtensions": true,
"types": [ "types": [
"node", "node",
"react" "react"