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),
email = coalesce($3, email),
password = coalesce($4, password),
claims = coalesce($5, claims)
claims = claims || coalesce($5, '{}'::jsonb)
where
id = $1
returning

View File

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

View File

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

View File

@ -26,7 +26,16 @@ jwt: jsonpath "$.token"
PATCH {{host}}/users/me/password
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

View File

@ -59,7 +59,8 @@ type EditUserDto 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 {
@ -182,7 +183,7 @@ func (h *Handler) GetMe(c echo.Context) error {
if err != nil {
return err
}
dbuser, err := h.db.GetUser(context.Background(), dbc.GetUserParams{
dbuser, err := h.db.GetUser(c.Request().Context(), dbc.GetUserParams{
UseId: true,
Id: id,
})
@ -406,6 +407,10 @@ func (h *Handler) ChangePassword(c echo.Context) error {
if err != nil {
return err
}
user, err := h.db.GetUser(c.Request().Context(), dbc.GetUserParams{
UseId: true,
Id: uid,
})
sid, err := GetCurrentSessionId(c)
if err != nil {
@ -421,13 +426,26 @@ func (h *Handler) ChangePassword(c echo.Context) error {
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{
Id: uid,
Password: &req.Password,
Password: &pass,
})
if err == pgx.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound, "Invalid token, user not found")
} else if err != nil {
if err != nil {
return err
}

View File

@ -1,12 +1,14 @@
FROM oven/bun AS builder
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
ENV NODE_ENV=production
COPY package.json bun.lock scripts .
COPY package.json bun.lock .
COPY scripts scripts
COPY public public
RUN bun install --production
COPY . .

View File

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

View File

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

View File

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

View File

@ -28,6 +28,7 @@
"expo-splash-screen": "^31.0.10",
"expo-status-bar": "~3.0.8",
"expo-updates": "~29.0.11",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"jassub": "^1.8.6",
"langmap": "^0.0.16",
@ -36,6 +37,7 @@
"react-i18next": "^16.1.0",
"react-native": "0.81.5",
"react-native-get-random-values": "^2.0.0",
"react-native-localization-settings": "^1.2.0",
"react-native-mmkv": "^3.3.3",
"react-native-nitro-modules": "^0.30.2",
"react-native-reanimated": "~4.1.2",
@ -48,6 +50,7 @@
"react-native-worklets": "0.5.1",
"react-tooltip": "^5.29.1",
"sweetalert2": "^11.26.3",
"tsx": "^4.20.6",
"uuid": "^13.0.0",
"video.js": "^8.23.4",
"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=="],
"@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/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=="],
"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=="],
"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-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=="],
"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-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=="],
"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-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-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-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.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
@ -1447,6 +1512,8 @@
"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-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-status-bar": "~3.0.8",
"expo-updates": "~29.0.11",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"jassub": "^1.8.6",
"langmap": "^0.0.16",
@ -47,6 +48,7 @@
"react-i18next": "^16.1.0",
"react-native": "0.81.5",
"react-native-get-random-values": "^2.0.0",
"react-native-localization-settings": "^1.2.0",
"react-native-mmkv": "^3.3.3",
"react-native-nitro-modules": "^0.30.2",
"react-native-reanimated": "~4.1.2",
@ -59,6 +61,7 @@
"react-native-worklets": "0.5.1",
"react-tooltip": "^5.29.1",
"sweetalert2": "^11.26.3",
"tsx": "^4.20.6",
"uuid": "^13.0.0",
"video.js": "^8.23.4",
"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": {
"play": "ያጫውቱ",
"trailer": "ትሬለሩን አጫውት",
"studio": "ስቱዲዮ",
"studios": "ስቱዲዮ",
"genre": "ዘውግ",
"genre-none": "ዘውግ የለውም",
"staff": "ስታፍ",
@ -70,30 +70,29 @@
"switchToList": ""
},
"genres": {
"Action": "",
"Adventure": "",
"Animation": "",
"Comedy": "",
"Crime": "",
"Documentary": "",
"Drama": "",
"Family": "",
"Fantasy": "",
"History": "",
"Horror": "",
"Music": "",
"Mystery": "",
"Romance": "",
"ScienceFiction": "",
"Thriller": "",
"War": "",
"Western": "",
"Kids": "",
"News": "",
"Reality": "",
"Soap": "",
"Talk": "",
"Politics": ""
"action": "",
"adventure": "",
"animation": "",
"comedy": "",
"crime": "",
"documentary": "",
"drama": "",
"family": "",
"fantasy": "",
"history": "",
"horror": "",
"music": "",
"mystery": "",
"romance": "",
"science-fiction": "",
"thriller": "",
"war": "",
"western": "",
"kids": "",
"reality": "",
"soap": "",
"talk": "",
"politics": ""
},
"misc": {
"settings": "",

View File

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

View File

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

View File

@ -16,7 +16,7 @@
"show": {
"play": "Play",
"trailer": "Play Trailer",
"studio": "Studio",
"studios": "Studios",
"genre": "Genres",
"genre-none": "No genres",
"staff": "Staff",
@ -71,30 +71,29 @@
"switchToList": "Switch to list view"
},
"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": "News",
"Reality": "Reality",
"Soap": "Soap",
"Talk": "Talk",
"Politics": "Politics"
"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",
"science-fiction": "Science Fiction",
"thriller": "Thriller",
"war": "War",
"western": "Western",
"kids": "Kids",
"reality": "Reality",
"politics": "Politics",
"soap": "Soap",
"talk": "Talk"
},
"misc": {
"settings": "Settings",
@ -129,8 +128,7 @@
},
"language": {
"label": "Language",
"description": "Set the language of your application",
"system": "System"
"description": "Set the language of your application"
}
},
"playback": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@
"show": {
"play": "播放",
"trailer": "播放预告片",
"studio": "制作公司",
"studios": "制作公司",
"genre": "类型",
"genre-none": "无类型",
"staff": "制作人员",
@ -257,29 +257,28 @@
}
},
"genres": {
"Action": "动作片",
"Adventure": "冒险",
"Animation": "动漫",
"Comedy": "喜剧片",
"Crime": "犯罪片",
"Documentary": "纪录片",
"Drama": "戏剧",
"Family": "家庭",
"Fantasy": "奇幻",
"History": "历史",
"Horror": "恐怖",
"Music": "音乐",
"Mystery": "悬疑",
"Romance": "浪漫",
"ScienceFiction": "科幻",
"Thriller": "惊悚",
"War": "战争",
"Western": "西部",
"Kids": "儿童",
"News": "新闻",
"Reality": "现实",
"Soap": "肥皂剧",
"Talk": "访谈",
"Politics": "政治"
"action": "动作片",
"adventure": "冒险",
"animation": "动漫",
"comedy": "喜剧片",
"crime": "犯罪片",
"documentary": "纪录片",
"drama": "戏剧",
"family": "家庭",
"fantasy": "奇幻",
"history": "历史",
"horror": "恐怖",
"music": "音乐",
"mystery": "悬疑",
"romance": "浪漫",
"science-fiction": "科幻",
"thriller": "惊悚",
"war": "战争",
"western": "西部",
"kids": "儿童",
"reality": "现实",
"soap": "肥皂剧",
"talk": "访谈",
"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 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();
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 { useTheme } from "yoshiki/native";
import { ErrorConsumer } from "~/providers/error-consumer";
import { NavbarTitle } from "~/ui/navbar";
import { NavbarProfile, NavbarTitle } from "~/ui/navbar";
export default function Layout() {
const insets = useSafeAreaInsets();
@ -13,6 +13,7 @@ export default function Layout() {
<Stack
screenOptions={{
headerTitle: () => <NavbarTitle />,
headerRight: () => <NavbarProfile />,
contentStyle: {
paddingLeft: insets.left,
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
.object({
// // keep a default for older versions of the api
// .default({}),
id: z.string(),
username: z.string(),
email: z.string(),
// permissions: z.array(z.string()),
claims: z.object({
permissions: z.array(z.string()),
// hasPassword: z.boolean().default(true),
// settings: z
// .object({
// downloadQuality: z
// .union([
// z.literal("original"),
// z.literal("8k"),
// z.literal("4k"),
// z.literal("1440p"),
// z.literal("1080p"),
// z.literal("720p"),
// z.literal("480p"),
// z.literal("360p"),
// z.literal("240p"),
// ])
// .default("original")
// .catch("original"),
// audioLanguage: z.string().default("default").catch("default"),
// subtitleLanguage: z.string().nullable().default(null).catch(null),
// })
// // keep a default for older versions of the api
// .default({}),
settings: z
.object({
downloadQuality: z
.union([
z.literal("original"),
z.literal("8k"),
z.literal("4k"),
z.literal("1440p"),
z.literal("1080p"),
z.literal("720p"),
z.literal("480p"),
z.literal("360p"),
z.literal("240p"),
])
.catch("original"),
audioLanguage: z.string().catch("default"),
subtitleLanguage: z.string().nullable().catch(null),
})
.default({
downloadQuality: "original",
audioLanguage: "default",
subtitleLanguage: null,
}),
// externalId: z
// .record(
// z.string(),
@ -38,6 +43,7 @@ export const User = z
// }),
// )
// .default({}),
}),
})
.transform((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
import type { AlertButton, AlertOptions } from "react-native";

View File

@ -1,4 +1,6 @@
export { Footer, Header, Main, Nav, UL } from "@expo/html-elements";
// export * from "./snackbar";
export * from "./alert";
export * from "./avatar";
export * from "./button";
export * from "./chip";
@ -9,11 +11,9 @@ export * from "./image";
export * from "./image-background";
export * from "./input";
export * from "./links";
// export * from "./snackbar";
// export * from "./alert";
export * from "./menu";
export * from "./popup";
export * from "./progress";
// export * from "./popup";
export * from "./select";
export * from "./skeleton";
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 { type ReactNode, useCallback, useEffect, useState } from "react";
import { ScrollView, View } from "react-native";
import { px, vh } from "yoshiki/native";
import { imageBorderRadius } from "./constants";
import { Container } from "./container";
import { ContrastArea, SwitchVariant, type YoshikiFunc } from "./themes";
import { ContrastArea, SwitchVariant, type YoshikiFunc } from "./theme";
import { ts } from "./utils";
export const Popup = ({
@ -52,7 +31,7 @@ export const Popup = ({
<Container
{...css(
{
borderRadius: px(imageBorderRadius),
borderRadius: px(6),
paddingHorizontal: 0,
bg: (theme) => theme.background,
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) => {
const accounts = readValue("accounts", z.array(Account)) ?? [];
const accounts = readAccounts();
// Prevent the user from adding the same account twice.
if (accounts.find((x) => x.id === account.id)) {
@ -29,7 +33,7 @@ export const addAccount = (account: Account) => {
};
export const removeAccounts = (filter: (acc: Account) => boolean) => {
let accounts = readValue("accounts", z.array(Account)) ?? [];
let accounts = readAccounts();
accounts = accounts.filter((x) => !filter(x));
if (!accounts.find((x) => x.selected) && accounts.length > 0) {
accounts[0].selected = true;
@ -38,7 +42,7 @@ export const removeAccounts = (filter: (acc: Account) => boolean) => {
};
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);
if (idx === -1) return;

View File

@ -1,5 +1,6 @@
import { PortalProvider } from "@gorhom/portal";
import type { ReactNode } from "react";
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
// 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")) },
// ]),
// ),
// );
// this file is auto-generated via a postinstall script.
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 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 { setServerData } from "~/utils";
import { resources, supportedLanguages } from "./translations.compile";
import { languageDetector } from "./translations-detector";
export const TranslationsProvider = ({ children }: { children: ReactNode }) => {
const val = useMemo(() => {
const i18n = i18next.createInstance();
i18n.init({
i18n.use(languageDetector).init({
interpolation: {
escapeValue: false,
},

View File

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

View File

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

View File

@ -1,6 +1,10 @@
import { z } from "zod/v4";
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";
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);
};
export const deleteAccount = async () => {
// await queryFn({
// method: "DELETE",
// url: "auth/users/me",
// parser: null,
// });
const accounts = readAccounts();
const account = accounts.find((x) => x.selected);
if (account) {
await queryFn({
method: "DELETE",
url: "auth/users/me",
authToken: account.token,
parser: null,
});
}
logout();
};

View File

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

View File

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

View File

@ -1,81 +1,67 @@
/*
* 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 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 Username from "@material-symbols/svg-400/outlined/badge.svg";
import Mail from "@material-symbols/svg-400/outlined/mail.svg";
import Password from "@material-symbols/svg-400/outlined/password.svg";
// import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
import Delete from "@material-symbols/svg-400/rounded/delete.svg";
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
// import * as ImagePicker from "expo-image-picker";
import { type ComponentProps, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-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 Username from "@material-symbols/svg-400/outlined/badge.svg";
import Mail from "@material-symbols/svg-400/outlined/mail.svg";
import Password from "@material-symbols/svg-400/outlined/password.svg";
import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
import Delete from "@material-symbols/svg-400/rounded/delete.svg";
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
function dataURItoBlob(dataURI: string) {
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" });
}
// function dataURItoBlob(dataURI: string) {
// 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 = () => {
const account = useAccount()!;
const { css, theme } = useYoshiki();
const { t } = useTranslation();
const [setPopup, close] = usePopup();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { mutateAsync } = useMutation({
mutationFn: async (update: Partial<Account>) =>
await queryFn({
path: ["auth", "me"],
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({
mutationFn: async (request: { newPassword: string; oldPassword: string }) =>
await queryFn({
path: ["auth", "password-reset"],
method: "POST",
body: request,
method: "PATCH",
path: ["auth", "users", "me", "password"],
compute: (body: { oldPassword: string; newPassword: string }) => ({
body,
}),
invalidate: null,
});
return (
@ -106,7 +92,8 @@ export const AccountSettings = () => {
],
{
cancelable: true,
userInterfaceStyle: theme.mode === "auto" ? "light" : theme.mode,
userInterfaceStyle:
theme.mode === "auto" ? "light" : theme.mode,
icon: "warning",
},
);
@ -137,43 +124,47 @@ export const AccountSettings = () => {
}
/>
</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
icon={AccountCircle}
customIcon={<Avatar src={account.logo} />}
label={t("settings.account.avatar.label")}
description={t("settings.account.avatar.description")}
icon={Mail}
label={t("settings.account.email.label")}
description={account.email}
>
<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
text={t("misc.edit")}
onPress={() =>
@ -202,8 +193,10 @@ export const AccountSettings = () => {
<ChangePasswordPopup
icon={Password}
label={t("settings.account.password.label")}
hasPassword={account.hasPassword}
apply={async (op, np) => await editPassword({ oldPassword: op, newPassword: np })}
hasPassword={true}
apply={async (op, np) =>
await editPassword({ oldPassword: op, newPassword: np })
}
close={close}
/>,
)
@ -236,7 +229,9 @@ const ChangePopup = ({
<Popup>
{({ css }) => (
<>
<View {...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}>
<View
{...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}
>
<Icon icon={icon} />
<H1 {...css({ fontSize: rem(2) })}>{label}</H1>
</View>
@ -246,7 +241,13 @@ const ChangePopup = ({
value={value}
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
text={t("misc.cancel")}
onPress={() => close()}
@ -289,7 +290,9 @@ const ChangePasswordPopup = ({
<Popup>
{({ css }) => (
<>
<View {...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}>
<View
{...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}
>
<Icon icon={icon} />
<H1 {...css({ fontSize: rem(2) })}>{label}</H1>
</View>
@ -303,14 +306,22 @@ const ChangePasswordPopup = ({
/>
)}
<PasswordInput
autoComplete="password-new"
autoComplete="new-password"
variant="big"
value={newValue}
onChangeText={(v) => setNewValue(v)}
placeholder={t("settings.account.password.newPassword")}
/>
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
<View {...css({ flexDirection: "row", alignSelf: "flex-end", gap: ts(1) })}>
{error && (
<P {...css({ color: (theme) => theme.colors.red })}>{error}</P>
)}
<View
{...css({
flexDirection: "row",
alignSelf: "flex-end",
gap: ts(1),
})}
>
<Button
text={t("misc.cancel")}
onPress={() => close()}
@ -323,7 +334,7 @@ const ChangePasswordPopup = ({
await apply(oldValue, newValue);
close();
} catch (e) {
setError((e as KyooErrors).errors[0]);
setError((e as KyooError).message);
}
}}
{...css({ minWidth: rem(6) })}

View File

@ -1,24 +1,7 @@
/*
* 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 User, queryFn, useAccount } from "@kyoo/models";
import { Children, type ReactElement, type ReactNode } from "react";
import { type Falsy, View } from "react-native";
import { percent, px, rem, useYoshiki } from "yoshiki/native";
import type { User } from "~/models";
import {
Container,
H1,
@ -27,13 +10,10 @@ import {
P,
SubP,
SwitchVariant,
imageBorderRadius,
ts,
} from "@kyoo/primitives";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Children, type ReactElement, type ReactNode } from "react";
import { type Falsy, View } from "react-native";
import { percent, px, rem, useYoshiki } from "yoshiki/native";
} from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { useMutation } from "~/query";
export const Preference = ({
customIcon,
@ -75,7 +55,7 @@ export const Preference = ({
>
{customIcon ?? <Icon icon={icon} />}
<View {...css({ flexShrink: 1 })}>
<P {...css({ marginBottom: 0 })}>{label}</P>
<P {...(css({ marginBottom: 0 }) as any)}>{label}</P>
<SubP>{description}</SubP>
</View>
</View>
@ -118,7 +98,7 @@ export const SettingsContainer = ({
<View
{...css({
bg: (theme) => theme.background,
borderRadius: px(imageBorderRadius),
borderRadius: px(6),
})}
>
{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 queryClient = useQueryClient();
const { mutateAsync } = useMutation({
mutationFn: async (update: Partial<User["settings"]>) =>
await queryFn({
path: ["auth", "me"],
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) => {
const next = { ...account!, settings: { ...account!.settings, ...newSettings } };
await queryClient.cancelQueries({ queryKey: ["auth", "me"] });
const previous = queryClient.getQueryData(["auth", "me"]);
queryClient.setQueryData(["auth", "me"], next);
return { previous, next };
optimistic: (update) => ({
...account,
claims: {
...account!.claims,
settings: { ...account!.claims.settings, ...update },
},
onError: (_, __, context) => {
queryClient.setQueryData(["auth", "me"], context!.previous);
},
onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }),
}),
invalidate: ["auth", "users", "me"],
});
if (!account) return null;
return [
account.settings[setting],
async (value: User["settings"][Setting]) => {
account.claims.settings[setting],
async (value: User["claims"]["settings"][Setting]) => {
await mutateAsync({ [setting]: value });
},
] 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 PlayModeI from "@material-symbols/svg-400/rounded/display_settings-fill.svg";
import AudioLanguage from "@material-symbols/svg-400/rounded/music_note-fill.svg";
import langmap from "langmap";
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 = () => {
const { t } = useTranslation();
@ -61,7 +55,9 @@ export const PlaybackSettings = () => {
onValueChange={(value) => setAudio(value)}
values={["default", ...languageCodes]}
getLabel={(key) =>
key === "default" ? t("mediainfo.default") : (getLanguageName(key) ?? key)
key === "default"
? t("mediainfo.default")
: (getLanguageName(key) ?? key)
}
/>
</Preference>
@ -73,7 +69,9 @@ export const PlaybackSettings = () => {
<Select
label={t("settings.playback.subtitleLanguage.label")}
value={subtitle ?? "none"}
onValueChange={(value) => setSubtitle(value === "none" ? null : value)}
onValueChange={(value) =>
setSubtitle(value === "none" ? null : value)
}
values={["none", "default", ...languageCodes]}
getLabel={(key) =>
key === "none"

View File

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