mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-08-07 15:50:18 +00:00
Nouvelle adresse movix + nouvelles fonctionalités
Modification du Oauth : Pouvoir manager les apps depuis le panel admin Correction de l'affichage des images des admins Majs des urls extension, userscript et premid Correction d'un crash sur les commentaires Ajout d'un light mode sur le frontend
This commit is contained in:
parent
8b8e63f504
commit
2045dbd2c9
47 changed files with 5153 additions and 372 deletions
|
|
@ -65,7 +65,7 @@ Le fichier `API/Mainapi/.env.example` est la référence complète. En pratique,
|
|||
- cache et coordination : `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`, `NUM_WORKERS`
|
||||
- scraping / proxy : `PROXY_SERVER_URL`, `CF_PROXY_403_URL`, `BYPASS403_SERVER_URL`, `SOCKS5_PROXIES`, `HTTP_PROXIES`
|
||||
- anti-abuse / forms : `TURNSTILE_SECRET_KEY`, `TURNSTILE_INVISIBLE_SECRETKEY`
|
||||
- paiement / VIP : variables `VIP_*`, `BTC_EXPLORER_API`, `LTC_EXPLORER_API`
|
||||
- paiement / VIP : variables `VIP_*`, `BLOCKCYPHER_TOKEN`
|
||||
|
||||
Certaines intégrations sont très spécifiques à des sources données, par exemple les cookies `DARKIWORLD_*`, `FSTREAM_LOGIN_*` ou `XTREAM_*`.
|
||||
|
||||
|
|
|
|||
|
|
@ -260,6 +260,20 @@ app.use(jsonParseErrorHandler);
|
|||
|
||||
app.use(express.urlencoded({ extended: true, limit: "5mb" })); // Reduced from 1000mb to prevent abuse
|
||||
|
||||
// 8. Serve uploaded OAuth app icons (`public/oauth-icons/<filename>`).
|
||||
// Le panel admin upload ici, OAuthAuthorizePage lit `/oauth-icons/<filename>`.
|
||||
const { ICON_DIR: OAUTH_ICON_DIR } = require('./utils/oauthClientsDb');
|
||||
app.use(
|
||||
'/oauth-icons',
|
||||
express.static(OAUTH_ICON_DIR, {
|
||||
fallthrough: false,
|
||||
maxAge: '7d',
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// ==========================================================================
|
||||
// Configure route modules with dependencies from extracted utilities
|
||||
// ==========================================================================
|
||||
|
|
@ -459,6 +473,7 @@ app.use('/api/profiles', require('./routes/profiles'));
|
|||
app.use('/api/help', require('./routes/helpFeedback'));
|
||||
app.use('/api/auth', require('./routes/authRoutes'));
|
||||
app.use('/api/oauth', oauthRouter);
|
||||
app.use('/api/admin/oauth-apps', require('./routes/adminOauthApps'));
|
||||
app.use('/api/sessions', require('./routes/sessions'));
|
||||
app.use('/api', require('./routes/debrid'));
|
||||
app.use('/proxy', require('./routes/proxy'));
|
||||
|
|
@ -533,6 +548,16 @@ const appReady = (async () => {
|
|||
await ensureOAuthStorage(pool);
|
||||
console.log('OAuth tables initialized successfully');
|
||||
|
||||
// OAuth client config (table oauth_clients + stats + grants VIP).
|
||||
// ensureTables et migrateLegacyJsonIfNeeded sont protégés par le lock
|
||||
// mais idempotents — sûr sur restart cluster. reloadCache hydrate
|
||||
// le cache in-process de CE worker (chaque worker a le sien).
|
||||
const oauthClientsDb = require('./utils/oauthClientsDb');
|
||||
await oauthClientsDb.ensureTables();
|
||||
await oauthClientsDb.migrateLegacyJsonIfNeeded();
|
||||
await oauthClientsDb.reloadCache();
|
||||
console.log('OAuth client tables initialized successfully');
|
||||
|
||||
// Initialize Wishboard routes
|
||||
const { createWishboardRouter } = require("./wishboardRoutes");
|
||||
const wishboardRouter = createWishboardRouter(pool, redis);
|
||||
|
|
|
|||
112
API/Mainapi/exportscripts/add_oauth_apps_tables.sql
Normal file
112
API/Mainapi/exportscripts/add_oauth_apps_tables.sql
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
-- Migration : passage du fichier `data/oauth-clients.json` à 3 tables MySQL.
|
||||
-- - oauth_clients : config des apps (remplace le JSON)
|
||||
-- - oauth_app_stats : compteur d'appels par app + type d'event
|
||||
-- - oauth_vip_grants : historique des grants VIP émis par chaque app
|
||||
--
|
||||
-- Idempotent grâce à `CREATE TABLE IF NOT EXISTS`.
|
||||
-- Lance avec : `mysql -u <user> -p movix < add_oauth_apps_tables.sql`
|
||||
-- ou via le script `routes/admin.js` au démarrage (auto-migrate).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Identifiant public visible dans le query OAuth (?client_id=...).
|
||||
client_id VARCHAR(128) NOT NULL UNIQUE,
|
||||
|
||||
-- Nom affiché sur la page d'autorisation et dans le panel admin.
|
||||
client_name VARCHAR(200) NOT NULL,
|
||||
|
||||
description TEXT NULL,
|
||||
homepage_url VARCHAR(500) NULL,
|
||||
|
||||
-- JSON arrays — sérialisation gérée côté Node.
|
||||
redirect_uris JSON NOT NULL,
|
||||
allowed_scopes JSON NOT NULL,
|
||||
|
||||
-- Type de client : public (PKCE obligatoire, pas de secret) ou
|
||||
-- confidentiel (client_secret nécessaire).
|
||||
public_client TINYINT(1) NOT NULL DEFAULT 1,
|
||||
require_pkce TINYINT(1) NOT NULL DEFAULT 1,
|
||||
-- Secret en clair (uniquement si publicClient = 0).
|
||||
client_secret VARCHAR(256) NULL,
|
||||
|
||||
-- Nom de fichier de l'icône (relatif à `public/oauth-icons/`).
|
||||
-- Ex : "movix-mcp-1234567890.png". NULL = pas d'icône custom.
|
||||
icon_filename VARCHAR(200) NULL,
|
||||
|
||||
-- Compteur de jours VIP que l'app peut distribuer via /api/oauth/vip/grant.
|
||||
-- Décrément à chaque grant ; admin peut alimenter via le panel.
|
||||
vip_days_balance INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Désactivation soft (cache l'app de la list mais garde l'historique).
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
|
||||
created_at BIGINT UNSIGNED NOT NULL,
|
||||
updated_at BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_client_id (client_id),
|
||||
KEY idx_is_active (is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Stats : un event = une ligne. Permet de grapher l'usage par app.
|
||||
-- Cleanup périodique : `DELETE FROM oauth_app_stats WHERE created_at < (now - 90j)`.
|
||||
CREATE TABLE IF NOT EXISTS oauth_app_stats (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Référence vers oauth_clients.client_id (pas la PK numérique, pour
|
||||
-- survivre à une suppression).
|
||||
client_id VARCHAR(128) NOT NULL,
|
||||
|
||||
-- Type d'event : 'authorize' (page d'auth affichée), 'authorize_granted'
|
||||
-- (user a cliqué Autoriser), 'authorize_denied', 'token' (échange code →
|
||||
-- token), 'api_call' (toute requête OAuth authentifiée), 'vip_grant'.
|
||||
event_type VARCHAR(32) NOT NULL,
|
||||
|
||||
-- User concerné (si applicable). Format `userType:userId`.
|
||||
user_id VARCHAR(160) NULL,
|
||||
|
||||
-- Métadonnées libres (path, status, scope demandé, etc.) en JSON.
|
||||
metadata JSON NULL,
|
||||
|
||||
created_at BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_client_event (client_id, event_type, created_at),
|
||||
KEY idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Historique des grants VIP. Chaque ligne = un grant fait par une app
|
||||
-- à un user. Sert d'audit + sert à recréer une access_key si l'user
|
||||
-- perd la sienne.
|
||||
CREATE TABLE IF NOT EXISTS oauth_vip_grants (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
client_id VARCHAR(128) NOT NULL,
|
||||
|
||||
-- User qui reçoit le VIP. Format `userType:userId`.
|
||||
user_id VARCHAR(160) NOT NULL,
|
||||
user_type VARCHAR(16) NOT NULL,
|
||||
user_id_only VARCHAR(128) NOT NULL,
|
||||
|
||||
-- Jours grantés (décrémenté de oauth_clients.vip_days_balance).
|
||||
days_granted INT UNSIGNED NOT NULL,
|
||||
|
||||
-- Access key générée (référence vers access_keys.key_value).
|
||||
access_key_value VARCHAR(128) NOT NULL,
|
||||
|
||||
-- Date de validité de la clé
|
||||
expires_at DATETIME NOT NULL,
|
||||
|
||||
-- Audit
|
||||
granted_at BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
-- Si l'admin révoque le grant : on flag (mais on n'efface pas la clé
|
||||
-- automatiquement — l'admin doit le faire séparément).
|
||||
revoked_at BIGINT UNSIGNED NULL,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_client_id (client_id, granted_at),
|
||||
KEY idx_user_id (user_id, granted_at),
|
||||
KEY idx_access_key (access_key_value)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
|
@ -4326,8 +4326,8 @@ router.delete("/cache", async (req, res) => {
|
|||
const XTREAM_URL = (process.env.XTREAM_URL || "").replace(/\/+$/, "");
|
||||
const XTREAM_USER = process.env.XTREAM_USER || "";
|
||||
const XTREAM_PASS = process.env.XTREAM_PASS || "";
|
||||
const IPTV_IMAGE_PROXY = "https://proxy.movix.cash/proxy";
|
||||
const IPTV_STREAM_PROXY = "https://proxiesembed.movix.cash/proxy";
|
||||
const IPTV_IMAGE_PROXY = "https://proxy.movix.tax/proxy";
|
||||
const IPTV_STREAM_PROXY = "https://proxiesembed.movix.tax/proxy";
|
||||
|
||||
// Cache catégories IPTV en mémoire (change rarement)
|
||||
let iptvCategoriesCache = null;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ const cors = require("cors");
|
|||
const { getOAuthAllowedCorsOrigins } = require('../utils/oauthClients');
|
||||
|
||||
const STATIC_ALLOWED_DOMAINS = [
|
||||
'movix.tax',
|
||||
'movix.cash',
|
||||
'movix.blog',
|
||||
'movix.rodeo',
|
||||
'movix.club',
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ function domainRestriction(req, res, next) {
|
|||
|
||||
const allowedDomains = [
|
||||
'localhost:3000',
|
||||
'movix.tax',
|
||||
'movix.cash',
|
||||
'movix.blog',
|
||||
'movix.rodeo',
|
||||
'movix.club',
|
||||
|
|
|
|||
BIN
API/Mainapi/public/oauth-icons/movix-mcp-1778761296022.jpg
Normal file
BIN
API/Mainapi/public/oauth-icons/movix-mcp-1778761296022.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
604
API/Mainapi/routes/adminOauthApps.js
Normal file
604
API/Mainapi/routes/adminOauthApps.js
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
/**
|
||||
* Routes admin pour gérer les applications OAuth Movix.
|
||||
* Mount : `app.use('/api/admin/oauth-apps', adminOauthAppsRouter)`.
|
||||
*
|
||||
* Toutes les routes sont protégées par `isAdmin` (table `admins`).
|
||||
* Source de vérité : la table `oauth_clients` (alimentée au boot par
|
||||
* `oauthClientsDb.reloadCache()`). Toute mutation appelle `reloadCache()`
|
||||
* en fin de requête pour rafraîchir le cache du worker courant.
|
||||
*
|
||||
* Note multi-worker : chaque worker a son propre cache in-process. Une
|
||||
* mutation depuis le worker A ne rafraîchit pas le cache du worker B
|
||||
* immédiatement. C'est acceptable car :
|
||||
* 1) les opérations admin sont rares ;
|
||||
* 2) le cache est rechargé au boot ;
|
||||
* 3) une lecture stale max 1 requête.
|
||||
* Si besoin d'invalidation cross-worker → publier un message Redis.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { ipKeyGenerator } = require('express-rate-limit');
|
||||
|
||||
const { isAdmin } = require('../middleware/auth');
|
||||
const { getPool } = require('../mysqlPool');
|
||||
const oauthClientsDb = require('../utils/oauthClientsDb');
|
||||
const { KNOWN_OAUTH_SCOPES } = require('../utils/oauthClients');
|
||||
const { createRedisRateLimitStore } = require('../utils/redisRateLimitStore');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const ALLOWED_ICON_MIME = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/webp': 'webp',
|
||||
};
|
||||
const MAX_ICON_SIZE_BYTES = 256 * 1024; // 256 KB
|
||||
|
||||
const CLIENT_ID_RE = /^[a-z0-9][a-z0-9-]{1,64}$/;
|
||||
|
||||
// Petit rate-limiter pour les routes admin OAuth (anti-bruteforce sur les secrets).
|
||||
const adminOauthAppsLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
store: createRedisRateLimitStore({ prefix: 'rate-limit:admin:oauth-apps:' }),
|
||||
passOnStoreError: true,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) =>
|
||||
req.headers['cf-connecting-ip']
|
||||
|| req.headers['x-forwarded-for']?.split(',')[0].trim()
|
||||
|| ipKeyGenerator(req.ip),
|
||||
validate: { xForwardedForHeader: false, ip: false },
|
||||
});
|
||||
|
||||
router.use(adminOauthAppsLimiter);
|
||||
router.use(isAdmin);
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function badRequest(res, message) {
|
||||
return res.status(400).json({ success: false, error: message });
|
||||
}
|
||||
|
||||
function notFound(res, message = 'Application OAuth introuvable') {
|
||||
return res.status(404).json({ success: false, error: message });
|
||||
}
|
||||
|
||||
function serverError(res, error, message = 'Erreur serveur') {
|
||||
console.error('[adminOauthApps]', message, error?.message || error);
|
||||
return res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
|
||||
function sanitizeClientId(raw) {
|
||||
const value = String(raw || '').trim().toLowerCase();
|
||||
return CLIENT_ID_RE.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function sanitizeClientName(raw) {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const value = raw.trim().slice(0, 200);
|
||||
return value.length >= 2 ? value : null;
|
||||
}
|
||||
|
||||
function sanitizeDescription(raw) {
|
||||
if (raw == null || raw === '') return null;
|
||||
if (typeof raw !== 'string') return null;
|
||||
return raw.trim().slice(0, 2000) || null;
|
||||
}
|
||||
|
||||
function sanitizeHttpUrl(raw) {
|
||||
if (raw == null || raw === '') return null;
|
||||
if (typeof raw !== 'string') return null;
|
||||
try {
|
||||
const url = new URL(raw.trim());
|
||||
// HTTPS only : le homepageUrl est rendu en lien cliquable sur la page
|
||||
// d'autorisation OAuth (boundary de confiance pour l'utilisateur).
|
||||
// Pas d'exception loopback ici — c'est pour le marketing, pas pour OAuth.
|
||||
if (url.protocol !== 'https:') return null;
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeRedirectUris(rawArray) {
|
||||
if (!Array.isArray(rawArray)) return null;
|
||||
const result = [];
|
||||
for (const raw of rawArray) {
|
||||
if (typeof raw !== 'string') continue;
|
||||
try {
|
||||
const url = new URL(raw.trim());
|
||||
const host = url.hostname.toLowerCase();
|
||||
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.localhost');
|
||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)) {
|
||||
continue;
|
||||
}
|
||||
url.hash = '';
|
||||
result.push(url.toString());
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return result.length > 0 ? Array.from(new Set(result)) : null;
|
||||
}
|
||||
|
||||
function sanitizeScopes(rawArray) {
|
||||
if (!Array.isArray(rawArray)) return null;
|
||||
const result = Array.from(new Set(
|
||||
rawArray
|
||||
.map((s) => String(s || '').trim())
|
||||
.filter((s) => KNOWN_OAUTH_SCOPES.includes(s)),
|
||||
));
|
||||
return result.length > 0 ? result : null;
|
||||
}
|
||||
|
||||
function generateClientSecret() {
|
||||
// 64 chars hex = 256 bits — assez pour un secret OAuth.
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
function serializeAppRow(row) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
clientId: row.client_id,
|
||||
clientName: row.client_name,
|
||||
description: row.description || null,
|
||||
homepageUrl: row.homepage_url || null,
|
||||
redirectUris: safeJsonParse(row.redirect_uris, []),
|
||||
allowedScopes: safeJsonParse(row.allowed_scopes, []),
|
||||
publicClient: row.public_client === 1 || row.public_client === true,
|
||||
requirePkce: row.require_pkce === 1 || row.require_pkce === true,
|
||||
hasClientSecret: !!row.client_secret,
|
||||
iconFilename: row.icon_filename || null,
|
||||
iconUrl: row.icon_filename ? `/oauth-icons/${row.icon_filename}` : null,
|
||||
vipDaysBalance: Number(row.vip_days_balance || 0),
|
||||
isActive: row.is_active === 1 || row.is_active === true,
|
||||
createdAt: Number(row.created_at || 0),
|
||||
updatedAt: Number(row.updated_at || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function safeJsonParse(raw, fallback) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return Array.isArray(raw) ? raw : fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAppByClientId(pool, clientId) {
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT * FROM oauth_clients WHERE client_id = ? LIMIT 1',
|
||||
[clientId],
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function removeIconFile(filename) {
|
||||
if (!filename) return;
|
||||
const target = path.join(oauthClientsDb.ICON_DIR, path.basename(filename));
|
||||
try {
|
||||
await fsp.unlink(target);
|
||||
} catch {
|
||||
/* swallow: déjà absent */
|
||||
}
|
||||
}
|
||||
|
||||
// ─── routes ─────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/scopes', (req, res) => {
|
||||
res.json({ success: true, scopes: [...KNOWN_OAUTH_SCOPES] });
|
||||
});
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const includeInactive = req.query?.inactive === '1' || req.query?.inactive === 'true';
|
||||
const whereSql = includeInactive ? '' : 'WHERE is_active = 1';
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT * FROM oauth_clients ${whereSql} ORDER BY created_at DESC`,
|
||||
);
|
||||
|
||||
// Stats compactes par app (30 derniers jours) pour l'affichage en liste.
|
||||
const since = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
const [statsRows] = await pool.execute(
|
||||
`SELECT client_id, event_type, COUNT(*) AS n
|
||||
FROM oauth_app_stats
|
||||
WHERE created_at >= ?
|
||||
GROUP BY client_id, event_type`,
|
||||
[since],
|
||||
);
|
||||
const statsByClient = new Map();
|
||||
for (const r of statsRows) {
|
||||
if (!statsByClient.has(r.client_id)) statsByClient.set(r.client_id, {});
|
||||
statsByClient.get(r.client_id)[r.event_type] = Number(r.n);
|
||||
}
|
||||
|
||||
const apps = rows.map((row) => ({
|
||||
...serializeAppRow(row),
|
||||
stats30d: statsByClient.get(row.client_id) || {},
|
||||
}));
|
||||
return res.json({ success: true, apps });
|
||||
} catch (err) {
|
||||
return serverError(res, err, 'Impossible de lister les applications');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:clientId', async (req, res) => {
|
||||
try {
|
||||
const clientId = sanitizeClientId(req.params.clientId);
|
||||
if (!clientId) return badRequest(res, 'clientId invalide');
|
||||
const pool = getPool();
|
||||
const row = await fetchAppByClientId(pool, clientId);
|
||||
if (!row) return notFound(res);
|
||||
return res.json({ success: true, app: serializeAppRow(row) });
|
||||
} catch (err) {
|
||||
return serverError(res, err, 'Impossible de charger l\'application');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const clientId = sanitizeClientId(req.body?.clientId);
|
||||
if (!clientId) return badRequest(res, 'clientId invalide (a-z, 0-9, -, 2 à 65 caractères)');
|
||||
const clientName = sanitizeClientName(req.body?.clientName);
|
||||
if (!clientName) return badRequest(res, 'clientName requis (≥2 caractères)');
|
||||
const redirectUris = sanitizeRedirectUris(req.body?.redirectUris);
|
||||
if (!redirectUris) return badRequest(res, 'redirectUris requis (≥1 URI HTTPS ou loopback http)');
|
||||
const allowedScopes = sanitizeScopes(req.body?.allowedScopes);
|
||||
if (!allowedScopes) return badRequest(res, 'allowedScopes requis (≥1 scope connu)');
|
||||
const description = sanitizeDescription(req.body?.description);
|
||||
const homepageUrl = sanitizeHttpUrl(req.body?.homepageUrl);
|
||||
const publicClient = req.body?.publicClient !== false;
|
||||
const requirePkce = publicClient ? true : req.body?.requirePkce === true;
|
||||
const generatedSecret = !publicClient ? generateClientSecret() : null;
|
||||
|
||||
const pool = getPool();
|
||||
const existing = await fetchAppByClientId(pool, clientId);
|
||||
if (existing) return badRequest(res, 'Cet clientId existe déjà');
|
||||
|
||||
const now = Date.now();
|
||||
await pool.execute(
|
||||
`INSERT INTO oauth_clients
|
||||
(client_id, client_name, description, homepage_url, redirect_uris,
|
||||
allowed_scopes, public_client, require_pkce, client_secret,
|
||||
is_active, vip_days_balance, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?)`,
|
||||
[
|
||||
clientId,
|
||||
clientName,
|
||||
description,
|
||||
homepageUrl,
|
||||
JSON.stringify(redirectUris),
|
||||
JSON.stringify(allowedScopes),
|
||||
publicClient ? 1 : 0,
|
||||
requirePkce ? 1 : 0,
|
||||
generatedSecret,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
await oauthClientsDb.reloadCache();
|
||||
const row = await fetchAppByClientId(pool, clientId);
|
||||
const serialized = serializeAppRow(row);
|
||||
// Le secret n'est exposé qu'UNE fois (à la création) — l'admin doit le copier.
|
||||
return res.json({
|
||||
success: true,
|
||||
app: serialized,
|
||||
clientSecret: generatedSecret, // null pour les clients publics
|
||||
});
|
||||
} catch (err) {
|
||||
return serverError(res, err, 'Impossible de créer l\'application');
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:clientId', async (req, res) => {
|
||||
try {
|
||||
const clientId = sanitizeClientId(req.params.clientId);
|
||||
if (!clientId) return badRequest(res, 'clientId invalide');
|
||||
|
||||
const pool = getPool();
|
||||
const existing = await fetchAppByClientId(pool, clientId);
|
||||
if (!existing) return notFound(res);
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
|
||||
if (req.body?.clientName !== undefined) {
|
||||
const v = sanitizeClientName(req.body.clientName);
|
||||
if (!v) return badRequest(res, 'clientName invalide');
|
||||
updates.push('client_name = ?'); params.push(v);
|
||||
}
|
||||
if (req.body?.description !== undefined) {
|
||||
updates.push('description = ?'); params.push(sanitizeDescription(req.body.description));
|
||||
}
|
||||
if (req.body?.homepageUrl !== undefined) {
|
||||
updates.push('homepage_url = ?'); params.push(sanitizeHttpUrl(req.body.homepageUrl));
|
||||
}
|
||||
if (req.body?.redirectUris !== undefined) {
|
||||
const v = sanitizeRedirectUris(req.body.redirectUris);
|
||||
if (!v) return badRequest(res, 'redirectUris invalide (≥1 URI HTTPS ou loopback http)');
|
||||
updates.push('redirect_uris = ?'); params.push(JSON.stringify(v));
|
||||
}
|
||||
if (req.body?.allowedScopes !== undefined) {
|
||||
const v = sanitizeScopes(req.body.allowedScopes);
|
||||
if (!v) return badRequest(res, 'allowedScopes invalide');
|
||||
updates.push('allowed_scopes = ?'); params.push(JSON.stringify(v));
|
||||
}
|
||||
if (req.body?.publicClient !== undefined) {
|
||||
const becomesPublic = req.body.publicClient === true;
|
||||
updates.push('public_client = ?'); params.push(becomesPublic ? 1 : 0);
|
||||
if (becomesPublic) {
|
||||
// Switch confidential → public : on force pkce et on supprime le secret.
|
||||
updates.push('require_pkce = 1');
|
||||
updates.push('client_secret = NULL');
|
||||
}
|
||||
}
|
||||
if (req.body?.requirePkce !== undefined) {
|
||||
updates.push('require_pkce = ?'); params.push(req.body.requirePkce === true ? 1 : 0);
|
||||
}
|
||||
if (req.body?.isActive !== undefined) {
|
||||
updates.push('is_active = ?'); params.push(req.body.isActive === true ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) return badRequest(res, 'Aucun champ à mettre à jour');
|
||||
|
||||
updates.push('updated_at = ?'); params.push(Date.now());
|
||||
params.push(clientId);
|
||||
|
||||
await pool.execute(
|
||||
`UPDATE oauth_clients SET ${updates.join(', ')} WHERE client_id = ?`,
|
||||
params,
|
||||
);
|
||||
|
||||
await oauthClientsDb.reloadCache();
|
||||
const row = await fetchAppByClientId(pool, clientId);
|
||||
return res.json({ success: true, app: serializeAppRow(row) });
|
||||
} catch (err) {
|
||||
return serverError(res, err, 'Impossible de mettre à jour l\'application');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:clientId/regenerate-secret', async (req, res) => {
|
||||
try {
|
||||
const clientId = sanitizeClientId(req.params.clientId);
|
||||
if (!clientId) return badRequest(res, 'clientId invalide');
|
||||
const pool = getPool();
|
||||
const existing = await fetchAppByClientId(pool, clientId);
|
||||
if (!existing) return notFound(res);
|
||||
if (existing.public_client === 1 || existing.public_client === true) {
|
||||
return badRequest(res, 'Les clients publics n\'utilisent pas de clientSecret');
|
||||
}
|
||||
const newSecret = generateClientSecret();
|
||||
await pool.execute(
|
||||
'UPDATE oauth_clients SET client_secret = ?, updated_at = ? WHERE client_id = ?',
|
||||
[newSecret, Date.now(), clientId],
|
||||
);
|
||||
await oauthClientsDb.reloadCache();
|
||||
return res.json({ success: true, clientSecret: newSecret });
|
||||
} catch (err) {
|
||||
return serverError(res, err, 'Impossible de régénérer le secret');
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:clientId', async (req, res) => {
|
||||
try {
|
||||
const clientId = sanitizeClientId(req.params.clientId);
|
||||
if (!clientId) return badRequest(res, 'clientId invalide');
|
||||
const pool = getPool();
|
||||
const existing = await fetchAppByClientId(pool, clientId);
|
||||
if (!existing) return notFound(res);
|
||||
// Hard delete : on supprime la ligne ; les stats et grants restent
|
||||
// (FK absente volontairement — historique d'audit).
|
||||
await pool.execute('DELETE FROM oauth_clients WHERE client_id = ?', [clientId]);
|
||||
// Cleanup icône si présente.
|
||||
if (existing.icon_filename) {
|
||||
await removeIconFile(existing.icon_filename);
|
||||
}
|
||||
await oauthClientsDb.reloadCache();
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
return serverError(res, err, 'Impossible de supprimer l\'application');
|
||||
}
|
||||
});
|
||||
|
||||
// Upload icône : JSON body { mimeType, dataBase64 }
|
||||
// On évite multer pour ne pas ajouter une dépendance ; les icônes sont
|
||||
// petites (< 256KB) donc base64 dans le body JSON est OK.
|
||||
router.post('/:clientId/icon', async (req, res) => {
|
||||
try {
|
||||
const clientId = sanitizeClientId(req.params.clientId);
|
||||
if (!clientId) return badRequest(res, 'clientId invalide');
|
||||
const mimeType = String(req.body?.mimeType || '').trim().toLowerCase();
|
||||
const ext = ALLOWED_ICON_MIME[mimeType];
|
||||
if (!ext) return badRequest(res, 'mimeType non supporté (png / jpeg / webp)');
|
||||
const dataBase64 = String(req.body?.dataBase64 || '');
|
||||
if (!dataBase64) return badRequest(res, 'dataBase64 requis');
|
||||
|
||||
let buffer;
|
||||
try {
|
||||
buffer = Buffer.from(dataBase64, 'base64');
|
||||
} catch {
|
||||
return badRequest(res, 'dataBase64 invalide');
|
||||
}
|
||||
if (buffer.length === 0) return badRequest(res, 'Fichier vide');
|
||||
if (buffer.length > MAX_ICON_SIZE_BYTES) {
|
||||
return badRequest(res, `Fichier trop gros (max ${Math.round(MAX_ICON_SIZE_BYTES / 1024)} KB)`);
|
||||
}
|
||||
|
||||
// Vérification rapide du magic number pour bloquer un PNG renommé en .jpg etc.
|
||||
const isPng = buffer.length >= 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47;
|
||||
const isJpeg = buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
||||
const isWebp = buffer.length >= 12
|
||||
&& buffer.slice(0, 4).toString('ascii') === 'RIFF'
|
||||
&& buffer.slice(8, 12).toString('ascii') === 'WEBP';
|
||||
if ((ext === 'png' && !isPng) || (ext === 'jpg' && !isJpeg) || (ext === 'webp' && !isWebp)) {
|
||||
return badRequest(res, 'Le contenu ne correspond pas au mimeType déclaré');
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const existing = await fetchAppByClientId(pool, clientId);
|
||||
if (!existing) return notFound(res);
|
||||
|
||||
// Ensure dir exists (sécurité : ICON_DIR géré par ensureTables au boot).
|
||||
if (!fs.existsSync(oauthClientsDb.ICON_DIR)) {
|
||||
fs.mkdirSync(oauthClientsDb.ICON_DIR, { recursive: true, mode: 0o755 });
|
||||
}
|
||||
|
||||
const filename = `${clientId}-${Date.now()}.${ext}`;
|
||||
const targetPath = path.join(oauthClientsDb.ICON_DIR, filename);
|
||||
await fsp.writeFile(targetPath, buffer, { mode: 0o644 });
|
||||
|
||||
// Cleanup ancienne icône avant d'enregistrer la nouvelle.
|
||||
const previousFilename = existing.icon_filename;
|
||||
await pool.execute(
|
||||
'UPDATE oauth_clients SET icon_filename = ?, updated_at = ? WHERE client_id = ?',
|
||||
[filename, Date.now(), clientId],
|
||||
);
|
||||
if (previousFilename && previousFilename !== filename) {
|
||||
await removeIconFile(previousFilename);
|
||||
}
|
||||
|
||||
await oauthClientsDb.reloadCache();
|
||||
return res.json({
|
||||
success: true,
|
||||
iconFilename: filename,
|
||||
iconUrl: `/oauth-icons/${filename}`,
|
||||
});
|
||||
} catch (err) {
|
||||
return serverError(res, err, 'Impossible d\'uploader l\'icône');
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:clientId/icon', async (req, res) => {
|
||||
try {
|
||||
const clientId = sanitizeClientId(req.params.clientId);
|
||||
if (!clientId) return badRequest(res, 'clientId invalide');
|
||||
const pool = getPool();
|
||||
const existing = await fetchAppByClientId(pool, clientId);
|
||||
if (!existing) return notFound(res);
|
||||
if (existing.icon_filename) {
|
||||
await removeIconFile(existing.icon_filename);
|
||||
await pool.execute(
|
||||
'UPDATE oauth_clients SET icon_filename = NULL, updated_at = ? WHERE client_id = ?',
|
||||
[Date.now(), clientId],
|
||||
);
|
||||
await oauthClientsDb.reloadCache();
|
||||
}
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
return serverError(res, err, 'Impossible de supprimer l\'icône');
|
||||
}
|
||||
});
|
||||
|
||||
// Alimente le compteur de jours VIP que l'app peut distribuer.
|
||||
// Body : { delta: number } → positif (ajoute) ou négatif (retire, sans descendre sous 0).
|
||||
router.post('/:clientId/vip-balance', async (req, res) => {
|
||||
try {
|
||||
const clientId = sanitizeClientId(req.params.clientId);
|
||||
if (!clientId) return badRequest(res, 'clientId invalide');
|
||||
const delta = Number(req.body?.delta);
|
||||
if (!Number.isInteger(delta) || delta === 0) {
|
||||
return badRequest(res, 'delta doit être un entier non nul');
|
||||
}
|
||||
if (Math.abs(delta) > 100000) {
|
||||
return badRequest(res, 'delta trop grand');
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const [rows] = await conn.execute(
|
||||
'SELECT id, vip_days_balance FROM oauth_clients WHERE client_id = ? FOR UPDATE',
|
||||
[clientId],
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
await conn.rollback();
|
||||
return notFound(res);
|
||||
}
|
||||
const current = Number(rows[0].vip_days_balance || 0);
|
||||
const next = Math.max(0, current + delta); // clamp à 0 pour éviter un balance négatif
|
||||
await conn.execute(
|
||||
'UPDATE oauth_clients SET vip_days_balance = ?, updated_at = ? WHERE id = ?',
|
||||
[next, Date.now(), rows[0].id],
|
||||
);
|
||||
await conn.commit();
|
||||
await oauthClientsDb.reloadCache();
|
||||
return res.json({
|
||||
success: true,
|
||||
previousBalance: current,
|
||||
newBalance: next,
|
||||
deltaApplied: next - current,
|
||||
});
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
} catch (err) {
|
||||
return serverError(res, err, 'Impossible de mettre à jour le balance VIP');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:clientId/stats', async (req, res) => {
|
||||
try {
|
||||
const clientId = sanitizeClientId(req.params.clientId);
|
||||
if (!clientId) return badRequest(res, 'clientId invalide');
|
||||
const sinceDays = Math.min(Math.max(Number(req.query?.sinceDays) || 30, 1), 365);
|
||||
const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
|
||||
const stats = await oauthClientsDb.getStats(clientId, sinceMs);
|
||||
if (!stats) return serverError(res, null, 'DB indisponible');
|
||||
return res.json({ success: true, sinceDays, ...stats });
|
||||
} catch (err) {
|
||||
return serverError(res, err, 'Impossible de récupérer les stats');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:clientId/grants', async (req, res) => {
|
||||
try {
|
||||
const clientId = sanitizeClientId(req.params.clientId);
|
||||
if (!clientId) return badRequest(res, 'clientId invalide');
|
||||
const limit = Math.min(Math.max(Number(req.query?.limit) || 50, 1), 500);
|
||||
const pool = getPool();
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT id, client_id, user_id, user_type, user_id_only, days_granted,
|
||||
access_key_value, expires_at, granted_at, revoked_at
|
||||
FROM oauth_vip_grants
|
||||
WHERE client_id = ?
|
||||
ORDER BY granted_at DESC
|
||||
LIMIT ?`,
|
||||
[clientId, limit],
|
||||
);
|
||||
const grants = rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
clientId: row.client_id,
|
||||
userId: row.user_id,
|
||||
userType: row.user_type,
|
||||
userIdOnly: row.user_id_only,
|
||||
daysGranted: Number(row.days_granted),
|
||||
// accessKey n'est PAS retournée — c'est un secret porté à l'user.
|
||||
// On expose juste les 4 derniers chars pour identifier.
|
||||
accessKeyHint: typeof row.access_key_value === 'string' && row.access_key_value.length > 4
|
||||
? `…${row.access_key_value.slice(-4)}`
|
||||
: null,
|
||||
expiresAt: row.expires_at,
|
||||
grantedAt: Number(row.granted_at),
|
||||
revokedAt: row.revoked_at ? Number(row.revoked_at) : null,
|
||||
}));
|
||||
return res.json({ success: true, grants });
|
||||
} catch (err) {
|
||||
return serverError(res, err, 'Impossible de récupérer les grants');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,23 +1,8 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const path = require('path');
|
||||
const fsp = require('fs').promises;
|
||||
const { getPool } = require('../mysqlPool');
|
||||
const { isUploaderOrAdmin } = require('../middleware/auth');
|
||||
|
||||
async function getUserData(userId, userType) {
|
||||
try {
|
||||
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, '');
|
||||
const safeUserType = userType === 'bip39' ? 'bip39' : 'oauth';
|
||||
const userPath = path.join(__dirname, '..', 'data', 'users', safeUserType, `${safeUserId}.json`);
|
||||
const data = JSON.parse(await fsp.readFile(userPath, 'utf8'));
|
||||
if (data.profiles && data.profiles.length > 0) {
|
||||
const p = data.profiles[0];
|
||||
return { username: p.name || 'Admin', avatar: p.avatar || null };
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return { username: 'Admin', avatar: null };
|
||||
}
|
||||
const { resolveAdminIdentity } = require('../utils/adminIdentity');
|
||||
|
||||
router.get('/admin/leaderboard', isUploaderOrAdmin, async (req, res) => {
|
||||
try {
|
||||
|
|
@ -75,14 +60,13 @@ router.get('/admin/leaderboard', isUploaderOrAdmin, async (req, res) => {
|
|||
}
|
||||
|
||||
const leaderboard = await Promise.all(rows.map(async (row) => {
|
||||
const userType = row.admin_auth_type === 'bip-39' ? 'bip39' : 'oauth';
|
||||
const u = await getUserData(row.admin_id, userType);
|
||||
const identity = await resolveAdminIdentity(row.admin_id, row.admin_auth_type);
|
||||
return {
|
||||
admin_id: row.admin_id,
|
||||
admin_auth_type: row.admin_auth_type,
|
||||
role: adminRoles[row.admin_id] || 'admin',
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
username: identity.username,
|
||||
avatar: identity.avatar,
|
||||
score: Number(row.score),
|
||||
last_action_at: row.last_action_at,
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -42,7 +42,7 @@ function configure(deps) {
|
|||
/** Wrap une URL m3u8 dans le proxy cinep si VIP et PROXY_SERVER_URL configuré */
|
||||
function wrapSourceUrl(url, isVip) {
|
||||
if (isVip && PROXY_SERVER_URL && url) {
|
||||
// PROXY_SERVER_URL = "https://proxy.movix.cash/proxy" → on veut la base sans /proxy
|
||||
// PROXY_SERVER_URL = "https://proxy.movix.tax/proxy" → on veut la base sans /proxy
|
||||
const serverBase = PROXY_SERVER_URL.replace(/\/proxy\/?$/, '').replace(/\/+$/, '');
|
||||
return `${serverBase}/cinep-proxy?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -675,4 +675,5 @@ module.exports.readUserData = readUserData;
|
|||
module.exports.writeUserData = writeUserData;
|
||||
module.exports.readProfileData = readProfileData;
|
||||
module.exports.writeProfileData = writeProfileData;
|
||||
module.exports.withProfileSyncLock = withProfileSyncLock;
|
||||
module.exports.USERS_DIR = USERS_DIR;
|
||||
|
|
|
|||
69
API/Mainapi/utils/adminIdentity.js
Normal file
69
API/Mainapi/utils/adminIdentity.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Résout l'identité affichable d'un admin/uploader (nom + avatar) à partir
|
||||
* de son `userId` + `authType` (`'oauth'` ou `'bip-39'` / `'bip39'`).
|
||||
*
|
||||
* Priorité :
|
||||
* 1) `auth.userProfile.username` + `auth.userProfile.avatar` du provider
|
||||
* OAuth (Discord/Google) — le "vrai" nom de la personne, pas le profil
|
||||
* Movix interne (qui est souvent "Profil" + un avatar Disney random).
|
||||
* 2) Le profil Movix `isDefault` ou le premier profil — pour les comptes
|
||||
* BIP-39 qui n'ont pas d'identité OAuth.
|
||||
* 3) Fallback `{ username: 'Admin', avatar: null }`.
|
||||
*
|
||||
* Utilisé par les leaderboards Wishboard et Download-links pour éviter
|
||||
* d'afficher "Admin" partout au lieu des vrais noms.
|
||||
*/
|
||||
|
||||
const { readUserData } = require('../routes/sync');
|
||||
|
||||
const DEFAULT = Object.freeze({ username: 'Admin', avatar: null });
|
||||
|
||||
function safeParseJson(raw) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||
try { return JSON.parse(raw); } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} userId
|
||||
* @param {string} authType — 'oauth', 'bip39' ou 'bip-39' (DB legacy)
|
||||
* @returns {Promise<{ username: string, avatar: string | null }>}
|
||||
*/
|
||||
async function resolveAdminIdentity(userId, authType) {
|
||||
if (!userId) return { ...DEFAULT };
|
||||
|
||||
const userType = authType === 'bip-39' || authType === 'bip39' ? 'bip39' : 'oauth';
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await readUserData(userType, userId);
|
||||
} catch {
|
||||
return { ...DEFAULT };
|
||||
}
|
||||
|
||||
if (!data || typeof data !== 'object') return { ...DEFAULT };
|
||||
|
||||
// 1) OAuth : nom + avatar du provider (Discord/Google).
|
||||
const auth = safeParseJson(data.auth);
|
||||
if (auth?.userProfile?.username) {
|
||||
return {
|
||||
username: String(auth.userProfile.username),
|
||||
avatar: auth.userProfile.avatar ? String(auth.userProfile.avatar) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// 2) BIP-39 ou OAuth sans `auth.userProfile` : profil Movix par défaut.
|
||||
const profiles = Array.isArray(data.profiles) ? data.profiles : [];
|
||||
const defaultProfile = profiles.find((p) => p && p.isDefault) || profiles[0];
|
||||
if (defaultProfile?.name) {
|
||||
return {
|
||||
username: String(defaultProfile.name),
|
||||
avatar: defaultProfile.avatar ? String(defaultProfile.avatar) : null,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...DEFAULT };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveAdminIdentity,
|
||||
};
|
||||
|
|
@ -1,17 +1,63 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
/**
|
||||
* Source de vérité = la table `oauth_clients` (cache en mémoire alimenté
|
||||
* au boot par `oauthClientsDb.reloadCache()`). On garde l'API synchrone
|
||||
* historique (`loadOAuthClients()`, `getOAuthClient()`) pour ne pas avoir
|
||||
* à toucher aux 30+ call sites.
|
||||
*
|
||||
* L'env `MOVIX_OAUTH_CLIENTS_JSON` reste supportée en surcouche (dev local
|
||||
* uniquement) ; le fichier `data/oauth-clients.json` n'est plus lu une fois
|
||||
* la migration vers DB effectuée (il est archivé en `.migrated`).
|
||||
*/
|
||||
|
||||
const { getCachedClients } = require('./oauthClientsDb');
|
||||
|
||||
const OAUTH_CLIENTS_FILE = path.join(__dirname, '..', 'data', 'oauth-clients.json');
|
||||
const OAUTH_CLIENTS_ENV = 'MOVIX_OAUTH_CLIENTS_JSON';
|
||||
const KNOWN_OAUTH_SCOPES = ['profile.read', 'profile.list', 'profile.manage', 'vip.read', 'vip.manage'];
|
||||
const KNOWN_OAUTH_SCOPES = [
|
||||
// Compte / profils
|
||||
'profile.read',
|
||||
'profile.list',
|
||||
'profile.manage',
|
||||
// VIP
|
||||
'vip.read',
|
||||
'vip.manage',
|
||||
// Émission de jours VIP par l'app (depuis son balance admin-alimenté).
|
||||
'vip.grant',
|
||||
// Favoris (1 read + 2 write granulaires)
|
||||
'favorites.read',
|
||||
'favorites.add',
|
||||
'favorites.remove',
|
||||
// Listes personnalisées (1 read + 5 write granulaires)
|
||||
'lists.read',
|
||||
'lists.create',
|
||||
'lists.rename',
|
||||
'lists.delete',
|
||||
'lists.add-item',
|
||||
'lists.remove-item',
|
||||
// Watchlist (1 read + 2 write granulaires)
|
||||
'watchlist.read',
|
||||
'watchlist.add',
|
||||
'watchlist.remove',
|
||||
// Historique (films/séries marqués comme vus)
|
||||
'history.read',
|
||||
'history.add',
|
||||
'history.remove',
|
||||
// Continue watching (reprise en cours)
|
||||
'continue-watching.read',
|
||||
// Notifications / alertes nouvelles saisons
|
||||
'alerts.read',
|
||||
'alerts.manage',
|
||||
// Notes personnelles (1-10) + texte facultatif
|
||||
'ratings.read',
|
||||
'ratings.manage',
|
||||
];
|
||||
const DEFAULT_SCOPE = 'profile.read';
|
||||
const OAUTH_DEBUG_ENABLED = process.env.MOVIX_OAUTH_DEBUG === 'true';
|
||||
|
||||
let cache = {
|
||||
fileMtimeMs: -1,
|
||||
envRaw: null,
|
||||
clients: [],
|
||||
};
|
||||
// Préfixe public servant les icônes d'apps (relatif à l'API : `/oauth-icons/<filename>`).
|
||||
// Si tu sers via un CDN, set OAUTH_ICON_PUBLIC_BASE_URL.
|
||||
const OAUTH_ICON_PUBLIC_BASE_URL = (
|
||||
process.env.OAUTH_ICON_PUBLIC_BASE_URL || '/oauth-icons'
|
||||
).replace(/\/+$/, '');
|
||||
|
||||
function safeJsonParse(rawValue, fallback) {
|
||||
if (typeof rawValue !== 'string' || !rawValue.trim()) {
|
||||
|
|
@ -128,6 +174,17 @@ function normalizeScopes(rawScopes) {
|
|||
);
|
||||
}
|
||||
|
||||
function buildIconUrl(iconFilename) {
|
||||
if (typeof iconFilename !== 'string' || !iconFilename.trim()) {
|
||||
return null;
|
||||
}
|
||||
// L'iconFilename est juste le basename — pas de path traversal possible
|
||||
// (validé au moment du upload côté route admin).
|
||||
const safeName = iconFilename.trim().replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
if (!safeName) return null;
|
||||
return `${OAUTH_ICON_PUBLIC_BASE_URL}/${safeName}`;
|
||||
}
|
||||
|
||||
function normalizeClient(rawClient) {
|
||||
if (!rawClient || typeof rawClient !== 'object' || Array.isArray(rawClient)) {
|
||||
return null;
|
||||
|
|
@ -152,7 +209,10 @@ function normalizeClient(rawClient) {
|
|||
const requirePkce = rawClient.requirePkce === true || publicClient;
|
||||
const allowedScopes = normalizeScopes(rawClient.allowedScopes);
|
||||
const homepageUrl = normalizeHttpUrl(rawClient.homepageUrl);
|
||||
// Compat ascendante : l'ancien JSON avait `logoUrl` (URL absolue), la
|
||||
// nouvelle DB a `iconFilename` (basename). On expose les deux.
|
||||
const logoUrl = normalizeHttpUrl(rawClient.logoUrl);
|
||||
const iconUrl = buildIconUrl(rawClient.iconFilename) || logoUrl;
|
||||
const description = typeof rawClient.description === 'string' && rawClient.description.trim()
|
||||
? rawClient.description.trim()
|
||||
: null;
|
||||
|
|
@ -167,69 +227,30 @@ function normalizeClient(rawClient) {
|
|||
allowedScopes: allowedScopes.length > 0 ? allowedScopes : [DEFAULT_SCOPE],
|
||||
homepageUrl,
|
||||
logoUrl,
|
||||
iconUrl,
|
||||
iconFilename: typeof rawClient.iconFilename === 'string' ? rawClient.iconFilename : null,
|
||||
description,
|
||||
vipDaysBalance: Number.isFinite(rawClient.vipDaysBalance) ? Number(rawClient.vipDaysBalance) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function readClientsFile() {
|
||||
try {
|
||||
if (!fs.existsSync(OAUTH_CLIENTS_FILE)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(OAUTH_CLIENTS_FILE, 'utf8');
|
||||
const parsed = safeJsonParse(fileContent, []);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
console.error('[OAuth Clients] Failed to read oauth-clients.json:', error.message || error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getClientsFileMtimeMs() {
|
||||
try {
|
||||
if (!fs.existsSync(OAUTH_CLIENTS_FILE)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return fs.statSync(OAUTH_CLIENTS_FILE).mtimeMs || -1;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function loadOAuthClients() {
|
||||
// Source 1: env var (override dev/test).
|
||||
const envRaw = process.env[OAUTH_CLIENTS_ENV] || '';
|
||||
const fileMtimeMs = getClientsFileMtimeMs();
|
||||
const fromEnv = envRaw ? safeJsonParse(envRaw, []) : [];
|
||||
|
||||
if (cache.envRaw === envRaw && cache.fileMtimeMs === fileMtimeMs) {
|
||||
return cache.clients;
|
||||
}
|
||||
|
||||
const fromEnv = safeJsonParse(envRaw, []);
|
||||
const fromFile = readClientsFile();
|
||||
const mergedSources = [
|
||||
...(Array.isArray(fromEnv) ? fromEnv : []),
|
||||
...(Array.isArray(fromFile) ? fromFile : []),
|
||||
];
|
||||
// Source 2: DB cache (source de vérité prod).
|
||||
const fromDb = getCachedClients() || [];
|
||||
|
||||
const byClientId = new Map();
|
||||
mergedSources.forEach((entry) => {
|
||||
// L'env override la DB (utile pour les tests E2E qui injectent un client éphémère).
|
||||
[...(Array.isArray(fromDb) ? fromDb : []), ...(Array.isArray(fromEnv) ? fromEnv : [])].forEach((entry) => {
|
||||
const normalized = normalizeClient(entry);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!normalized) return;
|
||||
byClientId.set(normalized.clientId, normalized);
|
||||
});
|
||||
|
||||
cache = {
|
||||
envRaw,
|
||||
fileMtimeMs,
|
||||
clients: Array.from(byClientId.values()),
|
||||
};
|
||||
|
||||
return cache.clients;
|
||||
return Array.from(byClientId.values());
|
||||
}
|
||||
|
||||
function getOAuthClient(clientId) {
|
||||
|
|
@ -252,6 +273,7 @@ function getOAuthClientPublicMetadata(client) {
|
|||
description: client.description,
|
||||
homepageUrl: client.homepageUrl,
|
||||
logoUrl: client.logoUrl,
|
||||
iconUrl: client.iconUrl,
|
||||
publicClient: client.publicClient,
|
||||
requirePkce: client.requirePkce,
|
||||
allowedScopes: [...client.allowedScopes],
|
||||
|
|
|
|||
344
API/Mainapi/utils/oauthClientsDb.js
Normal file
344
API/Mainapi/utils/oauthClientsDb.js
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
/**
|
||||
* Stockage DB des clients OAuth + stats + grants VIP. Remplace le fichier
|
||||
* `data/oauth-clients.json` (déprécié — migration auto au boot).
|
||||
*
|
||||
* Les autres modules continuent d'appeler `loadOAuthClients()` (sync) de
|
||||
* `oauthClients.js`, qui lit depuis le cache pré-warmé par les fonctions
|
||||
* async ci-dessous.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { getPool } = require('../mysqlPool');
|
||||
|
||||
const SCHEMA_PATH = path.join(__dirname, '..', 'exportscripts', 'add_oauth_apps_tables.sql');
|
||||
const LEGACY_JSON_PATH = path.join(__dirname, '..', 'data', 'oauth-clients.json');
|
||||
const ICON_DIR = path.join(__dirname, '..', 'public', 'oauth-icons');
|
||||
|
||||
// Cache en mémoire : refresh par invalidate() ou refresh périodique.
|
||||
let memCache = {
|
||||
loadedAt: 0,
|
||||
clients: [],
|
||||
};
|
||||
|
||||
const KNOWN_OAUTH_SCOPES_SET = new Set([
|
||||
'profile.read',
|
||||
'profile.list',
|
||||
'profile.manage',
|
||||
'vip.read',
|
||||
'vip.manage',
|
||||
'vip.grant',
|
||||
'favorites.read',
|
||||
'favorites.add',
|
||||
'favorites.remove',
|
||||
'lists.read',
|
||||
'lists.create',
|
||||
'lists.rename',
|
||||
'lists.delete',
|
||||
'lists.add-item',
|
||||
'lists.remove-item',
|
||||
'watchlist.read',
|
||||
'watchlist.add',
|
||||
'watchlist.remove',
|
||||
'history.read',
|
||||
'history.add',
|
||||
'history.remove',
|
||||
'continue-watching.read',
|
||||
'alerts.read',
|
||||
'alerts.manage',
|
||||
'ratings.read',
|
||||
'ratings.manage',
|
||||
]);
|
||||
|
||||
/** Strip les commentaires `-- …` ligne par ligne avant le split.
|
||||
* Note : ne gère pas `/* … *\/` mais le schéma n'en utilise pas. */
|
||||
function stripSqlLineComments(sqlText) {
|
||||
return sqlText
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('--'))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** Crée les tables si elles n'existent pas (idempotent). */
|
||||
async function ensureTables() {
|
||||
const pool = getPool();
|
||||
if (!pool) throw new Error('MySQL pool not ready');
|
||||
if (!fs.existsSync(SCHEMA_PATH)) return;
|
||||
// On strip d'abord TOUS les commentaires ligne `-- …` puis on split sur `;`.
|
||||
// Sans le strip, le premier statement embarquait le header de commentaires
|
||||
// du fichier et était filtré par `!startsWith('--')` → aucune table créée
|
||||
// et le INSERT migrate plantait sur "Table 'oauth_clients' doesn't exist".
|
||||
const sql = stripSqlLineComments(fs.readFileSync(SCHEMA_PATH, 'utf-8'));
|
||||
const statements = sql
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
for (const stmt of statements) {
|
||||
await pool.query(stmt);
|
||||
}
|
||||
// Crée aussi le dossier oauth-icons s'il n'existe pas.
|
||||
if (!fs.existsSync(ICON_DIR)) {
|
||||
fs.mkdirSync(ICON_DIR, { recursive: true, mode: 0o755 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Import unique du JSON legacy vers DB. Idempotent : skip si déjà importé. */
|
||||
async function migrateLegacyJsonIfNeeded() {
|
||||
const pool = getPool();
|
||||
if (!pool) return;
|
||||
if (!fs.existsSync(LEGACY_JSON_PATH)) return;
|
||||
|
||||
const [rows] = await pool.execute('SELECT COUNT(*) AS n FROM oauth_clients');
|
||||
const existing = Number(rows[0]?.n || 0);
|
||||
if (existing > 0) {
|
||||
// Migration déjà faite : on archive le JSON et on continue.
|
||||
try {
|
||||
const archivePath = LEGACY_JSON_PATH + '.migrated';
|
||||
if (!fs.existsSync(archivePath)) {
|
||||
fs.renameSync(LEGACY_JSON_PATH, archivePath);
|
||||
console.log('[OAuth Clients DB] Archived legacy JSON to', archivePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[OAuth Clients DB] Could not archive legacy JSON:', err.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(LEGACY_JSON_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
if (!Array.isArray(parsed)) return;
|
||||
const now = Date.now();
|
||||
for (const entry of parsed) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const clientId = String(entry.clientId || '').trim();
|
||||
const clientName = String(entry.clientName || '').trim();
|
||||
if (!clientId || !clientName) continue;
|
||||
const redirectUris = Array.isArray(entry.redirectUris) ? entry.redirectUris : [];
|
||||
const allowedScopes = Array.isArray(entry.allowedScopes) ? entry.allowedScopes : [];
|
||||
const description = entry.description ? String(entry.description) : null;
|
||||
const homepageUrl = entry.homepageUrl ? String(entry.homepageUrl) : null;
|
||||
const publicClient = entry.publicClient === false ? 0 : 1;
|
||||
const requirePkce = entry.requirePkce === false ? 0 : 1;
|
||||
const clientSecret = entry.clientSecret ? String(entry.clientSecret) : null;
|
||||
await pool.execute(
|
||||
`INSERT INTO oauth_clients
|
||||
(client_id, client_name, description, homepage_url, redirect_uris,
|
||||
allowed_scopes, public_client, require_pkce, client_secret,
|
||||
is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at)`,
|
||||
[
|
||||
clientId,
|
||||
clientName,
|
||||
description,
|
||||
homepageUrl,
|
||||
JSON.stringify(redirectUris),
|
||||
JSON.stringify(allowedScopes),
|
||||
publicClient,
|
||||
requirePkce,
|
||||
clientSecret,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
}
|
||||
console.log('[OAuth Clients DB] Migrated', parsed.length, 'client(s) from JSON to MySQL');
|
||||
// Archive le JSON
|
||||
try {
|
||||
fs.renameSync(LEGACY_JSON_PATH, LEGACY_JSON_PATH + '.migrated');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[OAuth Clients DB] Legacy migration failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function safeParseJson(raw, fallback) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return Array.isArray(raw) ? raw : fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function rowToClient(row) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
clientId: row.client_id,
|
||||
clientName: row.client_name,
|
||||
description: row.description || null,
|
||||
homepageUrl: row.homepage_url || null,
|
||||
redirectUris: safeParseJson(row.redirect_uris, []).filter((u) => typeof u === 'string'),
|
||||
allowedScopes: safeParseJson(row.allowed_scopes, []).filter((s) => typeof s === 'string' && KNOWN_OAUTH_SCOPES_SET.has(s)),
|
||||
publicClient: row.public_client === 1 || row.public_client === true,
|
||||
requirePkce: row.require_pkce === 1 || row.require_pkce === true,
|
||||
clientSecret: row.client_secret || null,
|
||||
iconFilename: row.icon_filename || null,
|
||||
vipDaysBalance: Number(row.vip_days_balance || 0),
|
||||
isActive: row.is_active === 1 || row.is_active === true,
|
||||
createdAt: Number(row.created_at || 0),
|
||||
updatedAt: Number(row.updated_at || 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** Charge tous les clients actifs depuis la DB. À appeler au boot + après chaque modif. */
|
||||
async function reloadCache() {
|
||||
const pool = getPool();
|
||||
if (!pool) return;
|
||||
const [rows] = await pool.execute('SELECT * FROM oauth_clients WHERE is_active = 1 ORDER BY id ASC');
|
||||
memCache = {
|
||||
loadedAt: Date.now(),
|
||||
clients: rows.map(rowToClient),
|
||||
};
|
||||
}
|
||||
|
||||
function getCachedClients() {
|
||||
return memCache.clients;
|
||||
}
|
||||
|
||||
function invalidateCache() {
|
||||
memCache = { loadedAt: 0, clients: [] };
|
||||
}
|
||||
|
||||
// ─── Stats helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async function recordEvent(clientId, eventType, userId, metadata) {
|
||||
const pool = getPool();
|
||||
if (!pool) return;
|
||||
try {
|
||||
await pool.execute(
|
||||
`INSERT INTO oauth_app_stats (client_id, event_type, user_id, metadata, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[
|
||||
String(clientId),
|
||||
String(eventType).slice(0, 32),
|
||||
userId ? String(userId).slice(0, 160) : null,
|
||||
metadata ? JSON.stringify(metadata) : null,
|
||||
Date.now(),
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[OAuth stats] recordEvent failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function getStats(clientId, sinceMs) {
|
||||
const pool = getPool();
|
||||
if (!pool) return null;
|
||||
const since = Number(sinceMs) || Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
const [byType] = await pool.execute(
|
||||
`SELECT event_type, COUNT(*) AS n
|
||||
FROM oauth_app_stats
|
||||
WHERE client_id = ? AND created_at >= ?
|
||||
GROUP BY event_type`,
|
||||
[clientId, since],
|
||||
);
|
||||
const [byDay] = await pool.execute(
|
||||
`SELECT FROM_UNIXTIME(FLOOR(created_at/1000), '%Y-%m-%d') AS day,
|
||||
COUNT(*) AS n
|
||||
FROM oauth_app_stats
|
||||
WHERE client_id = ? AND created_at >= ?
|
||||
GROUP BY day
|
||||
ORDER BY day ASC`,
|
||||
[clientId, since],
|
||||
);
|
||||
const [uniqueUsers] = await pool.execute(
|
||||
`SELECT COUNT(DISTINCT user_id) AS n
|
||||
FROM oauth_app_stats
|
||||
WHERE client_id = ? AND created_at >= ? AND user_id IS NOT NULL`,
|
||||
[clientId, since],
|
||||
);
|
||||
return {
|
||||
sinceMs: since,
|
||||
byType,
|
||||
byDay,
|
||||
uniqueUsers: Number(uniqueUsers[0]?.n || 0),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── VIP grants helpers ──────────────────────────────────────────────────
|
||||
|
||||
function generateAccessKeyValue() {
|
||||
// 32 chars base32-like uppercase (lisible).
|
||||
return crypto.randomBytes(20).toString('hex').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Décrémente atomiquement le balance et émet une access_key valide N jours.
|
||||
* Throw si balance insuffisant.
|
||||
*/
|
||||
async function grantVip({ clientId, userType, userId, days }) {
|
||||
if (!clientId || !userType || !userId || !Number.isInteger(days) || days <= 0 || days > 365) {
|
||||
throw new Error('Paramètres grant invalides');
|
||||
}
|
||||
const pool = getPool();
|
||||
if (!pool) throw new Error('DB indisponible');
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
// Lock + check balance
|
||||
const [rows] = await conn.execute(
|
||||
'SELECT id, vip_days_balance FROM oauth_clients WHERE client_id = ? FOR UPDATE',
|
||||
[clientId],
|
||||
);
|
||||
if (rows.length === 0) throw new Error('Client OAuth introuvable');
|
||||
const balance = Number(rows[0].vip_days_balance || 0);
|
||||
if (balance < days) {
|
||||
throw new Error(`Solde VIP insuffisant : ${balance} jour(s) disponible(s), ${days} demandé(s)`);
|
||||
}
|
||||
// Décrément
|
||||
await conn.execute(
|
||||
'UPDATE oauth_clients SET vip_days_balance = vip_days_balance - ?, updated_at = ? WHERE id = ?',
|
||||
[days, Date.now(), rows[0].id],
|
||||
);
|
||||
// Génère access_key
|
||||
const keyValue = generateAccessKeyValue();
|
||||
const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||
const expiresAtSql = expiresAt.toISOString().slice(0, 19).replace('T', ' ');
|
||||
await conn.execute(
|
||||
`INSERT INTO access_keys (key_value, active, expires_at, duree_validite)
|
||||
VALUES (?, 1, ?, ?)`,
|
||||
[keyValue, expiresAtSql, `${days}d`],
|
||||
);
|
||||
// Audit
|
||||
const userIdComposite = `${userType}:${userId}`;
|
||||
await conn.execute(
|
||||
`INSERT INTO oauth_vip_grants
|
||||
(client_id, user_id, user_type, user_id_only, days_granted,
|
||||
access_key_value, expires_at, granted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[clientId, userIdComposite, userType, userId, days, keyValue, expiresAtSql, Date.now()],
|
||||
);
|
||||
await conn.commit();
|
||||
return {
|
||||
accessKey: keyValue,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
daysGranted: days,
|
||||
remainingBalance: balance - days,
|
||||
};
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureTables,
|
||||
migrateLegacyJsonIfNeeded,
|
||||
reloadCache,
|
||||
getCachedClients,
|
||||
invalidateCache,
|
||||
recordEvent,
|
||||
getStats,
|
||||
grantVip,
|
||||
ICON_DIR,
|
||||
KNOWN_OAUTH_SCOPES_SET,
|
||||
};
|
||||
|
|
@ -35,7 +35,9 @@ const SYNCABLE_EXACT_KEYS = new Set([
|
|||
'subtitleStyle',
|
||||
'support_popup_seen',
|
||||
'user_language',
|
||||
'is_vip',
|
||||
// SECURITY (audit P0) : `is_vip` retiré du sync — c'est juste un cache UI
|
||||
// côté frontend qui doit être recalculé via /api/check-vip à chaque session.
|
||||
// Le laisser syncable permettait à n'importe qui de forger son statut VIP.
|
||||
'watched_movie',
|
||||
'watched_tv',
|
||||
'watchPartyNickname'
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const path = require('path');
|
|||
const { verifyAccessKey } = require('./checkVip');
|
||||
const { searchTmdb } = require('./utils/tmdbCache');
|
||||
const { verifyTurnstileFromRequest } = require('./utils/turnstile');
|
||||
const { resolveAdminIdentity } = require('./utils/adminIdentity');
|
||||
|
||||
const TURNSTILE_INVISIBLE_SECRETKEY = process.env.TURNSTILE_INVISIBLE_SECRETKEY;
|
||||
const TMDB_API_URL = 'https://api.themoviedb.org/3';
|
||||
|
|
@ -945,24 +946,18 @@ function createWishboardRouter(mysqlPool, redis) {
|
|||
}
|
||||
}
|
||||
|
||||
// Resolve user data (username, avatar) for each admin
|
||||
// Resolve user data (username, avatar) for each admin via the
|
||||
// shared helper — prefers OAuth provider identity over the
|
||||
// generic Movix profile, so we display "Maxou DM" instead of
|
||||
// "Admin" / "Profil".
|
||||
const leaderboard = await Promise.all(rows.map(async (row) => {
|
||||
let userData = { username: 'Admin', avatar: null };
|
||||
try {
|
||||
const userType = row.admin_auth_type === 'bip-39' ? 'bip39' : 'oauth';
|
||||
const basicData = await getUserData(row.admin_id, userType);
|
||||
if (basicData.username) userData.username = basicData.username;
|
||||
if (basicData.avatar) userData.avatar = basicData.avatar;
|
||||
} catch (err) {
|
||||
// Keep defaults
|
||||
}
|
||||
|
||||
const identity = await resolveAdminIdentity(row.admin_id, row.admin_auth_type);
|
||||
return {
|
||||
admin_id: row.admin_id,
|
||||
admin_auth_type: row.admin_auth_type,
|
||||
role: adminRoles[row.admin_id] || 'admin',
|
||||
username: userData.username,
|
||||
avatar: userData.avatar,
|
||||
username: identity.username,
|
||||
avatar: identity.avatar,
|
||||
greenlight_count: row.greenlight_count,
|
||||
last_greenlight_at: row.last_greenlight_at
|
||||
};
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ async function setupRules() {
|
|||
"localhost",
|
||||
"127.0.0.1",
|
||||
"movix.cash",
|
||||
"movix.cash",
|
||||
"movix.tax",
|
||||
"movix.club",
|
||||
],
|
||||
resourceTypes: [
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@
|
|||
"*://localhost/*",
|
||||
"*://movix.cash/*",
|
||||
"*://*.movix.cash/*",
|
||||
"*://movix.cash/*",
|
||||
"*://*.movix.cash/*",
|
||||
"*://movix.tax/*",
|
||||
"*://*.movix.tax/*",
|
||||
"*://movix.club/*",
|
||||
"*://*.movix.club/*"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 12px; text-align: center;">
|
||||
<a href="https://movix.cash/settings#extractions" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px;font-weight:600;">
|
||||
<a href="https://movix.tax/settings#extractions" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px;font-weight:600;">
|
||||
Configurer →
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -456,7 +456,7 @@
|
|||
|
||||
<!-- Footer -->
|
||||
<div class="footer fade-in fade-in-delay-6">
|
||||
<a href="https://movix.cash" target="_blank">
|
||||
<a href="https://movix.tax" target="_blank">
|
||||
Ouvrir Movix
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ async function setupRules() {
|
|||
"localhost",
|
||||
"127.0.0.1",
|
||||
"movix.cash",
|
||||
"movix.cash",
|
||||
"movix.tax",
|
||||
"movix.club",
|
||||
],
|
||||
resourceTypes: [
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 12px; text-align: center;">
|
||||
<a href="https://movix.cash/settings#extractions" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px;font-weight:600;">
|
||||
<a href="https://movix.tax/settings#extractions" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px;font-weight:600;">
|
||||
Configurer →
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -456,7 +456,7 @@
|
|||
|
||||
<!-- Footer -->
|
||||
<div class="footer fade-in fade-in-delay-6">
|
||||
<a href="https://movix.cash" target="_blank">
|
||||
<a href="https://movix.tax" target="_blank">
|
||||
Ouvrir Movix
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
|
|
|
|||
22
src/App.tsx
22
src/App.tsx
|
|
@ -12,6 +12,7 @@ import { AdWarningProvider } from './context/AdWarningContext';
|
|||
import { VipModalProvider } from './context/VipModalContext';
|
||||
import { ProfileProvider, useProfile } from './context/ProfileContext';
|
||||
import { TurnstileProvider } from './context/TurnstileContext';
|
||||
import { LightModeProvider, useLightMode } from './context/LightModeContext';
|
||||
|
||||
import NotFound from './pages/NotFound';
|
||||
import 'video.js/dist/video-js.css';
|
||||
|
|
@ -38,7 +39,7 @@ import { broadcastAuthChange, clearStoredAuthSession, getResolvedAccountContext
|
|||
import { isSyncableStorageKey, SYNC_OUTBOX_STORAGE_KEY } from './utils/syncStorage';
|
||||
import i18n, { detectInitialLanguage } from './i18n';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { motion, AnimatePresence, MotionConfig } from 'framer-motion';
|
||||
import IntroAnimation from './components/IntroAnimation';
|
||||
import { IntroProvider, useIntro } from './context/IntroContext';
|
||||
import { APRIL_FOOLS_ADMIN_PATH, isAprilFoolsAdminEnabled } from './utils/aprilFools';
|
||||
|
|
@ -1837,7 +1838,7 @@ const EmbedBlockPage = () => (
|
|||
{i18n.t('embed.message')}
|
||||
</p>
|
||||
<a
|
||||
href="https://movix.cash"
|
||||
href="https://movix.tax"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-block bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition sm:py-3 sm:px-6 lg:py-4 lg:px-8"
|
||||
|
|
@ -1866,6 +1867,19 @@ const MaintenancePage = ({ onContinue }: { onContinue: () => void }) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
// Wraps the tree in a <MotionConfig> tied to the Mode léger / animation prefs.
|
||||
// When `transitions` is disabled (manually or because Mode léger is on),
|
||||
// framer-motion treats EVERY animation as if `prefers-reduced-motion: reduce`
|
||||
// were set — initial/animate/exit are skipped on transform/opacity for free.
|
||||
const AnimationMotionConfig: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { effectivePrefs } = useLightMode();
|
||||
return (
|
||||
<MotionConfig reducedMotion={effectivePrefs.transitions ? 'user' : 'always'}>
|
||||
{children}
|
||||
</MotionConfig>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [forceContinue, setForceContinue] = React.useState(false);
|
||||
|
||||
|
|
@ -1891,6 +1905,8 @@ function App() {
|
|||
return (
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<LightModeProvider>
|
||||
<AnimationMotionConfig>
|
||||
<SearchProvider>
|
||||
<AdFreePopupProvider>
|
||||
<AuthProvider>
|
||||
|
|
@ -1912,6 +1928,8 @@ function App() {
|
|||
</AuthProvider>
|
||||
</AdFreePopupProvider>
|
||||
</SearchProvider>
|
||||
</AnimationMotionConfig>
|
||||
</LightModeProvider>
|
||||
</TooltipProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Link2,
|
||||
ListOrdered,
|
||||
MessageSquare,
|
||||
Plug,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Sprout,
|
||||
|
|
@ -18,6 +19,7 @@ import AdminComments from './AdminComments';
|
|||
import AdminHelpFeedback from './AdminHelpFeedback';
|
||||
import AdminLinkSubmissions from './Greenlight/AdminLinkSubmissions';
|
||||
import AdminWishboard from './Greenlight/AdminWishboard';
|
||||
import AdminOAuthApps from './AdminOAuthApps';
|
||||
import AdminReports from './AdminReports';
|
||||
import AdminSharedLists from './AdminSharedLists';
|
||||
import StreamingLinksManager from './StreamingLinksManager';
|
||||
|
|
@ -30,6 +32,7 @@ type AdminSection =
|
|||
| 'links'
|
||||
| 'vip-keys'
|
||||
| 'vip-invoices'
|
||||
| 'oauth-apps'
|
||||
| 'wishboard'
|
||||
| 'link-submissions'
|
||||
| 'comments'
|
||||
|
|
@ -80,6 +83,14 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({ role }) => {
|
|||
accent: 'text-yellow-300',
|
||||
highlight: '234 179 8'
|
||||
},
|
||||
{
|
||||
id: 'oauth-apps',
|
||||
title: t('adminOauthApps.cardTitle'),
|
||||
description: t('adminOauthApps.cardDesc'),
|
||||
icon: Plug,
|
||||
accent: 'text-purple-300',
|
||||
highlight: '168 85 247'
|
||||
},
|
||||
{
|
||||
id: 'wishboard',
|
||||
title: t('admin.wishboardGreenlight'),
|
||||
|
|
@ -305,6 +316,16 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({ role }) => {
|
|||
<AdminHelpFeedback />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'oauth-apps' && role === 'admin' && (
|
||||
<div>
|
||||
<h2 className="mb-6 flex items-center gap-3 text-2xl font-bold text-white">
|
||||
<Plug className="h-6 w-6 text-purple-300" />
|
||||
{t('adminOauthApps.cardTitle')}
|
||||
</h2>
|
||||
<AdminOAuthApps />
|
||||
</div>
|
||||
)}
|
||||
</AnimatedBorderCard>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
1073
src/components/AdminOAuthApps.tsx
Normal file
1073
src/components/AdminOAuthApps.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
|
@ -8,7 +8,7 @@ import axios from 'axios';
|
|||
import { toast } from 'sonner';
|
||||
import { getVipHeaders } from '../utils/vipUtils';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { safeRemarkGfm } from '../utils/markdownPlugins';
|
||||
import { useSafeRemarkGfm } from '../utils/markdownPlugins';
|
||||
import remarkEmoji from 'remark-emoji';
|
||||
import MarkdownToolbar from './MarkdownToolbar';
|
||||
|
||||
|
|
@ -50,8 +50,6 @@ const mdComponents = {
|
|||
h6: ({ children }: any) => <p className="font-bold text-white mb-1">{children}</p>,
|
||||
};
|
||||
|
||||
const mdPlugins = safeRemarkGfm ? [safeRemarkGfm, remarkEmoji] : [remarkEmoji];
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
|
|
@ -281,6 +279,12 @@ const CommentItem = React.memo<CommentItemProps>((props) => {
|
|||
setReportModal,
|
||||
} = props;
|
||||
|
||||
const safeRemarkGfm = useSafeRemarkGfm();
|
||||
const mdPlugins = useMemo(
|
||||
() => (safeRemarkGfm ? [safeRemarkGfm, remarkEmoji] : [remarkEmoji]),
|
||||
[safeRemarkGfm],
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={comment.id}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,14 @@ const detectLowEndDevice = (): boolean => {
|
|||
const isLowEnd = (typeof dm === 'number' && dm <= 2) || (typeof hc === 'number' && hc <= 2);
|
||||
const isTV = /Tizen|WebOS|SmartTV|GoogleTV|HbbTV|NetCast|VIDAA|AppleTV|AndroidTV|BRAVIA|Hisense|Aquos/i.test(ua);
|
||||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
return isLowEnd || isTV || reducedMotion;
|
||||
// Settings → Performance → "Carrousels automatiques" or master Mode léger.
|
||||
// Either disables auto-rotation; user can still swipe/drag manually.
|
||||
let userDisabled = false;
|
||||
try {
|
||||
userDisabled = localStorage.getItem('settings_anim_carousel') === 'false'
|
||||
|| localStorage.getItem('settings_light_mode') === 'on';
|
||||
} catch { /* localStorage unavailable (private mode, etc.) */ }
|
||||
return isLowEnd || isTV || reducedMotion || userDisabled;
|
||||
};
|
||||
|
||||
interface Media {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Bold, Italic, Code, Link, List, ListOrdered, Quote, Strikethrough, Eye, EyeOff, Info, X } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { safeRemarkGfm } from '../utils/markdownPlugins';
|
||||
import { useSafeRemarkGfm } from '../utils/markdownPlugins';
|
||||
import remarkEmoji from 'remark-emoji';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import EmojiAutocomplete from './EmojiAutocomplete';
|
||||
|
|
@ -50,8 +50,6 @@ const previewComponents = {
|
|||
h6: ({ children }: any) => <p className="font-bold text-white mb-1">{children}</p>,
|
||||
};
|
||||
|
||||
const previewPlugins = safeRemarkGfm ? [safeRemarkGfm, remarkEmoji] : [remarkEmoji];
|
||||
|
||||
type FormatAction = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
|
|
@ -178,6 +176,11 @@ const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({ textareaRef, value, o
|
|||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const actions = buildActions(t);
|
||||
const safeRemarkGfm = useSafeRemarkGfm();
|
||||
const previewPlugins = useMemo(
|
||||
() => (safeRemarkGfm ? [safeRemarkGfm, remarkEmoji] : [remarkEmoji]),
|
||||
[safeRemarkGfm],
|
||||
);
|
||||
|
||||
const applyFormat = (action: FormatAction) => {
|
||||
const textarea = textareaRef.current;
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ const RedirectPopup: React.FC<RedirectPopupProps> = ({
|
|||
{t('redirect.ourNewAddress')}
|
||||
</h2>
|
||||
<div className="bg-red-600/20 border-2 border-red-500 rounded-lg p-4 mb-4">
|
||||
<p className="text-3xl font-bold text-white">movix.cash</p>
|
||||
<p className="text-3xl font-bold text-white">movix.tax</p>
|
||||
</div>
|
||||
<p className="text-gray-300 text-sm">
|
||||
{t('redirect.joinTelegramNews')}
|
||||
|
|
|
|||
63
src/components/ui/checkbox.tsx
Normal file
63
src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CheckboxProps {
|
||||
checked: boolean;
|
||||
onCheckedChange: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
'aria-label'?: string;
|
||||
className?: string;
|
||||
size?: 'sm' | 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkbox custom. On gère `role="checkbox" aria-checked` à la main pour ne
|
||||
* pas ajouter Radix. L'icône Check apparaît avec une transition scale+opacity
|
||||
* pour un petit feedback visuel quand on coche / décoche.
|
||||
*/
|
||||
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
({ checked, onCheckedChange, disabled, id, className, size = 'default', ...props }, ref) => {
|
||||
const dims = size === 'sm'
|
||||
? { box: 'h-4 w-4', icon: 'h-3 w-3' }
|
||||
: { box: 'h-5 w-5', icon: 'h-3.5 w-3.5' };
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
id={id}
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex shrink-0 cursor-pointer items-center justify-center rounded-md border',
|
||||
'transition-all duration-200 ease-out',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-300/60 focus-visible:ring-offset-2 focus-visible:ring-offset-black',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
dims.box,
|
||||
checked
|
||||
? 'border-yellow-300 bg-yellow-300 shadow-[0_0_10px_rgba(253,224,71,0.35)]'
|
||||
: 'border-white/20 bg-white/5 hover:border-white/40 hover:bg-white/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
strokeWidth={3}
|
||||
className={cn(
|
||||
dims.icon,
|
||||
'text-black transition-all duration-150',
|
||||
checked ? 'scale-100 opacity-100' : 'scale-50 opacity-0',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
|
||||
export { Checkbox };
|
||||
72
src/components/ui/confirm-dialog.tsx
Normal file
72
src/components/ui/confirm-dialog.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Button } from './button';
|
||||
import ReusableModal from './reusable-modal';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal de confirmation custom. Remplace `window.confirm()` qui :
|
||||
* - n'est pas stylisable (cassait le look custom du panel admin)
|
||||
* - bloque le main thread
|
||||
* - ne supporte pas le markdown / les composants React dans le message
|
||||
*
|
||||
* Pour les actions destructives passe `variant="destructive"` pour avoir
|
||||
* une icône d'alerte rouge + un bouton de confirmation rouge.
|
||||
*/
|
||||
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
variant = 'default',
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isDestructive = variant === 'destructive';
|
||||
|
||||
return (
|
||||
<ReusableModal isOpen={isOpen} onClose={onCancel} title={title} className="max-w-md">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
{isDestructive && (
|
||||
<div className="shrink-0 rounded-full bg-red-500/15 p-2 ring-1 ring-red-500/30">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 text-sm text-white/80">{message}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onCancel} disabled={busy}>
|
||||
{cancelLabel ?? t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isDestructive ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{confirmLabel ?? t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ReusableModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDialog;
|
||||
|
|
@ -83,7 +83,9 @@ export const SquareBackground: React.FC<SquareBackgroundProps> = ({
|
|||
colorRef.current = parseRGB(borderColor);
|
||||
|
||||
const showCssGrid = mode === 'static';
|
||||
const showHalo = mode === 'static' || mode === 'combined';
|
||||
// Halo : seulement quand le mode l'inclut ET que l'utilisateur ne l'a pas
|
||||
// explicitement désactivé dans Apparence → "Halo lumineux".
|
||||
const showHalo = (mode === 'static' || mode === 'combined') && prefs.haloEnabled;
|
||||
const showCanvas = mode === 'animated' || mode === 'combined';
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -506,7 +508,7 @@ export const SquareBackground: React.FC<SquareBackgroundProps> = ({
|
|||
{showHalo && (
|
||||
<div
|
||||
ref={haloRef}
|
||||
className="absolute z-[1] pointer-events-none"
|
||||
className="square-bg-halo absolute z-[1] pointer-events-none"
|
||||
style={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
|
|
|
|||
62
src/components/ui/switch.tsx
Normal file
62
src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SwitchProps {
|
||||
checked: boolean;
|
||||
onCheckedChange: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
'aria-label'?: string;
|
||||
className?: string;
|
||||
size?: 'sm' | 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch on/off custom. Pas de dépendance Radix : on gère soi-même le pattern
|
||||
* `role="switch" aria-checked`. Le thumb glisse via une transition CSS sur
|
||||
* `translate-x`, donc pas besoin de framer-motion ici.
|
||||
*/
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
({ checked, onCheckedChange, disabled, id, className, size = 'default', ...props }, ref) => {
|
||||
const dims = size === 'sm'
|
||||
? { track: 'h-5 w-9', thumb: 'h-3.5 w-3.5', translate: 'translate-x-4' }
|
||||
: { track: 'h-6 w-11', thumb: 'h-4 w-4', translate: 'translate-x-5' };
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="switch"
|
||||
id={id}
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent',
|
||||
'transition-colors duration-200 ease-in-out',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-300/60 focus-visible:ring-offset-2 focus-visible:ring-offset-black',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
dims.track,
|
||||
checked
|
||||
? 'bg-yellow-300 shadow-[0_0_12px_rgba(253,224,71,0.45)]'
|
||||
: 'bg-white/10 hover:bg-white/20',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none inline-block transform rounded-full bg-white shadow-lg ring-0',
|
||||
'transition-transform duration-200 ease-in-out',
|
||||
dims.thumb,
|
||||
checked ? dims.translate : 'translate-x-0.5',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export { Switch };
|
||||
|
|
@ -2,14 +2,57 @@ import React, { createContext, useContext, useState, useEffect, useCallback, use
|
|||
|
||||
type LightModeSetting = 'auto' | 'on' | 'off';
|
||||
|
||||
export type AnimationPrefKey =
|
||||
| 'bgAnimations'
|
||||
| 'loadingAnimations'
|
||||
| 'carouselAutoplay'
|
||||
| 'blurEffects'
|
||||
| 'transitions';
|
||||
|
||||
export interface AnimationPrefs {
|
||||
bgAnimations: boolean;
|
||||
loadingAnimations: boolean;
|
||||
carouselAutoplay: boolean;
|
||||
blurEffects: boolean;
|
||||
transitions: boolean;
|
||||
}
|
||||
|
||||
interface LightModeContextType {
|
||||
isLightMode: boolean;
|
||||
lightModeSetting: LightModeSetting;
|
||||
setLightModeSetting: (setting: LightModeSetting) => void;
|
||||
prefs: AnimationPrefs;
|
||||
effectivePrefs: AnimationPrefs;
|
||||
setPref: (key: AnimationPrefKey, value: boolean) => void;
|
||||
resetPrefs: () => void;
|
||||
}
|
||||
|
||||
const LightModeContext = createContext<LightModeContextType | undefined>(undefined);
|
||||
|
||||
// Each pref maps to a localStorage key and a `data-*` attribute on <html>.
|
||||
// Keeping the attribute name short — it ends up on every CSS selector.
|
||||
const PREF_META: Record<AnimationPrefKey, { storageKey: string; attr: string }> = {
|
||||
bgAnimations: { storageKey: 'settings_anim_bg', attr: 'data-no-bg-anim' },
|
||||
loadingAnimations: { storageKey: 'settings_anim_loading', attr: 'data-no-loading-anim' },
|
||||
carouselAutoplay: { storageKey: 'settings_anim_carousel', attr: 'data-no-carousel-anim' },
|
||||
blurEffects: { storageKey: 'settings_anim_blur', attr: 'data-no-blur' },
|
||||
transitions: { storageKey: 'settings_anim_transitions', attr: 'data-no-transitions' },
|
||||
};
|
||||
|
||||
const DEFAULT_PREFS: AnimationPrefs = {
|
||||
bgAnimations: true,
|
||||
loadingAnimations: true,
|
||||
carouselAutoplay: true,
|
||||
blurEffects: true,
|
||||
transitions: true,
|
||||
};
|
||||
|
||||
function readPref(key: AnimationPrefKey): boolean {
|
||||
const raw = localStorage.getItem(PREF_META[key].storageKey);
|
||||
if (raw === null) return DEFAULT_PREFS[key];
|
||||
return raw !== 'false';
|
||||
}
|
||||
|
||||
function detectWeakDevice(): boolean {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
if (ua.includes('tizen') || ua.includes('webos') || ua.includes('web0s') ||
|
||||
|
|
@ -21,7 +64,8 @@ function detectWeakDevice(): boolean {
|
|||
if (navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 2) {
|
||||
return true;
|
||||
}
|
||||
if ((navigator as any).deviceMemory && (navigator as any).deviceMemory <= 2) {
|
||||
if ((navigator as Navigator & { deviceMemory?: number }).deviceMemory &&
|
||||
(navigator as Navigator & { deviceMemory?: number }).deviceMemory! <= 2) {
|
||||
return true;
|
||||
}
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||
|
|
@ -35,28 +79,76 @@ export const LightModeProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
return (localStorage.getItem('settings_light_mode') as LightModeSetting) || 'auto';
|
||||
});
|
||||
|
||||
const [prefs, setPrefsState] = useState<AnimationPrefs>(() => ({
|
||||
bgAnimations: readPref('bgAnimations'),
|
||||
loadingAnimations: readPref('loadingAnimations'),
|
||||
carouselAutoplay: readPref('carouselAutoplay'),
|
||||
blurEffects: readPref('blurEffects'),
|
||||
transitions: readPref('transitions'),
|
||||
}));
|
||||
|
||||
const isLightMode = useMemo(() => {
|
||||
if (lightModeSetting === 'on') return true;
|
||||
if (lightModeSetting === 'off') return false;
|
||||
return detectWeakDevice();
|
||||
}, [lightModeSetting]);
|
||||
|
||||
// Effective prefs: light mode ON forces every category to "disabled" (false)
|
||||
// regardless of the user's granular state. Granular state is preserved so the
|
||||
// user gets it back when they turn light mode off.
|
||||
const effectivePrefs: AnimationPrefs = useMemo(() => {
|
||||
if (isLightMode) {
|
||||
return {
|
||||
bgAnimations: false,
|
||||
loadingAnimations: false,
|
||||
carouselAutoplay: false,
|
||||
blurEffects: false,
|
||||
transitions: false,
|
||||
};
|
||||
}
|
||||
return prefs;
|
||||
}, [isLightMode, prefs]);
|
||||
|
||||
const setLightModeSetting = useCallback((setting: LightModeSetting) => {
|
||||
setLightModeSettingState(setting);
|
||||
localStorage.setItem('settings_light_mode', setting);
|
||||
}, []);
|
||||
|
||||
const setPref = useCallback((key: AnimationPrefKey, value: boolean) => {
|
||||
setPrefsState((prev) => ({ ...prev, [key]: value }));
|
||||
localStorage.setItem(PREF_META[key].storageKey, String(value));
|
||||
}, []);
|
||||
|
||||
const resetPrefs = useCallback(() => {
|
||||
setPrefsState(DEFAULT_PREFS);
|
||||
(Object.keys(PREF_META) as AnimationPrefKey[]).forEach((key) => {
|
||||
localStorage.removeItem(PREF_META[key].storageKey);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Sync HTML attributes whenever effective state changes. Attributes drive
|
||||
// the CSS in light-mode.css. Master `data-light-mode` is kept for legacy
|
||||
// selectors and for any third-party CSS that reads it.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (isLightMode) {
|
||||
document.documentElement.setAttribute('data-light-mode', 'true');
|
||||
root.setAttribute('data-light-mode', 'true');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-light-mode');
|
||||
root.removeAttribute('data-light-mode');
|
||||
}
|
||||
}, [isLightMode]);
|
||||
(Object.keys(PREF_META) as AnimationPrefKey[]).forEach((key) => {
|
||||
const attr = PREF_META[key].attr;
|
||||
if (!effectivePrefs[key]) {
|
||||
root.setAttribute(attr, 'true');
|
||||
} else {
|
||||
root.removeAttribute(attr);
|
||||
}
|
||||
});
|
||||
}, [isLightMode, effectivePrefs]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ isLightMode, lightModeSetting, setLightModeSetting }),
|
||||
[isLightMode, lightModeSetting, setLightModeSetting]
|
||||
() => ({ isLightMode, lightModeSetting, setLightModeSetting, prefs, effectivePrefs, setPref, resetPrefs }),
|
||||
[isLightMode, lightModeSetting, setLightModeSetting, prefs, effectivePrefs, setPref, resetPrefs]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1043,8 +1043,8 @@
|
|||
"castUnavailable": "Casting unavailable",
|
||||
"castUnavailableNoDevices": "No receiver detected on your Wi-Fi network.",
|
||||
"castUnavailableUnsupportedBrowser": "Your browser doesn't support casting on this page.",
|
||||
"castUnavailableSdkBlocked": "The Google Cast SDK couldn't load (likely blocked by an ad blocker or your ISP). Disable your blocker on movix.cash and reload the page.",
|
||||
"castUnavailableHelpChromecast": "Chromecast: use Chrome or Edge on the same Wi-Fi as the TV. If you have an ad blocker, disable it on movix.cash (it often blocks Google's Cast SDK).",
|
||||
"castUnavailableSdkBlocked": "The Google Cast SDK couldn't load (likely blocked by an ad blocker or your ISP). Disable your blocker on movix.tax and reload the page.",
|
||||
"castUnavailableHelpChromecast": "Chromecast: use Chrome or Edge on the same Wi-Fi as the TV. If you have an ad blocker, disable it on movix.tax (it often blocks Google's Cast SDK).",
|
||||
"castUnavailableHelpAirPlay": "AirPlay: open the page in Safari on iPhone, iPad or Mac.",
|
||||
"castUnavailableSeeHelp": "View the Chromecast guide",
|
||||
"cast": "Cast",
|
||||
|
|
@ -1521,6 +1521,7 @@
|
|||
"subtitle": "Customize your experience",
|
||||
"sections": {
|
||||
"appearance": "Appearance",
|
||||
"performance": "Performance",
|
||||
"language": "Language",
|
||||
"vip": "VIP",
|
||||
"sessions": "Sessions",
|
||||
|
|
@ -1532,6 +1533,24 @@
|
|||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceDesc": "Customize the interface and animations",
|
||||
"performanceDesc": "Lighten the interface on low-end devices",
|
||||
"lightModeAutoOn": "Active",
|
||||
"lightModeAutoOff": "Inactive",
|
||||
"lightModeAutoHint": "\"Auto\" automatically detects low-end devices (Smart TV, fewer than 3 CPU cores, low memory, or system \"reduce motion\" preference).",
|
||||
"animPrefsTitle": "Detailed settings",
|
||||
"animPrefsHint": "Disable only the categories that cause lag — useful if you want to keep some animations.",
|
||||
"animPrefsHintLightModeOn": "Light mode is active: all animations below are forced off. Your choices are preserved and will be restored when you turn light mode off.",
|
||||
"animForcedByLightMode": "Forced OFF",
|
||||
"animBgTitle": "Background animations",
|
||||
"animBgDesc": "Particles, snowflakes, animated backgrounds, screensaver.",
|
||||
"animLoadingTitle": "Loading animations",
|
||||
"animLoadingDesc": "Pulsing skeletons, spinners, loading indicators.",
|
||||
"animCarouselTitle": "Auto-rotating carousels",
|
||||
"animCarouselDesc": "Automatic banner and carousel rotation. Manual swiping still works.",
|
||||
"animBlurTitle": "Blur effects",
|
||||
"animBlurDesc": "Backdrop-blur effects. Very GPU-heavy on older devices.",
|
||||
"animTransTitle": "Page transitions",
|
||||
"animTransDesc": "Fade and slide animations between pages and modals.",
|
||||
"introAnimation": "Intro animation",
|
||||
"introAnimationDesc": "Shows a periodic-table style animation when the site starts",
|
||||
"keepScrollPositionBetweenPages": "Keep scroll position between pages",
|
||||
|
|
@ -1588,9 +1607,13 @@
|
|||
"smoothScrollIntensity": "Smooth scroll intensity",
|
||||
"smoothScrollIntensityDesc": "Controls scroll inertia. Smoother = more glide, slightly less immediate response. Pick \"Disabled\" to fall back to native scroll (recommended on slower setups).",
|
||||
"smoothScrollIntensity.off": "Disabled",
|
||||
"smoothScrollIntensity.offDesc": "Native browser scroll",
|
||||
"smoothScrollIntensity.standard": "Standard",
|
||||
"smoothScrollIntensity.standardDesc": "Moderate, balanced inertia",
|
||||
"smoothScrollIntensity.fluid": "Fluid",
|
||||
"smoothScrollIntensity.fluidDesc": "Long glide, soft scrolling",
|
||||
"smoothScrollIntensity.ultra": "Ultra fluid",
|
||||
"smoothScrollIntensity.ultraDesc": "Maximum inertia, premium feel",
|
||||
"soundEffects": "Sound effects",
|
||||
"soundEffectsDesc": "Enables interface sounds, for example during roulette spins.",
|
||||
"snowEffect": "Snow effect",
|
||||
|
|
@ -1603,6 +1626,18 @@
|
|||
"bgStaticDesc": "Static grid with light spotlight",
|
||||
"bgAnimated": "Interactive",
|
||||
"bgAnimatedDesc": "Mouse-reactive animation only",
|
||||
"bgHalo": "Light halo",
|
||||
"bgHaloDesc": "Colored radial glow following the cursor. Turn it off if you find it distracting or if your GPU struggles.",
|
||||
"bgSquareSize": "Square size",
|
||||
"bgSquareSizeDesc": "Grid density: smaller = more squares.",
|
||||
"bgSizeSmall": "Dense",
|
||||
"bgSizeSmallDesc": "Very dense grid, lots of squares",
|
||||
"bgSizeMedium": "Medium",
|
||||
"bgSizeMediumDesc": "Balanced density, default",
|
||||
"bgSizeLarge": "Spacious",
|
||||
"bgSizeLargeDesc": "Wider squares, less dense",
|
||||
"bgSizeXLarge": "Very spacious",
|
||||
"bgSizeXLargeDesc": "Very wide squares, minimal grid",
|
||||
"lightMode": "Light mode",
|
||||
"lightModeDesc": "Disables animations, blur effects and particles to improve performance on low-resource devices (TV, older devices).",
|
||||
"lightModeAuto": "Auto",
|
||||
|
|
@ -5603,6 +5638,71 @@
|
|||
"badge": "Reinforced supervision"
|
||||
}
|
||||
},
|
||||
"adminOauthApps": {
|
||||
"cardTitle": "OAuth Applications",
|
||||
"cardDesc": "Manage third-party apps, their permissions, icons and VIP balance",
|
||||
"searchPlaceholder": "Search by id, name or description…",
|
||||
"showInactive": "Include inactive",
|
||||
"create": "New app",
|
||||
"empty": "No application registered.",
|
||||
"inactive": "Disabled",
|
||||
"callsLast30d": "Calls (30 d)",
|
||||
"vipBalance": "VIP balance (days)",
|
||||
"addDays": "Add",
|
||||
"removeDays": "Remove",
|
||||
"balanceHint": "Negative to remove",
|
||||
"balanceDeltaInvalid": "Invalid delta",
|
||||
"balanceUpdated": "Balance updated: {{newBalance}} day(s)",
|
||||
"stats": "Statistics",
|
||||
"edit": "Edit",
|
||||
"regenSecret": "New secret",
|
||||
"removeIcon": "Remove icon",
|
||||
"disable": "Disable",
|
||||
"enable": "Enable",
|
||||
"disabled": "Application disabled",
|
||||
"enabled": "Application enabled",
|
||||
"confirmDelete": "Delete application \"{{name}}\"? This cannot be undone.",
|
||||
"deleted": "Application deleted",
|
||||
"confirmRegenSecret": "Regenerate the secret? The old one will become invalid immediately.",
|
||||
"secretCopied": "New secret copied to clipboard (paste it into the client app)",
|
||||
"iconTooLarge": "Icon too large (max 256 KB)",
|
||||
"iconTypeInvalid": "Unsupported image type (PNG, JPEG or WebP)",
|
||||
"iconUploaded": "Icon updated",
|
||||
"iconRemoved": "Icon removed",
|
||||
"uploadIcon": "Upload an icon",
|
||||
"dropToUpload": "Drop the image to upload",
|
||||
"confirmIconDelete": "Remove the icon?",
|
||||
"createTitle": "Create an OAuth application",
|
||||
"editTitle": "Edit {{name}}",
|
||||
"statsTitle": "Statistics — {{name}}",
|
||||
"statsUnavailable": "Statistics unavailable",
|
||||
"noActivity": "No activity over the period.",
|
||||
"noGrants": "No grants issued by this application.",
|
||||
"dailyActivity": "Daily activity (last 14 days)",
|
||||
"recentGrants": "Recent VIP grants",
|
||||
"uniqueUsers": "Unique users",
|
||||
"userColumn": "User",
|
||||
"daysColumn": "Days",
|
||||
"keyColumn": "Key",
|
||||
"grantedAtColumn": "Issued on",
|
||||
"expiresColumn": "Expires on",
|
||||
"saved": "Changes saved",
|
||||
"created": "Application created",
|
||||
"secretNotShownAgain": "The clientSecret will never be shown again. Copy it now.",
|
||||
"clientIdLabel": "clientId",
|
||||
"clientIdHint": "Lowercase letters, digits and hyphens only (2 to 65 characters)",
|
||||
"clientNameLabel": "Display name",
|
||||
"clientNamePlaceholder": "My Application",
|
||||
"descriptionLabel": "Description (optional)",
|
||||
"homepageLabel": "Website (optional)",
|
||||
"redirectUrisLabel": "Redirect URIs (one per line)",
|
||||
"scopesLabel": "Allowed permissions",
|
||||
"clientTypeLabel": "Client type",
|
||||
"publicLabel": "Public (PKCE required — mobile apps, SPAs, MCP)",
|
||||
"confidentialLabel": "Confidential (with clientSecret)",
|
||||
"creating": "Creating…",
|
||||
"saving": "Saving…"
|
||||
},
|
||||
"oauthAuthorize": {
|
||||
"eyebrow": "Movix external login",
|
||||
"title": "Authorize an external app",
|
||||
|
|
@ -5660,14 +5760,198 @@
|
|||
"vipManage": {
|
||||
"title": "Manage your VIP",
|
||||
"description": "Allows the app to view and manage your VIP invoices through secured Movix endpoints."
|
||||
},
|
||||
"favoritesRead": {
|
||||
"title": "View your favorites",
|
||||
"description": "Allows the app to read your list of favorite movies and TV shows."
|
||||
},
|
||||
"favoritesAdd": {
|
||||
"title": "Add to favorites",
|
||||
"description": "Allows the app to add movies and TV shows to your favorites."
|
||||
},
|
||||
"favoritesRemove": {
|
||||
"title": "Remove from favorites",
|
||||
"description": "Allows the app to remove movies and TV shows from your favorites."
|
||||
},
|
||||
"listsRead": {
|
||||
"title": "View your custom lists",
|
||||
"description": "Allows the app to list your custom lists and their contents."
|
||||
},
|
||||
"listsCreate": {
|
||||
"title": "Create lists",
|
||||
"description": "Allows the app to create new empty custom lists."
|
||||
},
|
||||
"listsRename": {
|
||||
"title": "Rename your lists",
|
||||
"description": "Allows the app to rename your existing custom lists."
|
||||
},
|
||||
"listsDelete": {
|
||||
"title": "Delete your lists",
|
||||
"description": "Allows the app to permanently delete your custom lists."
|
||||
},
|
||||
"listsAddItem": {
|
||||
"title": "Add items to your lists",
|
||||
"description": "Allows the app to add movies and TV shows to your custom lists."
|
||||
},
|
||||
"listsRemoveItem": {
|
||||
"title": "Remove items from your lists",
|
||||
"description": "Allows the app to remove movies and TV shows from your custom lists."
|
||||
},
|
||||
"watchlistRead": {
|
||||
"title": "View your watchlist",
|
||||
"description": "Allows the app to read your watchlist (movies, TV shows, live TV channels, shared lists)."
|
||||
},
|
||||
"watchlistAdd": {
|
||||
"title": "Add to watchlist",
|
||||
"description": "Allows the app to add movies, TV shows, live TV channels and shared lists to your watchlist."
|
||||
},
|
||||
"watchlistRemove": {
|
||||
"title": "Remove from watchlist",
|
||||
"description": "Allows the app to remove items from your watchlist."
|
||||
},
|
||||
"historyRead": {
|
||||
"title": "View your watch history",
|
||||
"description": "Allows the app to read the list of movies and TV shows you marked as watched."
|
||||
},
|
||||
"historyAdd": {
|
||||
"title": "Mark as watched",
|
||||
"description": "Allows the app to add movies and TV shows to your watch history."
|
||||
},
|
||||
"historyRemove": {
|
||||
"title": "Remove from watch history",
|
||||
"description": "Allows the app to remove movies and TV shows from your watch history."
|
||||
},
|
||||
"continueWatchingRead": {
|
||||
"title": "View your in-progress movies/shows",
|
||||
"description": "Allows the app to read movies and TV shows you started but haven't finished, including playback progress."
|
||||
},
|
||||
"alertsRead": {
|
||||
"title": "View your alerts",
|
||||
"description": "Allows the app to list the movies and shows you've subscribed to alerts for (new seasons, releases)."
|
||||
},
|
||||
"alertsManage": {
|
||||
"title": "Manage your alerts",
|
||||
"description": "Allows the app to subscribe to or unsubscribe from new-season / release alerts."
|
||||
},
|
||||
"ratingsRead": {
|
||||
"title": "View your personal ratings",
|
||||
"description": "Allows the app to read the ratings (1 to 10) you've given to movies and TV shows."
|
||||
},
|
||||
"ratingsManage": {
|
||||
"title": "Manage your personal ratings",
|
||||
"description": "Allows the app to add, change or remove your personal ratings."
|
||||
},
|
||||
"commentsRead": {
|
||||
"title": "Read your comments",
|
||||
"description": "Allows the app to read your public comments posted on movie and TV show pages."
|
||||
},
|
||||
"wishboardRead": {
|
||||
"title": "View the Wishboard",
|
||||
"description": "Allows the app to browse the Movix community's content requests."
|
||||
},
|
||||
"sharedListsRead": {
|
||||
"title": "View shared lists",
|
||||
"description": "Allows the app to read the contents of a Movix shared list from its share code."
|
||||
},
|
||||
"liveTvRead": {
|
||||
"title": "View Live TV",
|
||||
"description": "Allows the app to list available Live TV channels."
|
||||
},
|
||||
"vipInvoicesRead": {
|
||||
"title": "View your VIP invoices",
|
||||
"description": "Allows the app to read the list of your VIP invoices (purchases, donations)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"fakeNotRequestedTitle": "What Movix doesn't ask for",
|
||||
"fakeNotRequested": [
|
||||
"Hack NASA from your smart fridge",
|
||||
"Reveal the real Matrix source code",
|
||||
"Explain the meaning of life (spoiler: 42)",
|
||||
"Tell you who will win the 2034 World Cup",
|
||||
"Reveal Satoshi Nakamoto's true identity",
|
||||
"Steal your cat Mr. Whiskers at 3 a.m.",
|
||||
"Steam-iron your dog",
|
||||
"Tame an adult lion in your living room",
|
||||
"Grow your dreadlocks in 48 hours",
|
||||
"Impersonate Pope Francis",
|
||||
"Repair the Hubble telescope by WhatsApp",
|
||||
"Send you to low Earth orbit without a helmet",
|
||||
"Turn your toaster into a bitcoin miner",
|
||||
"Teach you German in 12 hours flat",
|
||||
"Massage your shoulders during your HR Zoom",
|
||||
"Convince your ex it was their fault",
|
||||
"Block your mother-in-law on Facebook AND LinkedIn",
|
||||
"Unsubscribe you from your 47 zombie newsletters",
|
||||
"Hide your TV remote in the fridge",
|
||||
"Reprogram your washing machine in Mandarin",
|
||||
"Cook your rice for exactly 17 minutes",
|
||||
"Sing La Traviata in your shower",
|
||||
"Sign you up on Tinder while you're away",
|
||||
"Right-swipe every cat profile on Tinder",
|
||||
"Help you lose 5 kg in 2 days (not legal)",
|
||||
"Find you a Paris flat for €400/month",
|
||||
"Forecast your 2030 taxes ahead of time",
|
||||
"Yell \"ALEXAAA\" in your ears at 4 a.m.",
|
||||
"Order you 200 toothpicks on Amazon",
|
||||
"Steal your hidden Ferrero Rocher stash",
|
||||
"Sing you a lullaby in Klingon",
|
||||
"Explain why your AirPods always vanish",
|
||||
"Set your alarm for exactly 3:33 a.m. every night",
|
||||
"Repaint your ceiling neon pink at 4 a.m.",
|
||||
"Boot Windows XP on your iPhone",
|
||||
"Install Internet Explorer 6 out of nostalgia",
|
||||
"Update Adobe Flash Player one last time",
|
||||
"Uninstall McAfee that's been spamming since 2014",
|
||||
"Crack your neighbor's WiFi (\"BT-Hub-3F2A\")",
|
||||
"Stick 12 Hello Kitty stickers on your fridge",
|
||||
"Make you VIP for free (never. NEVER.)",
|
||||
"Serve your cat a mojito every Friday",
|
||||
"Do your gel nails while you sleep",
|
||||
"Trim your hair with a dog-grooming clipper",
|
||||
"Pick your wallpaper behind your back (a cactus)",
|
||||
"Send a passive-aggressive text to your ex",
|
||||
"Write your CV in WordArt 1997",
|
||||
"Teach you to knit a mohair scarf",
|
||||
"Give you Mars's accurate weather forecast",
|
||||
"Reveal the truth about Santa Claus",
|
||||
"Decline your LinkedIn invites with insults",
|
||||
"Unsubscribe you from TikTok in your sleep",
|
||||
"Give you a Cuban salsa class on Zoom",
|
||||
"Silently steal your neighbor's 4K TV",
|
||||
"Fix your vacuum that refuses to start",
|
||||
"Set you up on a romantic dinner with your baker",
|
||||
"Hand-wax your laminate floor",
|
||||
"Do your dark laundry on a Friday the 13th",
|
||||
"Order you a 4-cheese pizza at 2 a.m.",
|
||||
"Help you crack the Da Vinci code",
|
||||
"Teach you to whistle while inhaling",
|
||||
"Style your hair into a hedgehog without gel",
|
||||
"Reply to your tricky HR email",
|
||||
"Find your soulmate on Vinted",
|
||||
"Explain blockchain to your grandma",
|
||||
"Tattoo \"VIP\" on your forearm during your nap",
|
||||
"Teach you fluent cat",
|
||||
"Code your WordPress site in COBOL",
|
||||
"Get you a boat license in a swimming pool",
|
||||
"Gift you Jeff Bezos for your birthday",
|
||||
"Turn you into an NFT against your will",
|
||||
"Teach you ballet during nap time",
|
||||
"Reveal every Konami Code",
|
||||
"Drone-deliver your coffee at 6 a.m. sharp",
|
||||
"Make you cry at a French stand-up sketch",
|
||||
"Send you a handwritten letter in Sumerian",
|
||||
"Land you a mattress-tester job at IKEA",
|
||||
"Teach you music theory in reversed Braille",
|
||||
"Build you a pickle-themed advent calendar",
|
||||
"Steal your bike and return it next Friday",
|
||||
"Knit a sweater for your vacuum cleaner"
|
||||
]
|
||||
},
|
||||
"embed": {
|
||||
"blocked": "🚫 Access Blocked 🚫",
|
||||
"unauthorized": "Embed Not Authorized",
|
||||
"message": "This site cannot be displayed in an embed. Please visit our official site directly.",
|
||||
"goToSite": "Go to movix.cash"
|
||||
"goToSite": "Go to movix.tax"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "🔧 Our services are temporarily unavailable 🔧",
|
||||
|
|
@ -5876,7 +6160,7 @@
|
|||
"whyTitle": "Why a 12-word seed?",
|
||||
"whyBody": "Movix has no email or password: your identity is a <1>12-word phrase</1> (<2>BIP39</2> standard, the same as Bitcoin). No data collection, no password reset, no phishing emails. You own your account.",
|
||||
"stepsTitle": "How to create",
|
||||
"step1": "Go to movix.cash → Create account.",
|
||||
"step1": "Go to movix.tax → Create account.",
|
||||
"step2": "Movix generates 12 random words — shown ONLY ONCE on screen.",
|
||||
"step3": "Save those 12 words in a password manager, an encrypted file, or on paper. This is your one-and-only key.",
|
||||
"step4": "Confirm by retyping the words. Your account is created, you're logged in.",
|
||||
|
|
@ -5949,9 +6233,9 @@
|
|||
"heroSub": "Add Movix to your home screen for quick access, no app store.",
|
||||
"introBody": "Movix is a <1>PWA</1> (Progressive Web App): you can install it like a real app from your browser. No Play Store or App Store needed. Works on iOS, Android, Windows, macOS, Linux.",
|
||||
"iosTitle": "iOS (Safari)",
|
||||
"iosBody": "Open movix.cash in Safari. Tap the share icon (square with an up arrow) → 'Add to Home Screen' → Add. The Movix icon appears on your home screen, fullscreen when you open it.",
|
||||
"iosBody": "Open movix.tax in Safari. Tap the share icon (square with an up arrow) → 'Add to Home Screen' → Add. The Movix icon appears on your home screen, fullscreen when you open it.",
|
||||
"androidTitle": "Android (Chrome)",
|
||||
"androidBody": "Open movix.cash in Chrome. An 'Install app' popup appears at the bottom. Otherwise, menu (⋮) → 'Install app' or 'Add to home screen'.",
|
||||
"androidBody": "Open movix.tax in Chrome. An 'Install app' popup appears at the bottom. Otherwise, menu (⋮) → 'Install app' or 'Add to home screen'.",
|
||||
"desktopTitle": "Desktop (Chrome / Edge / Brave)",
|
||||
"desktopBody": "In the URL bar, look for the install icon (monitor with a down arrow, right of the URL). Click it to install Movix as a standalone app.",
|
||||
"advantagesTitle": "Upsides",
|
||||
|
|
@ -5979,7 +6263,7 @@
|
|||
"whyTitle": "Why open-source?",
|
||||
"whyBody": "Transparency first: you can verify what your browser runs and what the server stores. Your BIP39 account (12-word seed) is handled client-side — the server never sees your seed, and you can go read the code to prove it. Resilience next: if the main domain goes down or if Movix disappears, anyone can clone, build, and host. The project doesn't depend on any proprietary service to run. Trust last: many streaming platforms are closed and opaque — here everything is auditable, and every commit is public.",
|
||||
"selfhostTitle": "Self-host your own instance",
|
||||
"selfhostBody": "You can clone the <1>repo</1> and run Movix locally or on your own server. The README and CLAUDE.md at the root document the stack, env vars (~100 for the main API), commands (npm run dev for the frontend on :3000, node server.js for the main API on :25565, python server.py for the embed proxies on :25569, etc.) and the service architecture. You'll need MySQL, Redis, and TMDB / Turnstile / Discord&Google OAuth keys depending on what you enable. The instance you spin up is fully independent of movix.cash — your users, your database, your control.",
|
||||
"selfhostBody": "You can clone the <1>repo</1> and run Movix locally or on your own server. The README and CLAUDE.md at the root document the stack, env vars (~100 for the main API), commands (npm run dev for the frontend on :3000, node server.js for the main API on :25565, python server.py for the embed proxies on :25569, etc.) and the service architecture. You'll need MySQL, Redis, and TMDB / Turnstile / Discord&Google OAuth keys depending on what you enable. The instance you spin up is fully independent of movix.tax — your users, your database, your control.",
|
||||
"contribTitle": "Contribute",
|
||||
"contribBody": "PRs are welcome on the <1>GitHub repo</1>. Bugs, translations (especially English — French is the primary language, many EN strings still need polish), new sources, UX improvements, new tutorials to add to the hub: all helpful. For small fixes, PR directly. For big architecture changes, open an issue first so we can talk it through before you spend three days on it.",
|
||||
"limitsTitle": "What the license does NOT allow",
|
||||
|
|
@ -6008,7 +6292,7 @@
|
|||
"cause1Body": "French ISPs (Orange, SFR, Free, Bouygues) regularly block our domains and streaming sources by court order (<1>ARCOM</1>). Most of the time, this is the culprit.",
|
||||
"cause1Cta": "How to bypass",
|
||||
"cause2Title": "Overzealous adblocker",
|
||||
"cause2Body": "Some adblockers kill extraction scripts or block source domains. Try whitelisting movix.cash and disable temporarily to test. We recommend Brave + uBlock Origin, the most stable combo on Movix.",
|
||||
"cause2Body": "Some adblockers kill extraction scripts or block source domains. Try whitelisting movix.tax and disable temporarily to test. We recommend Brave + uBlock Origin, the most stable combo on Movix.",
|
||||
"cause2CtaBrave": "Download Brave",
|
||||
"cause2CtaUblock": "Install uBlock Origin",
|
||||
"cause3Title": "Movix domain unreachable",
|
||||
|
|
@ -6143,11 +6427,11 @@
|
|||
"heroSub": "How Movix redirects you to an alive domain when the main one is blocked, and where to find the official list.",
|
||||
"introBody": "French ISPs (Orange, SFR, Free, Bouygues) regularly block Movix domains on <1>ARCOM</1> orders. To keep you connected through blocks, Movix installs a service worker in your browser that detects network failure and automatically redirects you to a still-alive mirror — no manual URL lookup needed.",
|
||||
"howTitle": "How the redirect works",
|
||||
"howBody": "When you visit movix.cash, a service worker (a silent piece of code) installs in your browser. If movix.cash later becomes unreachable (3-second timeout or DNS error), the service worker fetches the alive-mirror list from an external non-blocked source (rentry.co/movix, a pastebin service hosted outside France) and redirects you to the first available mirror. Transparent, instant, zero config.",
|
||||
"howBody": "When you visit movix.tax, a service worker (a silent piece of code) installs in your browser. If movix.tax later becomes unreachable (3-second timeout or DNS error), the service worker fetches the alive-mirror list from an external non-blocked source (rentry.co/movix, a pastebin service hosted outside France) and redirects you to the first available mirror. Transparent, instant, zero config.",
|
||||
"officialListTitle": "Where to see the official list",
|
||||
"officialListBody": "Two places publish the canonical alive-mirror list: <1>movix.health</1> (domain status with real-time up/down indicator) and <2>rentry.co/movix</2> (raw list, edited by the Movix team, always current). If you doubt a URL claiming to be Movix, check it against one of these — anything NOT on the list is a fake (sacrificial domains designed to fool anti-piracy bots, or phishing attempts).",
|
||||
"newDeviceTitle": "New phone / new browser — the redirect doesn't kick in",
|
||||
"newDeviceBody": "The service worker can only redirect you if it's been installed at least once before the block. On a brand-new device that has never visited movix.cash, if the domain is already ISP-blocked, you'll hit an ISP error page with no redirect. Solution: open movix.health or rentry.co/movix from any browser (those pages are hosted outside the ISP block), grab an alive mirror, and visit it. Or join <1>Telegram @movix_site</1> where the list is always up to date.",
|
||||
"newDeviceBody": "The service worker can only redirect you if it's been installed at least once before the block. On a brand-new device that has never visited movix.tax, if the domain is already ISP-blocked, you'll hit an ISP error page with no redirect. Solution: open movix.health or rentry.co/movix from any browser (those pages are hosted outside the ISP block), grab an alive mirror, and visit it. Or join <1>Telegram @movix_site</1> where the list is always up to date.",
|
||||
"reliabilityTitle": "How often things change",
|
||||
"reliabilityBody": "A domain can go down overnight (new ARCOM order) or stay stable for months. The team rotates multiple domains in parallel — when one goes down, another takes over automatically. The rentry.co list is updated within 24h of any change.",
|
||||
"limitsTitle": "Limits",
|
||||
|
|
|
|||
|
|
@ -1043,8 +1043,8 @@
|
|||
"castUnavailable": "Diffusion non disponible",
|
||||
"castUnavailableNoDevices": "Aucun récepteur détecté sur ton réseau Wi-Fi.",
|
||||
"castUnavailableUnsupportedBrowser": "Ton navigateur ne gère pas la diffusion sur cette page.",
|
||||
"castUnavailableSdkBlocked": "Le SDK Google Cast n'a pas pu charger (probablement bloqué par un bloqueur de pubs ou ton FAI). Désactive ton bloqueur sur movix.cash et recharge la page.",
|
||||
"castUnavailableHelpChromecast": "Chromecast : utilise Chrome ou Edge sur le même Wi-Fi que la TV. Si tu as un bloqueur de pubs, désactive-le sur movix.cash (il bloque souvent le SDK Cast de Google).",
|
||||
"castUnavailableSdkBlocked": "Le SDK Google Cast n'a pas pu charger (probablement bloqué par un bloqueur de pubs ou ton FAI). Désactive ton bloqueur sur movix.tax et recharge la page.",
|
||||
"castUnavailableHelpChromecast": "Chromecast : utilise Chrome ou Edge sur le même Wi-Fi que la TV. Si tu as un bloqueur de pubs, désactive-le sur movix.tax (il bloque souvent le SDK Cast de Google).",
|
||||
"castUnavailableHelpAirPlay": "AirPlay : ouvre la page dans Safari sur iPhone, iPad ou Mac.",
|
||||
"castUnavailableSeeHelp": "Voir le guide Chromecast",
|
||||
"cast": "Caster",
|
||||
|
|
@ -1521,6 +1521,7 @@
|
|||
"subtitle": "Personnalisez votre expérience",
|
||||
"sections": {
|
||||
"appearance": "Apparence",
|
||||
"performance": "Performance",
|
||||
"language": "Langue",
|
||||
"vip": "VIP",
|
||||
"sessions": "Sessions",
|
||||
|
|
@ -1532,6 +1533,24 @@
|
|||
},
|
||||
"appearance": "Apparence",
|
||||
"appearanceDesc": "Personnalisez l'interface et les animations",
|
||||
"performanceDesc": "Allégez l'interface sur les appareils peu performants",
|
||||
"lightModeAutoOn": "Actif",
|
||||
"lightModeAutoOff": "Inactif",
|
||||
"lightModeAutoHint": "« Auto » détecte automatiquement les appareils faibles (Smart TV, moins de 3 cœurs CPU, peu de mémoire, ou préférence système « réduire les animations »).",
|
||||
"animPrefsTitle": "Réglages détaillés",
|
||||
"animPrefsHint": "Désactive uniquement les catégories qui te font ramer — pratique si tu veux garder certaines animations.",
|
||||
"animPrefsHintLightModeOn": "Mode léger est actif : toutes les animations ci-dessous sont forcées désactivées. Tes choix sont conservés et seront rétablis quand tu désactiveras le Mode léger.",
|
||||
"animForcedByLightMode": "Forcé OFF",
|
||||
"animBgTitle": "Animations de fond",
|
||||
"animBgDesc": "Particules, flocons de neige, fonds animés, économiseur d'écran.",
|
||||
"animLoadingTitle": "Animations de chargement",
|
||||
"animLoadingDesc": "Squelettes pulsants, spinners, indicateurs de chargement.",
|
||||
"animCarouselTitle": "Carrousels automatiques",
|
||||
"animCarouselDesc": "Rotation automatique des bannières et carrousels. Le swipe manuel reste actif.",
|
||||
"animBlurTitle": "Effets de flou",
|
||||
"animBlurDesc": "Flous d'arrière-plan (backdrop-blur). Très lourds en GPU sur les appareils anciens.",
|
||||
"animTransTitle": "Transitions de page",
|
||||
"animTransDesc": "Animations d'apparition/disparition (fondus, glissements) entre pages et modales.",
|
||||
"introAnimation": "Animation d'intro",
|
||||
"introAnimationDesc": "Affiche une animation style tableau périodique au lancement du site",
|
||||
"keepScrollPositionBetweenPages": "Conserver la position entre les pages",
|
||||
|
|
@ -1588,9 +1607,13 @@
|
|||
"smoothScrollIntensity": "Intensité du scroll fluide",
|
||||
"smoothScrollIntensityDesc": "Contrôle l'inertie du défilement. Plus fluide = plus de glisse, réponse un peu moins immédiate. Choisis « Désactivé » pour repasser au scroll natif (recommandé sur configs lentes).",
|
||||
"smoothScrollIntensity.off": "Désactivé",
|
||||
"smoothScrollIntensity.offDesc": "Scroll natif du navigateur",
|
||||
"smoothScrollIntensity.standard": "Standard",
|
||||
"smoothScrollIntensity.standardDesc": "Inertie modérée, équilibrée",
|
||||
"smoothScrollIntensity.fluid": "Fluide",
|
||||
"smoothScrollIntensity.fluidDesc": "Glisse longue, défilement souple",
|
||||
"smoothScrollIntensity.ultra": "Ultra fluide",
|
||||
"smoothScrollIntensity.ultraDesc": "Inertie maximale, effet premium",
|
||||
"soundEffects": "Bruitages",
|
||||
"soundEffectsDesc": "Active les sons d'interface, par exemple pendant la roulette.",
|
||||
"snowEffect": "Effet de neige",
|
||||
|
|
@ -1603,6 +1626,18 @@
|
|||
"bgStaticDesc": "Grille statique avec halo lumineux",
|
||||
"bgAnimated": "Interactif",
|
||||
"bgAnimatedDesc": "Animation réactive à la souris uniquement",
|
||||
"bgHalo": "Halo lumineux",
|
||||
"bgHaloDesc": "Lueur radiale colorée qui suit le curseur. Désactive-la si tu trouves l'effet gênant ou si ton GPU rame.",
|
||||
"bgSquareSize": "Taille des carrés",
|
||||
"bgSquareSizeDesc": "Densité de la grille : plus petit = plus de carrés.",
|
||||
"bgSizeSmall": "Dense",
|
||||
"bgSizeSmallDesc": "Grille très dense, beaucoup de carrés",
|
||||
"bgSizeMedium": "Moyen",
|
||||
"bgSizeMediumDesc": "Densité équilibrée, par défaut",
|
||||
"bgSizeLarge": "Aéré",
|
||||
"bgSizeLargeDesc": "Carrés plus larges, moins denses",
|
||||
"bgSizeXLarge": "Très aéré",
|
||||
"bgSizeXLargeDesc": "Carrés très larges, grille minimale",
|
||||
"lightMode": "Mode léger",
|
||||
"lightModeDesc": "Désactive les animations, effets de flou et particules pour améliorer les performances sur les appareils à faibles ressources (TV, anciens appareils).",
|
||||
"lightModeAuto": "Auto",
|
||||
|
|
@ -5603,6 +5638,71 @@
|
|||
"badge": "Supervision renforcée"
|
||||
}
|
||||
},
|
||||
"adminOauthApps": {
|
||||
"cardTitle": "Applications OAuth",
|
||||
"cardDesc": "Gérer les apps tierces, leurs droits, leurs icônes et leur solde VIP",
|
||||
"searchPlaceholder": "Rechercher par id, nom, description…",
|
||||
"showInactive": "Inclure désactivées",
|
||||
"create": "Nouvelle app",
|
||||
"empty": "Aucune application enregistrée.",
|
||||
"inactive": "Désactivée",
|
||||
"callsLast30d": "Appels (30 j)",
|
||||
"vipBalance": "Solde VIP (jours)",
|
||||
"addDays": "Ajouter",
|
||||
"removeDays": "Retirer",
|
||||
"balanceHint": "Négatif pour retirer",
|
||||
"balanceDeltaInvalid": "Delta invalide",
|
||||
"balanceUpdated": "Solde mis à jour : {{newBalance}} jour(s)",
|
||||
"stats": "Statistiques",
|
||||
"edit": "Modifier",
|
||||
"regenSecret": "Nouveau secret",
|
||||
"removeIcon": "Retirer icône",
|
||||
"disable": "Désactiver",
|
||||
"enable": "Activer",
|
||||
"disabled": "Application désactivée",
|
||||
"enabled": "Application activée",
|
||||
"confirmDelete": "Supprimer l'application « {{name}} » ? Cette action est irréversible.",
|
||||
"deleted": "Application supprimée",
|
||||
"confirmRegenSecret": "Régénérer le secret ? L'ancien deviendra invalide immédiatement.",
|
||||
"secretCopied": "Nouveau secret copié dans le presse-papier (à coller dans l'app cliente)",
|
||||
"iconTooLarge": "Icône trop grosse (max 256 Ko)",
|
||||
"iconTypeInvalid": "Type d'image non supporté (PNG, JPEG ou WebP)",
|
||||
"iconUploaded": "Icône mise à jour",
|
||||
"iconRemoved": "Icône retirée",
|
||||
"uploadIcon": "Uploader une icône",
|
||||
"dropToUpload": "Déposez l'image pour l'uploader",
|
||||
"confirmIconDelete": "Retirer l'icône ?",
|
||||
"createTitle": "Créer une application OAuth",
|
||||
"editTitle": "Modifier {{name}}",
|
||||
"statsTitle": "Statistiques — {{name}}",
|
||||
"statsUnavailable": "Statistiques indisponibles",
|
||||
"noActivity": "Aucune activité sur la période.",
|
||||
"noGrants": "Aucun grant émis par cette application.",
|
||||
"dailyActivity": "Activité par jour (14 derniers jours)",
|
||||
"recentGrants": "Grants VIP récents",
|
||||
"uniqueUsers": "Utilisateurs uniques",
|
||||
"userColumn": "Utilisateur",
|
||||
"daysColumn": "Jours",
|
||||
"keyColumn": "Clé",
|
||||
"grantedAtColumn": "Émise le",
|
||||
"expiresColumn": "Expire le",
|
||||
"saved": "Modifications enregistrées",
|
||||
"created": "Application créée",
|
||||
"secretNotShownAgain": "Le clientSecret n'apparaîtra plus jamais. Copie-le maintenant.",
|
||||
"clientIdLabel": "clientId",
|
||||
"clientIdHint": "Lettres minuscules, chiffres et tirets uniquement (2 à 65 caractères)",
|
||||
"clientNameLabel": "Nom affiché",
|
||||
"clientNamePlaceholder": "Mon Application",
|
||||
"descriptionLabel": "Description (optionnel)",
|
||||
"homepageLabel": "Site web (optionnel)",
|
||||
"redirectUrisLabel": "Redirect URIs (1 par ligne)",
|
||||
"scopesLabel": "Permissions autorisées",
|
||||
"clientTypeLabel": "Type de client",
|
||||
"publicLabel": "Public (PKCE obligatoire — apps mobiles, SPA, MCP)",
|
||||
"confidentialLabel": "Confidentiel (avec clientSecret)",
|
||||
"creating": "Création…",
|
||||
"saving": "Enregistrement…"
|
||||
},
|
||||
"oauthAuthorize": {
|
||||
"eyebrow": "Connexion externe Movix",
|
||||
"title": "Autoriser une application externe",
|
||||
|
|
@ -5660,14 +5760,198 @@
|
|||
"vipManage": {
|
||||
"title": "Gérer votre VIP",
|
||||
"description": "Permet à l’application de consulter et gérer vos invoices VIP via les endpoints sécurisés Movix."
|
||||
},
|
||||
"favoritesRead": {
|
||||
"title": "Voir vos favoris",
|
||||
"description": "Permet à l’application de lire la liste de vos films et séries favoris."
|
||||
},
|
||||
"favoritesAdd": {
|
||||
"title": "Ajouter aux favoris",
|
||||
"description": "Permet à l’application d’ajouter des films et séries à vos favoris."
|
||||
},
|
||||
"favoritesRemove": {
|
||||
"title": "Retirer des favoris",
|
||||
"description": "Permet à l’application de retirer des films et séries de vos favoris."
|
||||
},
|
||||
"listsRead": {
|
||||
"title": "Voir vos listes personnalisées",
|
||||
"description": "Permet à l’application de lister vos listes personnalisées et leur contenu."
|
||||
},
|
||||
"listsCreate": {
|
||||
"title": "Créer des listes",
|
||||
"description": "Permet à l’application de créer de nouvelles listes personnalisées vides."
|
||||
},
|
||||
"listsRename": {
|
||||
"title": "Renommer vos listes",
|
||||
"description": "Permet à l’application de renommer vos listes personnalisées existantes."
|
||||
},
|
||||
"listsDelete": {
|
||||
"title": "Supprimer vos listes",
|
||||
"description": "Permet à l’application de supprimer définitivement vos listes personnalisées."
|
||||
},
|
||||
"listsAddItem": {
|
||||
"title": "Ajouter des items à vos listes",
|
||||
"description": "Permet à l’application d’ajouter des films et séries à vos listes personnalisées."
|
||||
},
|
||||
"listsRemoveItem": {
|
||||
"title": "Retirer des items de vos listes",
|
||||
"description": "Permet à l’application de retirer des films et séries de vos listes personnalisées."
|
||||
},
|
||||
"watchlistRead": {
|
||||
"title": "Voir votre watchlist",
|
||||
"description": "Permet à l’application de lire votre watchlist (films, séries, chaînes live TV, listes partagées)."
|
||||
},
|
||||
"watchlistAdd": {
|
||||
"title": "Ajouter à la watchlist",
|
||||
"description": "Permet à l’application d’ajouter des films, séries, chaînes live TV et listes partagées à votre watchlist."
|
||||
},
|
||||
"watchlistRemove": {
|
||||
"title": "Retirer de la watchlist",
|
||||
"description": "Permet à l’application de retirer des items de votre watchlist."
|
||||
},
|
||||
"historyRead": {
|
||||
"title": "Voir votre historique de visionnage",
|
||||
"description": "Permet à l’application de lire la liste des films et séries que vous avez marqués comme vus."
|
||||
},
|
||||
"historyAdd": {
|
||||
"title": "Marquer comme vu",
|
||||
"description": "Permet à l’application d’ajouter des films et séries à votre historique de visionnage."
|
||||
},
|
||||
"historyRemove": {
|
||||
"title": "Retirer de l’historique de visionnage",
|
||||
"description": "Permet à l’application de retirer des films et séries de votre historique de visionnage."
|
||||
},
|
||||
"continueWatchingRead": {
|
||||
"title": "Voir vos films/séries en cours",
|
||||
"description": "Permet à l’application de lire les films et séries que vous avez commencés mais pas terminés, avec la progression de lecture."
|
||||
},
|
||||
"alertsRead": {
|
||||
"title": "Voir vos alertes",
|
||||
"description": "Permet à l’application de lister les films et séries pour lesquels vous avez activé une alerte (nouvelles saisons, sorties)."
|
||||
},
|
||||
"alertsManage": {
|
||||
"title": "Gérer vos alertes",
|
||||
"description": "Permet à l’application d’activer ou de désactiver des alertes de nouvelles saisons / sorties."
|
||||
},
|
||||
"ratingsRead": {
|
||||
"title": "Voir vos notes personnelles",
|
||||
"description": "Permet à l’application de lire les notes (1 à 10) que vous avez attribuées aux films et séries."
|
||||
},
|
||||
"ratingsManage": {
|
||||
"title": "Gérer vos notes personnelles",
|
||||
"description": "Permet à l’application d’attribuer, modifier ou supprimer vos notes personnelles."
|
||||
},
|
||||
"commentsRead": {
|
||||
"title": "Lire vos commentaires",
|
||||
"description": "Permet à l’application de lire les commentaires publics que vous avez postés sur les fiches films et séries."
|
||||
},
|
||||
"wishboardRead": {
|
||||
"title": "Voir le Wishboard",
|
||||
"description": "Permet à l’application de parcourir les demandes de contenu de la communauté Movix."
|
||||
},
|
||||
"sharedListsRead": {
|
||||
"title": "Voir les listes partagées",
|
||||
"description": "Permet à l’application de lire le contenu d’une liste partagée Movix à partir de son code de partage."
|
||||
},
|
||||
"liveTvRead": {
|
||||
"title": "Voir la Live TV",
|
||||
"description": "Permet à l’application de lister les chaînes Live TV disponibles."
|
||||
},
|
||||
"vipInvoicesRead": {
|
||||
"title": "Voir vos factures VIP",
|
||||
"description": "Permet à l’application de lire la liste de vos factures VIP (achats, dons)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"fakeNotRequestedTitle": "Ce que Movix ne demande pas",
|
||||
"fakeNotRequested": [
|
||||
"Hacker la NASA depuis ton frigo connecté",
|
||||
"Te révéler les vrais codes de la Matrice",
|
||||
"T’expliquer le sens de la vie (spoiler : 42)",
|
||||
"Te dire qui gagnera la Coupe du Monde 2034",
|
||||
"Te dévoiler l’identité réelle de Satoshi Nakamoto",
|
||||
"Voler ton chat Pamplemousse à 3h du matin",
|
||||
"Repasser ton chien à la vapeur",
|
||||
"Dompter un lion adulte dans ton salon",
|
||||
"Te faire pousser des dreadlocks en 48h",
|
||||
"Te faire passer pour le Pape François",
|
||||
"Réparer le télescope Hubble par WhatsApp",
|
||||
"T’envoyer en orbite basse sans casque",
|
||||
"Convertir ton grille-pain en mineur de bitcoin",
|
||||
"T’apprendre l’allemand en 12h chrono",
|
||||
"Te masser les épaules pendant ton Zoom RH",
|
||||
"Convaincre ton ex que c’était sa faute",
|
||||
"Bloquer ta belle-mère sur Facebook ET LinkedIn",
|
||||
"Te désinscrire de tes 47 newsletters dormantes",
|
||||
"Cacher ta télécommande dans le frigo",
|
||||
"Reprogrammer ta machine à laver en mandarin",
|
||||
"Cuire ton riz pendant exactement 17 minutes",
|
||||
"Te chanter La Traviata sous la douche",
|
||||
"T’inscrire à Tinder en ton absence",
|
||||
"Swiper à droite sur tous les profils de chats",
|
||||
"Te faire perdre 5 kg en 2 jours (pas légal)",
|
||||
"Te trouver un appart à Paris pour 400€/mois",
|
||||
"Te calculer tes impôts en pré-vision 2030",
|
||||
"Crier « ALEXAAA » dans tes oreilles à 4h du mat",
|
||||
"Te commander 200 cure-dents sur Amazon",
|
||||
"Voler les Ferrero Rocher cachés dans ton placard",
|
||||
"Te chanter une berceuse en klingon",
|
||||
"T’expliquer pourquoi tes AirPods disparaissent",
|
||||
"Régler ton réveil à 3h33 pile chaque nuit",
|
||||
"Repeindre ton plafond en rose flashy à 4h du mat",
|
||||
"Booter Windows XP sur ton iPhone",
|
||||
"Installer Internet Explorer 6 par nostalgie",
|
||||
"Mettre à jour Adobe Flash Player une dernière fois",
|
||||
"Désinstaller McAfee qui spam depuis 2014",
|
||||
"Cracker le WiFi du voisin (« Livebox-3F2A »)",
|
||||
"Coller 12 stickers Hello Kitty sur ton frigo",
|
||||
"Te faire devenir VIP gratis (jamais. JAMAIS.)",
|
||||
"Servir un mojito à ton chat chaque vendredi",
|
||||
"Te faire les ongles en gel pendant que tu dors",
|
||||
"Couper tes cheveux à la tondeuse pour chien",
|
||||
"Choisir ton fond d’écran à ton insu (un cactus)",
|
||||
"Envoyer un sms passif-agressif à ton ex",
|
||||
"Te faire un CV avec WordArt 1997",
|
||||
"T’apprendre à tricoter une écharpe en mohair",
|
||||
"Te donner la météo précise sur Mars",
|
||||
"Te révéler la vérité sur le Père Noël",
|
||||
"Décliner tes invitations LinkedIn par insultes",
|
||||
"Te désinscrire de TikTok pendant ton sommeil",
|
||||
"Te filer un cours de salsa cubaine en visio",
|
||||
"Voler la TV 4K de ton voisin silencieusement",
|
||||
"Réparer ton aspirateur qui refuse de démarrer",
|
||||
"T’organiser un dîner romantique avec ton boulanger",
|
||||
"Cirer ton parquet flottant à la main",
|
||||
"Faire ta lessive de noirs un mardi 13",
|
||||
"Te commander une pizza 4 fromages à 2h du mat",
|
||||
"T’aider à craquer le code de Vinci",
|
||||
"T’apprendre à siffler en avalant",
|
||||
"Te coiffer en hérisson sans gel",
|
||||
"Te répondre à ton mail RH compliqué",
|
||||
"Te trouver l’âme sœur sur Vinted",
|
||||
"T’expliquer la blockchain à ta grand-mère",
|
||||
"Tatouer « VIP » sur ton avant-bras pendant ta sieste",
|
||||
"T’apprendre à parler aux chats couramment",
|
||||
"Te coder un site WordPress en COBOL",
|
||||
"Te faire passer ton permis bateau en piscine",
|
||||
"T’envoyer Jeff Bezos en cadeau d’anniversaire",
|
||||
"Te transformer en NFT contre ton gré",
|
||||
"T’apprendre la danse classique pendant la sieste",
|
||||
"Te révéler tous les codes du Konami",
|
||||
"Te livrer ton café en drone à 6h pile",
|
||||
"Te faire pleurer devant un sketch des Inconnus",
|
||||
"T’envoyer une lettre manuscrite en sumérien",
|
||||
"Te trouver un job de testeur de matelas chez IKEA",
|
||||
"T’apprendre le solfège en braille inversé",
|
||||
"Te faire un calendrier de l’avent thème pickles",
|
||||
"Voler ton vélo et te le rendre vendredi prochain",
|
||||
"Te tricoter un pull pour ton aspirateur"
|
||||
]
|
||||
},
|
||||
"embed": {
|
||||
"blocked": "🚫 Accès bloqué 🚫",
|
||||
"unauthorized": "Embed non autorisé",
|
||||
"message": "Ce site ne peut pas être affiché dans un embed. Veuillez visiter directement notre site officiel.",
|
||||
"goToSite": "Aller sur movix.cash"
|
||||
"goToSite": "Aller sur movix.tax"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "🔧 Nos services sont momentanément indisponibles 🔧",
|
||||
|
|
@ -5876,7 +6160,7 @@
|
|||
"whyTitle": "Pourquoi une seed de 12 mots ?",
|
||||
"whyBody": "Movix n'a ni mail ni mot de passe : ton identité est une <1>phrase de 12 mots</1> (standard <2>BIP39</2>, celui de Bitcoin). Pas de collecte de données, pas de reset de mot de passe, pas d'email de phishing. Tu es maître de ton compte.",
|
||||
"stepsTitle": "Comment créer",
|
||||
"step1": "Va sur movix.cash → Créer un compte.",
|
||||
"step1": "Va sur movix.tax → Créer un compte.",
|
||||
"step2": "Movix génère 12 mots aléatoires — affichés UNE SEULE FOIS à l'écran.",
|
||||
"step3": "Sauvegarde ces 12 mots dans un gestionnaire de mots de passe, un fichier chiffré, ou sur papier. C'est ta clé unique.",
|
||||
"step4": "Confirme en retapant les mots. Ton compte est créé, tu es connecté.",
|
||||
|
|
@ -5949,9 +6233,9 @@
|
|||
"heroSub": "Ajouter Movix à ton écran d'accueil pour un accès rapide, sans app store.",
|
||||
"introBody": "Movix est une <1>PWA</1> (Progressive Web App) : tu peux l'installer comme une vraie app depuis ton navigateur. Pas besoin du Play Store ou App Store. Marche sur iOS, Android, Windows, macOS, Linux.",
|
||||
"iosTitle": "iOS (Safari)",
|
||||
"iosBody": "Ouvre movix.cash dans Safari. Touche l'icône de partage (carré avec flèche vers le haut) → « Sur l'écran d'accueil » → Ajouter. L'icône Movix apparaît sur ton écran d'accueil, plein écran quand tu l'ouvres.",
|
||||
"iosBody": "Ouvre movix.tax dans Safari. Touche l'icône de partage (carré avec flèche vers le haut) → « Sur l'écran d'accueil » → Ajouter. L'icône Movix apparaît sur ton écran d'accueil, plein écran quand tu l'ouvres.",
|
||||
"androidTitle": "Android (Chrome)",
|
||||
"androidBody": "Ouvre movix.cash dans Chrome. Un popup « Installer l'app » apparaît en bas. Sinon, menu (⋮) → « Installer l'application » ou « Ajouter à l'écran d'accueil ».",
|
||||
"androidBody": "Ouvre movix.tax dans Chrome. Un popup « Installer l'app » apparaît en bas. Sinon, menu (⋮) → « Installer l'application » ou « Ajouter à l'écran d'accueil ».",
|
||||
"desktopTitle": "Desktop (Chrome / Edge / Brave)",
|
||||
"desktopBody": "Dans la barre d'URL, cherche l'icône d'installation (monitor avec flèche vers le bas, à droite de l'URL). Clique dessus pour installer Movix comme une vraie app standalone.",
|
||||
"advantagesTitle": "Avantages",
|
||||
|
|
@ -5979,7 +6263,7 @@
|
|||
"whyTitle": "Pourquoi open-source ?",
|
||||
"whyBody": "Transparence d'abord : tu peux vérifier ce que ton navigateur exécute et ce que le serveur stocke. Ton compte BIP39 (12 mots seed) est géré côté client — le serveur ne connaît pas ta seed, et tu peux aller lire le code pour le prouver. Résilience ensuite : si le domaine principal tombe ou si Movix disparaît, n'importe qui peut cloner, compiler et héberger. Le projet ne dépend d'aucun service propriétaire pour tourner. Confiance enfin : beaucoup de plateformes streaming sont fermées et opaques — ici tout est auditable, et chaque commit est public.",
|
||||
"selfhostTitle": "Self-hoster sa propre instance",
|
||||
"selfhostBody": "Tu peux cloner le <1>repo</1> et faire tourner Movix localement ou sur ton propre serveur. Le README et CLAUDE.md à la racine documentent la stack, les variables d'env (~100 pour le main API), les commandes (npm run dev pour le front sur :3000, node server.js pour le main API sur :25565, python server.py pour les proxies embed sur :25569, etc.) et l'architecture des services. Prévoir MySQL, Redis, et des clés TMDB / Turnstile / OAuth Discord&Google selon ce que tu actives. L'instance que tu montes est totalement indépendante de movix.cash — tes utilisateurs, ta base, ton contrôle.",
|
||||
"selfhostBody": "Tu peux cloner le <1>repo</1> et faire tourner Movix localement ou sur ton propre serveur. Le README et CLAUDE.md à la racine documentent la stack, les variables d'env (~100 pour le main API), les commandes (npm run dev pour le front sur :3000, node server.js pour le main API sur :25565, python server.py pour les proxies embed sur :25569, etc.) et l'architecture des services. Prévoir MySQL, Redis, et des clés TMDB / Turnstile / OAuth Discord&Google selon ce que tu actives. L'instance que tu montes est totalement indépendante de movix.tax — tes utilisateurs, ta base, ton contrôle.",
|
||||
"contribTitle": "Contribuer",
|
||||
"contribBody": "Les PRs sont les bienvenues sur le <1>repo GitHub</1>. Bugs, traductions (surtout anglais — le français est la langue principale, beaucoup de strings EN sont encore à polir), nouvelles sources, améliorations UX, tuto à ajouter dans le hub : tout est utile. Pour les petits fix, PR direct. Pour les gros changements d'architecture, ouvre une issue d'abord pour qu'on en parle avant que tu passes trois jours dessus.",
|
||||
"limitsTitle": "Ce que la licence NE permet PAS",
|
||||
|
|
@ -6008,7 +6292,7 @@
|
|||
"cause1Body": "Les FAI français (Orange, SFR, Free, Bouygues) bloquent régulièrement nos domaines et nos sources de streaming sur décision <1>ARCOM</1>. C'est le cas 8 fois sur 10.",
|
||||
"cause1Cta": "Comment contourner",
|
||||
"cause2Title": "Adblocker trop agressif",
|
||||
"cause2Body": "Certains adblockers coupent les scripts d'extraction ou bloquent des domaines de sources. Essaie de whitelist movix.cash et désactive temporairement pour tester. On recommande Brave + uBlock Origin, le combo le plus stable sur Movix.",
|
||||
"cause2Body": "Certains adblockers coupent les scripts d'extraction ou bloquent des domaines de sources. Essaie de whitelist movix.tax et désactive temporairement pour tester. On recommande Brave + uBlock Origin, le combo le plus stable sur Movix.",
|
||||
"cause2CtaBrave": "Télécharger Brave",
|
||||
"cause2CtaUblock": "Installer uBlock Origin",
|
||||
"cause3Title": "Domaine Movix injoignable",
|
||||
|
|
@ -6143,11 +6427,11 @@
|
|||
"heroSub": "Comment Movix te redirige vers un domaine alive quand le principal est bloqué, et où trouver la liste officielle.",
|
||||
"introBody": "Les FAI français (Orange, SFR, Free, Bouygues) bloquent régulièrement les domaines Movix sur décision <1>ARCOM</1>. Pour éviter que tu perdes l'accès à chaque blocage, Movix installe un service worker dans ton navigateur qui détecte l'échec réseau et te redirige automatiquement vers un miroir encore alive — sans que tu aies besoin de connaître les URLs à la main.",
|
||||
"howTitle": "Comment la redirection marche",
|
||||
"howBody": "Quand tu visites movix.cash, un service worker (un petit bout de code silencieux) se met en place dans ton navigateur. Si un jour movix.cash devient injoignable (timeout 3 secondes ou erreur DNS), le service worker va chercher la liste des miroirs alive sur une source externe non-bloquée (rentry.co/movix, un service de pastebin hébergé hors de France), et te redirige vers le premier miroir disponible. Transparent, instantané, sans config.",
|
||||
"howBody": "Quand tu visites movix.tax, un service worker (un petit bout de code silencieux) se met en place dans ton navigateur. Si un jour movix.tax devient injoignable (timeout 3 secondes ou erreur DNS), le service worker va chercher la liste des miroirs alive sur une source externe non-bloquée (rentry.co/movix, un service de pastebin hébergé hors de France), et te redirige vers le premier miroir disponible. Transparent, instantané, sans config.",
|
||||
"officialListTitle": "Où voir la liste officielle",
|
||||
"officialListBody": "Deux endroits publient la liste canonique des miroirs alive : <1>movix.health</1> (statut des domaines avec indicateur up/down en temps réel) et <2>rentry.co/movix</2> (liste brute, éditée par l'équipe Movix, toujours à jour). Si tu doutes d'une URL qui prétend être Movix, vérifie-la sur un de ces deux endroits — tout ce qui n'y figure PAS est un faux (domaines sacrificiels pour tromper les bots anti-piratage ou tentatives de phishing).",
|
||||
"newDeviceTitle": "Nouveau téléphone / nouveau navigateur — la redirection marche pas",
|
||||
"newDeviceBody": "Le service worker ne peut te rediriger QUE s'il a été installé au moins une fois avant le blocage. Sur un appareil neuf qui n'a jamais visité movix.cash, si le domaine est déjà bloqué par ton FAI, tu arriveras sur une page d'erreur du FAI sans aucune redirection. Solution : ouvre movix.health ou rentry.co/movix depuis n'importe quel navigateur (ces pages sont hébergées hors bloc FAI), récupère un miroir alive, et visite-le. Ou rejoins <1>Telegram @movix_site</1> où la liste est toujours à jour.",
|
||||
"newDeviceBody": "Le service worker ne peut te rediriger QUE s'il a été installé au moins une fois avant le blocage. Sur un appareil neuf qui n'a jamais visité movix.tax, si le domaine est déjà bloqué par ton FAI, tu arriveras sur une page d'erreur du FAI sans aucune redirection. Solution : ouvre movix.health ou rentry.co/movix depuis n'importe quel navigateur (ces pages sont hébergées hors bloc FAI), récupère un miroir alive, et visite-le. Ou rejoins <1>Telegram @movix_site</1> où la liste est toujours à jour.",
|
||||
"reliabilityTitle": "Fréquence des changements",
|
||||
"reliabilityBody": "Un domaine peut tomber du jour au lendemain (nouvelle décision ARCOM) ou rester stable des mois. L'équipe tourne plusieurs domaines en parallèle — quand un tombe, un autre prend le relais automatiquement. La liste sur rentry.co est mise à jour dans la journée qui suit un changement.",
|
||||
"limitsTitle": "Limites",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import axios from 'axios'
|
|||
import { api } from './services/api'
|
||||
import { registerBlockDetection } from './services/blockDetection'
|
||||
import './index.css'
|
||||
import './styles/light-mode.css'
|
||||
|
||||
type MovixConsoleWarningWindow = Window & {
|
||||
__movixConsoleSafetyWarningStarted?: boolean;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { PrefetchLink as Link } from '@/routing/PrefetchLink';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight, Crown, ExternalLink, List, ShieldCheck, UserRound, UserRoundCog } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowRight, Bell, BellPlus, BookmarkMinus, BookmarkPlus, ChevronDown, Crown, ExternalLink, Eye, EyeOff, FilePenLine, FolderPlus, FolderX, Heart, HeartHandshake, HeartOff, History, Library, List, ListChecks, ListMinus, ListPlus, PlayCircle, ShieldCheck, Star, StarOff, UserRound, UserRoundCog } from 'lucide-react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { discordAuth } from '../services/discordAuth';
|
||||
import { googleAuth } from '../services/googleAuth';
|
||||
|
|
@ -21,6 +21,7 @@ interface OAuthPreviewResponse {
|
|||
description?: string | null;
|
||||
homepageUrl?: string | null;
|
||||
logoUrl?: string | null;
|
||||
iconUrl?: string | null;
|
||||
publicClient: boolean;
|
||||
requirePkce: boolean;
|
||||
allowedScopes: string[];
|
||||
|
|
@ -108,6 +109,201 @@ function formatTokenLifetime(ms: number, t: (key: string, options?: Record<strin
|
|||
return t('oauthAuthorize.tokenLifetimeHours', { count: hours });
|
||||
}
|
||||
|
||||
// ─── Carte humoristique : ce que l'app NE demande PAS ─────────────────────
|
||||
// Affichée juste après la vraie liste de permissions. Pour rappeler aux gens
|
||||
// qu'ils donnent uniquement un accès limité — pas tous les droits sur leur
|
||||
// vie. Les strings vivent dans les fichiers i18n via la clé
|
||||
// `oauthAuthorize.fakeNotRequested` (array). Le fallback ci-dessous sert si
|
||||
// le bundle de traduction n'a pas chargé.
|
||||
const FAKE_NOT_REQUESTED_FALLBACK: string[] = [
|
||||
'Hacker la NASA depuis ton frigo connecté',
|
||||
'Te révéler les vrais codes de la Matrice',
|
||||
'T\'expliquer le sens de la vie (spoiler : 42)',
|
||||
'Te dire qui gagnera la Coupe du Monde 2034',
|
||||
'Te dévoiler l\'identité réelle de Satoshi Nakamoto',
|
||||
'Voler ton chat Pamplemousse à 3h du matin',
|
||||
'Repasser ton chien à la vapeur',
|
||||
'Dompter un lion adulte dans ton salon',
|
||||
'Te faire pousser des dreadlocks en 48h',
|
||||
'Te faire passer pour le Pape François',
|
||||
'Réparer le télescope Hubble par WhatsApp',
|
||||
'T\'envoyer en orbite basse sans casque',
|
||||
'Convertir ton grille-pain en mineur de bitcoin',
|
||||
'T\'apprendre l\'allemand en 12h chrono',
|
||||
'Te masser les épaules pendant ton Zoom RH',
|
||||
'Convaincre ton ex que c\'était sa faute',
|
||||
'Bloquer ta belle-mère sur Facebook ET LinkedIn',
|
||||
'Te désinscrire de tes 47 newsletters dormantes',
|
||||
'Cacher ta télécommande dans le frigo',
|
||||
'Reprogrammer ta machine à laver en mandarin',
|
||||
'Cuire ton riz pendant exactement 17 minutes',
|
||||
'Te chanter La Traviata sous la douche',
|
||||
'T\'inscrire à Tinder en ton absence',
|
||||
'Swiper à droite sur tous les profils de chats',
|
||||
'Te faire perdre 5 kg en 2 jours (pas légal)',
|
||||
'Te trouver un appart à Paris pour 400€/mois',
|
||||
'Te calculer tes impôts en pré-vision 2030',
|
||||
'Crier "ALEXAAA" dans tes oreilles à 4h du mat',
|
||||
'Te commander 200 cure-dents sur Amazon',
|
||||
'Voler les Ferrero Rocher cachés dans ton placard',
|
||||
'Te chanter une berceuse en klingon',
|
||||
'T\'expliquer pourquoi tes AirPods disparaissent',
|
||||
'Régler ton réveil à 3h33 pile chaque nuit',
|
||||
'Repeindre ton plafond en rose flashy à 4h du mat',
|
||||
'Booter Windows XP sur ton iPhone',
|
||||
'Installer Internet Explorer 6 par nostalgie',
|
||||
'Mettre à jour Adobe Flash Player une dernière fois',
|
||||
'Désinstaller McAfee qui spam depuis 2014',
|
||||
'Cracker le WiFi du voisin (« Livebox-3F2A »)',
|
||||
'Coller 12 stickers Hello Kitty sur ton frigo',
|
||||
'Te faire devenir VIP gratis (jamais. JAMAIS.)',
|
||||
'Servir un mojito à ton chat chaque vendredi',
|
||||
'Te faire les ongles en gel pendant que tu dors',
|
||||
'Couper tes cheveux à la tondeuse pour chien',
|
||||
'Choisir ton fond d\'écran à ton insu (un cactus)',
|
||||
'Envoyer un sms passif-agressif à ton ex',
|
||||
'Te faire un CV avec WordArt 1997',
|
||||
'T\'apprendre à tricoter une écharpe en mohair',
|
||||
'Te donner la météo précise sur Mars',
|
||||
'Te révéler la vérité sur le Père Noël',
|
||||
'Décliner tes invitations LinkedIn par insultes',
|
||||
'Te désinscrire de TikTok pendant ton sommeil',
|
||||
'Te filer un cours de salsa cubaine en visio',
|
||||
'Voler la TV 4K de ton voisin silencieusement',
|
||||
'Réparer ton aspirateur qui refuse de démarrer',
|
||||
'T\'organiser un dîner romantique avec ton boulanger',
|
||||
'Cirer ton parquet flottant à la main',
|
||||
'Faire ta lessive de noirs un mardi 13',
|
||||
'Te commander une pizza 4 fromages à 2h du mat',
|
||||
'T\'aider à craquer le code de Vinci',
|
||||
'T\'apprendre à siffler en avalant',
|
||||
'Te coiffer en hérisson sans gel',
|
||||
'Te répondre à ton mail RH compliqué',
|
||||
'Te trouver l\'âme sœur sur Vinted',
|
||||
'T\'expliquer la blockchain à ta grand-mère',
|
||||
'Tatouer "VIP" sur ton avant-bras pendant ta sieste',
|
||||
'T\'apprendre à parler aux chats couramment',
|
||||
'Te coder un site WordPress en COBOL',
|
||||
'Te faire passer ton permis bateau en piscine',
|
||||
'T\'envoyer Jeff Bezos en cadeau d\'anniversaire',
|
||||
'Te transformer en NFT contre ton gré',
|
||||
'T\'apprendre la danse classique pendant la sieste',
|
||||
'Te révéler tous les codes du Konami',
|
||||
'Te livrer ton café en drone à 6h pile',
|
||||
'Te faire pleurer devant un sketch des Inconnus',
|
||||
'T\'envoyer une lettre manuscrite en sumérien',
|
||||
'Te trouver un job de testeur de matelas chez IKEA',
|
||||
'T\'apprendre le solfège en braille inversé',
|
||||
'Te faire un calendrier de l\'avent thème pickles',
|
||||
'Voler ton vélo et te le rendre vendredi prochain',
|
||||
'Te tricoter un pull pour ton aspirateur',
|
||||
];
|
||||
|
||||
const FakePermissionsTeasingCard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
// Charge la liste traduite via returnObjects (i18next), avec fallback en
|
||||
// dur si la clé n'existe pas (ex : bundle de traduction incomplet).
|
||||
const fakeListRaw = t('oauthAuthorize.fakeNotRequested', { returnObjects: true });
|
||||
const fakeList = Array.isArray(fakeListRaw) && fakeListRaw.length > 0
|
||||
? (fakeListRaw as string[])
|
||||
: FAKE_NOT_REQUESTED_FALLBACK;
|
||||
// useMemo sur la longueur — évite que les changements de référence du
|
||||
// tableau retourné par t() repick une nouvelle ligne à chaque re-render.
|
||||
const randomIdx = useMemo(
|
||||
() => Math.floor(Math.random() * fakeList.length),
|
||||
[fakeList.length],
|
||||
);
|
||||
const randomFake = fakeList[randomIdx] ?? '';
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||
<p className="text-[0.65rem] uppercase tracking-[0.25em] text-gray-400">
|
||||
🚫 {t('oauthAuthorize.fakeNotRequestedTitle', 'Ce que Movix ne demande pas')}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2.5 rounded-lg border border-white/[0.06] bg-white/[0.02] px-2.5 py-2">
|
||||
<span className="shrink-0 text-red-400/70 leading-none">✗</span>
|
||||
<p className="min-w-0 flex-1 text-sm text-gray-300 leading-snug">{randomFake}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Accordéon des permissions demandées ────────────────────────────────
|
||||
// Fermé par défaut (les utilisateurs voient déjà le nombre via le badge),
|
||||
// ouvert au clic. Animation height + opacity via framer-motion.
|
||||
interface PermissionsAccordionProps {
|
||||
requestedScopes: {
|
||||
scope: string;
|
||||
icon: typeof UserRound;
|
||||
title: string;
|
||||
description: string;
|
||||
}[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
const PermissionsAccordion: React.FC<PermissionsAccordionProps> = ({ requestedScopes, label }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-black/20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left transition-colors hover:bg-white/[0.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40 rounded-xl"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[0.65rem] uppercase tracking-[0.25em] text-gray-400">
|
||||
{label}
|
||||
</p>
|
||||
<span className="rounded-md border border-white/10 bg-white/5 px-1.5 py-0.5 text-[0.6rem] font-medium text-gray-300">
|
||||
{requestedScopes.length}
|
||||
</span>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: isOpen ? 180 : 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="text-gray-400"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</motion.div>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
key="content"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{
|
||||
height: { duration: 0.28, ease: [0.4, 0, 0.2, 1] },
|
||||
opacity: { duration: 0.18, ease: 'easeOut' },
|
||||
}}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="space-y-1.5 px-3 pb-3 pt-1">
|
||||
{requestedScopes.map((scopeItem) => {
|
||||
const Icon = scopeItem.icon;
|
||||
return (
|
||||
<div
|
||||
key={scopeItem.scope}
|
||||
className="flex items-center gap-2.5 rounded-lg border border-white/[0.06] bg-white/[0.02] px-2.5 py-2"
|
||||
>
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 p-1.5 text-red-200">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<p className="min-w-0 truncate text-sm font-medium text-white">
|
||||
{scopeItem.title}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OAuthAuthorizePage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
|
@ -181,6 +377,39 @@ const OAuthAuthorizePage: React.FC = () => {
|
|||
'profile.manage': { icon: UserRoundCog, titleKey: 'oauthAuthorize.scopes.profileManage.title', descKey: 'oauthAuthorize.scopes.profileManage.description' },
|
||||
'vip.read': { icon: ShieldCheck, titleKey: 'oauthAuthorize.scopes.vipRead.title', descKey: 'oauthAuthorize.scopes.vipRead.description' },
|
||||
'vip.manage': { icon: Crown, titleKey: 'oauthAuthorize.scopes.vipManage.title', descKey: 'oauthAuthorize.scopes.vipManage.description' },
|
||||
// Favoris
|
||||
'favorites.read': { icon: Heart, titleKey: 'oauthAuthorize.scopes.favoritesRead.title', descKey: 'oauthAuthorize.scopes.favoritesRead.description' },
|
||||
'favorites.add': { icon: HeartHandshake, titleKey: 'oauthAuthorize.scopes.favoritesAdd.title', descKey: 'oauthAuthorize.scopes.favoritesAdd.description' },
|
||||
'favorites.remove': { icon: HeartOff, titleKey: 'oauthAuthorize.scopes.favoritesRemove.title', descKey: 'oauthAuthorize.scopes.favoritesRemove.description' },
|
||||
// Listes personnalisées
|
||||
'lists.read': { icon: Library, titleKey: 'oauthAuthorize.scopes.listsRead.title', descKey: 'oauthAuthorize.scopes.listsRead.description' },
|
||||
'lists.create': { icon: FolderPlus, titleKey: 'oauthAuthorize.scopes.listsCreate.title', descKey: 'oauthAuthorize.scopes.listsCreate.description' },
|
||||
'lists.rename': { icon: FilePenLine, titleKey: 'oauthAuthorize.scopes.listsRename.title', descKey: 'oauthAuthorize.scopes.listsRename.description' },
|
||||
'lists.delete': { icon: FolderX, titleKey: 'oauthAuthorize.scopes.listsDelete.title', descKey: 'oauthAuthorize.scopes.listsDelete.description' },
|
||||
'lists.add-item': { icon: ListPlus, titleKey: 'oauthAuthorize.scopes.listsAddItem.title', descKey: 'oauthAuthorize.scopes.listsAddItem.description' },
|
||||
'lists.remove-item': { icon: ListMinus, titleKey: 'oauthAuthorize.scopes.listsRemoveItem.title', descKey: 'oauthAuthorize.scopes.listsRemoveItem.description' },
|
||||
// Watchlist
|
||||
'watchlist.read': { icon: ListChecks, titleKey: 'oauthAuthorize.scopes.watchlistRead.title', descKey: 'oauthAuthorize.scopes.watchlistRead.description' },
|
||||
'watchlist.add': { icon: BookmarkPlus, titleKey: 'oauthAuthorize.scopes.watchlistAdd.title', descKey: 'oauthAuthorize.scopes.watchlistAdd.description' },
|
||||
'watchlist.remove': { icon: BookmarkMinus, titleKey: 'oauthAuthorize.scopes.watchlistRemove.title', descKey: 'oauthAuthorize.scopes.watchlistRemove.description' },
|
||||
// Historique
|
||||
'history.read': { icon: History, titleKey: 'oauthAuthorize.scopes.historyRead.title', descKey: 'oauthAuthorize.scopes.historyRead.description' },
|
||||
'history.add': { icon: Eye, titleKey: 'oauthAuthorize.scopes.historyAdd.title', descKey: 'oauthAuthorize.scopes.historyAdd.description' },
|
||||
'history.remove': { icon: EyeOff, titleKey: 'oauthAuthorize.scopes.historyRemove.title', descKey: 'oauthAuthorize.scopes.historyRemove.description' },
|
||||
// Continue watching
|
||||
'continue-watching.read': { icon: PlayCircle, titleKey: 'oauthAuthorize.scopes.continueWatchingRead.title', descKey: 'oauthAuthorize.scopes.continueWatchingRead.description' },
|
||||
// Alertes
|
||||
'alerts.read': { icon: Bell, titleKey: 'oauthAuthorize.scopes.alertsRead.title', descKey: 'oauthAuthorize.scopes.alertsRead.description' },
|
||||
'alerts.manage': { icon: BellPlus, titleKey: 'oauthAuthorize.scopes.alertsManage.title', descKey: 'oauthAuthorize.scopes.alertsManage.description' },
|
||||
// Ratings (notes personnelles)
|
||||
'ratings.read': { icon: Star, titleKey: 'oauthAuthorize.scopes.ratingsRead.title', descKey: 'oauthAuthorize.scopes.ratingsRead.description' },
|
||||
'ratings.manage': { icon: StarOff, titleKey: 'oauthAuthorize.scopes.ratingsManage.title', descKey: 'oauthAuthorize.scopes.ratingsManage.description' },
|
||||
// Note : `comments.read`, `wishboard.read`, `shared-lists.read`,
|
||||
// `live-tv.read`, `vip-invoices.read` ont été retirés car les routes
|
||||
// backend correspondantes sont soit publiques (top10, wishboard,
|
||||
// shared-lists), soit utilisent un autre scope (vip.manage pour les
|
||||
// invoices, x-access-key pour live TV). Les outils MCP marchent toujours
|
||||
// — ils n'avaient juste pas besoin de scope OAuth dédié.
|
||||
};
|
||||
|
||||
return (preview?.request.scopes || []).map((scope) => {
|
||||
|
|
@ -341,11 +570,18 @@ const OAuthAuthorizePage: React.FC = () => {
|
|||
<div className="rounded-2xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-white/5">
|
||||
{preview.client.logoUrl ? (
|
||||
<img src={preview.client.logoUrl} alt={preview.client.clientName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<ShieldCheck className="h-5 w-5 text-red-300" />
|
||||
)}
|
||||
{(() => {
|
||||
// iconUrl est servie par l'API (`/oauth-icons/...`) — préfixée par API_URL.
|
||||
// logoUrl peut être absolue (legacy) → on l'utilise telle quelle si présente.
|
||||
const iconSrc = preview.client.iconUrl
|
||||
? `${API_URL}${preview.client.iconUrl}`
|
||||
: preview.client.logoUrl || null;
|
||||
return iconSrc ? (
|
||||
<img src={iconSrc} alt={preview.client.clientName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<ShieldCheck className="h-5 w-5 text-red-300" />
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[0.65rem] uppercase tracking-[0.25em] text-gray-400">
|
||||
|
|
@ -393,26 +629,10 @@ const OAuthAuthorizePage: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||
<p className="text-[0.65rem] uppercase tracking-[0.25em] text-gray-400">
|
||||
{t('oauthAuthorize.permissionsLabel')}
|
||||
</p>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{requestedScopes.map((scopeItem) => {
|
||||
const Icon = scopeItem.icon;
|
||||
return (
|
||||
<div key={scopeItem.scope} className="flex items-center gap-2.5 rounded-lg border border-white/[0.06] bg-white/[0.02] px-2.5 py-2">
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 p-1.5 text-red-200">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<p className="min-w-0 truncate text-sm font-medium text-white">
|
||||
{scopeItem.title}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<PermissionsAccordion requestedScopes={requestedScopes} label={t('oauthAuthorize.permissionsLabel')} />
|
||||
|
||||
<FakePermissionsTeasingCard />
|
||||
|
||||
|
||||
{authToken ? (
|
||||
<div className="rounded-xl border border-emerald-400/20 bg-emerald-500/10 px-3 py-2.5">
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
ArrowLeft, Settings, Shield, Monitor, Smartphone, Tablet,
|
||||
Copy, X, Snowflake, Activity, Trash2, Crown, Volume2,
|
||||
Database, Key, Lock, Palette, Eye, Download, Upload, Globe, AlertTriangle, History, CalendarClock, FlaskConical, Link2, MessageCircle, BellOff, Sparkles,
|
||||
Zap, RefreshCw, ChevronDown, ListOrdered
|
||||
Zap, RefreshCw, ChevronDown, ListOrdered, Gauge
|
||||
} from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
|
@ -56,6 +56,7 @@ import {
|
|||
subscribeToLastPlayerChanges,
|
||||
} from '../utils/lastPlayerPref';
|
||||
import { BgColorPickerPanel } from '../components/Settings/BgColorPickerPanel';
|
||||
import { useLightMode } from '../context/LightModeContext';
|
||||
import {
|
||||
BG_ACCENT_PRESETS,
|
||||
BG_STORAGE_KEYS,
|
||||
|
|
@ -147,6 +148,7 @@ function getNonSyncReasonTranslationKey(reason: NonSyncableStorageReason) {
|
|||
|
||||
const SECTIONS = [
|
||||
{ id: 'appearance', labelKey: 'settings.sections.appearance', icon: Palette },
|
||||
{ id: 'performance', labelKey: 'settings.sections.performance', icon: Gauge },
|
||||
{ id: 'language', labelKey: 'settings.sections.language', icon: Globe },
|
||||
{ id: 'vip', labelKey: 'settings.sections.vip', icon: Crown },
|
||||
{ id: 'sessions', labelKey: 'settings.sections.sessions', icon: Monitor },
|
||||
|
|
@ -355,6 +357,7 @@ const SettingsPage: React.FC = () => {
|
|||
const navigate = useNavigate();
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const { t, i18n } = useTranslation();
|
||||
const { lightModeSetting, setLightModeSetting, isLightMode, prefs: animPrefs, effectivePrefs: animEffectivePrefs, setPref: setAnimPref } = useLightMode();
|
||||
// Active section tracking
|
||||
const [activeSection, setActiveSection] = useState<string>(() => {
|
||||
const hash = location.hash.replace('#', '');
|
||||
|
|
@ -424,6 +427,9 @@ const SettingsPage: React.FC = () => {
|
|||
const [bgForceSquareSize, setBgForceSquareSize] = useState<boolean>(() => {
|
||||
return localStorage.getItem(BG_STORAGE_KEYS.forceSquareSize) === '1';
|
||||
});
|
||||
const [bgHaloEnabled, setBgHaloEnabled] = useState<boolean>(() => {
|
||||
return localStorage.getItem(BG_STORAGE_KEYS.haloEnabled) !== '0';
|
||||
});
|
||||
|
||||
const bgAccentRgb = bgAccent === 'custom'
|
||||
? hexToRgbString(bgAccentCustomHex)
|
||||
|
|
@ -465,6 +471,13 @@ const SettingsPage: React.FC = () => {
|
|||
notifyBgPrefsChanged();
|
||||
};
|
||||
|
||||
const handleBgHaloToggle = () => {
|
||||
const next = !bgHaloEnabled;
|
||||
setBgHaloEnabled(next);
|
||||
localStorage.setItem(BG_STORAGE_KEYS.haloEnabled, next ? '1' : '0');
|
||||
notifyBgPrefsChanged();
|
||||
};
|
||||
|
||||
const handleBgForceSquareSizeToggle = () => {
|
||||
const next = !bgForceSquareSize;
|
||||
setBgForceSquareSize(next);
|
||||
|
|
@ -1776,12 +1789,12 @@ const SettingsPage: React.FC = () => {
|
|||
{t('settings.smoothScrollIntensityDesc', 'Contrôle l\'inertie du défilement. Plus fluide = plus de glisse mais réponse moins immédiate. Choisis « Désactivé » pour repasser au scroll natif (recommandé sur configs lentes).')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{([
|
||||
{ id: 'off', labelKey: 'settings.smoothScrollIntensity.off', fallback: 'Désactivé' },
|
||||
{ id: 'standard', labelKey: 'settings.smoothScrollIntensity.standard', fallback: 'Standard' },
|
||||
{ id: 'fluid', labelKey: 'settings.smoothScrollIntensity.fluid', fallback: 'Fluide' },
|
||||
{ id: 'ultra', labelKey: 'settings.smoothScrollIntensity.ultra', fallback: 'Ultra fluide' },
|
||||
{ id: 'off', labelKey: 'settings.smoothScrollIntensity.off', descKey: 'settings.smoothScrollIntensity.offDesc', fallbackLabel: 'Désactivé', fallbackDesc: 'Scroll natif du navigateur' },
|
||||
{ id: 'standard', labelKey: 'settings.smoothScrollIntensity.standard', descKey: 'settings.smoothScrollIntensity.standardDesc', fallbackLabel: 'Standard', fallbackDesc: 'Inertie modérée, équilibrée' },
|
||||
{ id: 'fluid', labelKey: 'settings.smoothScrollIntensity.fluid', descKey: 'settings.smoothScrollIntensity.fluidDesc', fallbackLabel: 'Fluide', fallbackDesc: 'Glisse longue, défilement souple' },
|
||||
{ id: 'ultra', labelKey: 'settings.smoothScrollIntensity.ultra', descKey: 'settings.smoothScrollIntensity.ultraDesc', fallbackLabel: 'Ultra fluide', fallbackDesc: 'Inertie maximale, effet premium' },
|
||||
] as const).map((opt) => {
|
||||
const isOff = opt.id === 'off';
|
||||
const active = isOff
|
||||
|
|
@ -1799,13 +1812,16 @@ const SettingsPage: React.FC = () => {
|
|||
handleSmoothScrollIntensityChange(opt.id);
|
||||
}
|
||||
}}
|
||||
className={`text-xs font-medium rounded-lg px-3 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 ${
|
||||
className={`flex-1 min-w-[100px] p-3 rounded-xl text-left transition-colors border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 ${
|
||||
active
|
||||
? (isOff ? 'bg-gray-600 text-white shadow-inner' : 'bg-indigo-500 text-white shadow-inner')
|
||||
: 'bg-gray-700/40 text-gray-300 hover:bg-gray-700/70'
|
||||
? (isOff
|
||||
? 'bg-gray-600/15 border-gray-500/40 text-white'
|
||||
: 'bg-indigo-600/10 border-indigo-500/30 text-white')
|
||||
: 'bg-gray-700/20 border-gray-700/40 text-gray-400 hover:bg-gray-700/40 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey, opt.fallback)}
|
||||
<div className="text-xs font-semibold">{t(opt.labelKey, opt.fallbackLabel)}</div>
|
||||
<div className="text-[10px] text-gray-500 mt-0.5">{t(opt.descKey, opt.fallbackDesc)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1851,25 +1867,6 @@ const SettingsPage: React.FC = () => {
|
|||
{renderToggle(commentsSectionHidden, handleCommentsSectionToggle, 'blue')}
|
||||
</motion.div>
|
||||
|
||||
{/* Bandeau hero accueil */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1438 }}
|
||||
className="flex items-center justify-between p-4 bg-gray-800/30 rounded-xl border border-gray-700/40 hover:border-gray-600/50 transition-colors group"
|
||||
>
|
||||
<div className="flex-1 mr-4">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Sparkles className="w-3.5 h-3.5 text-purple-400" />
|
||||
<h4 className="font-medium text-white text-sm">{t('settings.hideHero')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
{t('settings.hideHeroDesc')}
|
||||
</p>
|
||||
</div>
|
||||
{renderToggle(heroHidden, handleHeroToggle)}
|
||||
</motion.div>
|
||||
|
||||
{/* Effet neige */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
|
|
@ -1925,6 +1922,22 @@ const SettingsPage: React.FC = () => {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* ─── Toggle Halo lumineux ─────────────────────────────
|
||||
Désactive le dégradé radial qui suit le curseur. Visible
|
||||
uniquement dans les modes "combiné" et "classique"
|
||||
(le mode "interactif" n'a pas de halo). */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-700/40 flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h5 className="font-medium text-white text-sm mb-0.5">
|
||||
{t('settings.bgHalo')}
|
||||
</h5>
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
{t('settings.bgHaloDesc')}
|
||||
</p>
|
||||
</div>
|
||||
{renderToggle(bgHaloEnabled, handleBgHaloToggle, 'indigo')}
|
||||
</div>
|
||||
|
||||
{/* ─── Couleur accent du fond ─────────────────────────── */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-700/40">
|
||||
<h5 className="font-medium text-white text-sm mb-0.5">
|
||||
|
|
@ -2018,25 +2031,26 @@ const SettingsPage: React.FC = () => {
|
|||
<p className="text-xs text-gray-500 leading-relaxed mb-3">
|
||||
{t('settings.bgSquareSizeDesc', 'Densité de la grille : plus petit = plus de carrés.')}
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{([
|
||||
{ value: 32, label: t('settings.bgSizeSmall', 'Dense') },
|
||||
{ value: 48, label: t('settings.bgSizeMedium', 'Moyen') },
|
||||
{ value: 64, label: t('settings.bgSizeLarge', 'Aéré') },
|
||||
{ value: 80, label: t('settings.bgSizeXLarge', 'Très aéré') },
|
||||
{ value: 32, labelKey: 'settings.bgSizeSmall', descKey: 'settings.bgSizeSmallDesc', fallbackLabel: 'Dense', fallbackDesc: 'Grille très dense, beaucoup de carrés' },
|
||||
{ value: 48, labelKey: 'settings.bgSizeMedium', descKey: 'settings.bgSizeMediumDesc', fallbackLabel: 'Moyen', fallbackDesc: 'Densité équilibrée, par défaut' },
|
||||
{ value: 64, labelKey: 'settings.bgSizeLarge', descKey: 'settings.bgSizeLargeDesc', fallbackLabel: 'Aéré', fallbackDesc: 'Carrés plus larges, moins denses' },
|
||||
{ value: 80, labelKey: 'settings.bgSizeXLarge', descKey: 'settings.bgSizeXLargeDesc', fallbackLabel: 'Très aéré', fallbackDesc: 'Carrés très larges, grille minimale' },
|
||||
] as const).map((opt) => {
|
||||
const active = bgSquareSize === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleBgSquareSizeChange(opt.value)}
|
||||
className={`text-xs font-medium rounded-lg px-3 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 ${
|
||||
className={`flex-1 min-w-[100px] p-3 rounded-xl text-left transition-colors border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 ${
|
||||
active
|
||||
? 'bg-indigo-500 text-white shadow-inner'
|
||||
: 'bg-gray-700/40 text-gray-300 hover:bg-gray-700/70'
|
||||
? 'bg-indigo-600/10 border-indigo-500/30 text-white'
|
||||
: 'bg-gray-700/20 border-gray-700/40 text-gray-400 hover:bg-gray-700/40 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
<div className="text-xs font-semibold">{t(opt.labelKey, opt.fallbackLabel)}</div>
|
||||
<div className="text-[10px] text-gray-500 mt-0.5">{t(opt.descKey, opt.fallbackDesc)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -2164,6 +2178,167 @@ const SettingsPage: React.FC = () => {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════ */}
|
||||
{/* SECTION: Performance */}
|
||||
{/* ════════════════════════════════════════════════════════ */}
|
||||
<section id="performance" className="scroll-mt-24">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-br from-emerald-600/20 to-teal-600/20 border border-emerald-500/20">
|
||||
<Gauge className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">{t('settings.sections.performance')}</h2>
|
||||
<p className="text-sm text-gray-500">{t('settings.performanceDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle "Masquer le bandeau d'accueil" — déplacé d'Apparence
|
||||
vers Performance car couper le hero supprime images lourdes,
|
||||
rotation auto et fetchs TMDB en plus de l'animation. */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
className="flex items-center justify-between p-4 bg-gray-800/30 rounded-xl border border-gray-700/40 hover:border-gray-600/50 transition-colors group mb-3"
|
||||
>
|
||||
<div className="flex-1 mr-4">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Sparkles className="w-3.5 h-3.5 text-purple-400" />
|
||||
<h4 className="font-medium text-white text-sm">{t('settings.hideHero')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
{t('settings.hideHeroDesc')}
|
||||
</p>
|
||||
</div>
|
||||
{renderToggle(heroHidden, handleHeroToggle)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="p-4 bg-gray-800/30 rounded-xl border border-gray-700/40 hover:border-gray-600/50 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<h4 className="font-medium text-white text-sm">{t('settings.lightMode')}</h4>
|
||||
{lightModeSetting === 'auto' && (
|
||||
<span className="text-[10px] uppercase tracking-wider font-semibold px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-300 border border-emerald-500/20">
|
||||
{isLightMode ? t('settings.lightModeAutoOn') : t('settings.lightModeAutoOff')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
{t('settings.lightModeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{([
|
||||
{ id: 'auto', labelKey: 'settings.lightModeAuto', descKey: 'settings.lightModeAutoDesc', fallbackLabel: 'Auto', fallbackDesc: 'Détecte automatiquement' },
|
||||
{ id: 'on', labelKey: 'settings.lightModeOn', descKey: 'settings.lightModeOnDesc', fallbackLabel: 'Activé', fallbackDesc: 'Toujours actif' },
|
||||
{ id: 'off', labelKey: 'settings.lightModeOff', descKey: 'settings.lightModeOffDesc', fallbackLabel: 'Désactivé', fallbackDesc: 'Tous les effets' },
|
||||
] as const).map((opt) => {
|
||||
const active = lightModeSetting === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setLightModeSetting(opt.id)}
|
||||
className={`flex-1 min-w-[100px] p-3 rounded-xl text-left transition-colors border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 ${
|
||||
active
|
||||
? 'bg-emerald-600/10 border-emerald-500/30 text-white'
|
||||
: 'bg-gray-700/20 border-gray-700/40 text-gray-400 hover:bg-gray-700/40 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-semibold">{t(opt.labelKey, opt.fallbackLabel)}</div>
|
||||
<div className="text-[10px] text-gray-500 mt-0.5">{t(opt.descKey, opt.fallbackDesc)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-600 leading-relaxed">
|
||||
{t('settings.lightModeAutoHint')}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Réglages granulaires d'animations.
|
||||
Chaque toggle pose son propre attribut `data-no-*` sur <html>
|
||||
via LightModeContext. Quand Mode léger est actif, toutes les
|
||||
catégories sont forcées "désactivées" (effectivePrefs), mais
|
||||
l'état persistant `prefs` est conservé → quand l'utilisateur
|
||||
coupe Mode léger, il retrouve ses choix granulaires. */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="mt-3 p-4 bg-gray-800/20 rounded-xl border border-gray-700/30"
|
||||
>
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-medium text-white mb-0.5">
|
||||
{t('settings.animPrefsTitle')}
|
||||
</h4>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed">
|
||||
{isLightMode
|
||||
? t('settings.animPrefsHintLightModeOn')
|
||||
: t('settings.animPrefsHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{([
|
||||
{ key: 'bgAnimations', titleKey: 'settings.animBgTitle', descKey: 'settings.animBgDesc' },
|
||||
{ key: 'loadingAnimations', titleKey: 'settings.animLoadingTitle', descKey: 'settings.animLoadingDesc' },
|
||||
{ key: 'carouselAutoplay', titleKey: 'settings.animCarouselTitle', descKey: 'settings.animCarouselDesc' },
|
||||
{ key: 'blurEffects', titleKey: 'settings.animBlurTitle', descKey: 'settings.animBlurDesc' },
|
||||
{ key: 'transitions', titleKey: 'settings.animTransTitle', descKey: 'settings.animTransDesc' },
|
||||
] as const).map((row) => {
|
||||
const isOn = animEffectivePrefs[row.key];
|
||||
const userOn = animPrefs[row.key];
|
||||
const forcedByLightMode = isLightMode && !animEffectivePrefs[row.key];
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className={`flex items-center justify-between p-3 bg-gray-800/30 rounded-lg border border-gray-700/40 transition-colors ${
|
||||
forcedByLightMode ? 'opacity-60' : 'hover:border-gray-600/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 mr-3 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<span className="font-medium text-white text-xs">{t(row.titleKey)}</span>
|
||||
{forcedByLightMode && (
|
||||
<span className="text-[9px] uppercase tracking-wider font-semibold px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-300/80 border border-emerald-500/20">
|
||||
{t('settings.animForcedByLightMode')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed">{t(row.descKey)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
disabled={forcedByLightMode}
|
||||
onClick={() => setAnimPref(row.key, !userOn)}
|
||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 ${
|
||||
isOn ? 'bg-emerald-500' : 'bg-gray-600'
|
||||
} ${forcedByLightMode ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
aria-label={t(row.titleKey)}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||
isOn ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════ */}
|
||||
{/* SECTION: Langue */}
|
||||
{/* ════════════════════════════════════════════════════════ */}
|
||||
|
|
|
|||
|
|
@ -3211,52 +3211,90 @@ const TVDetails: React.FC = () => {
|
|||
hasProgress: false
|
||||
});
|
||||
|
||||
type ContinueWatchingTvEntry = {
|
||||
id: number;
|
||||
currentEpisode?: {
|
||||
season: number;
|
||||
episode: number;
|
||||
};
|
||||
lastAccessed?: string;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Load any existing watch progress
|
||||
if (id) {
|
||||
try {
|
||||
// First, check the continueWatching localStorage data
|
||||
const continueWatching = JSON.parse(localStorage.getItem('continueWatching') || '{"movies": [], "tv": []}');
|
||||
|
||||
if (continueWatching.tv && Array.isArray(continueWatching.tv)) {
|
||||
const showIdInt = parseInt(id);
|
||||
const tvShow = continueWatching.tv.find((show: any) => show.id === showIdInt);
|
||||
|
||||
if (tvShow && tvShow.currentEpisode) {
|
||||
// Try to get detailed progress data for this specific episode
|
||||
const progressKey = `progress_tv_${id}_s${tvShow.currentEpisode.season}_e${tvShow.currentEpisode.episode}`;
|
||||
const progressValue = localStorage.getItem(progressKey);
|
||||
let position = 0;
|
||||
let duration = 0;
|
||||
|
||||
if (progressValue) {
|
||||
try {
|
||||
const progressData = JSON.parse(progressValue);
|
||||
position = Number(progressData.position) || 0;
|
||||
duration = Number(progressData.duration) || 0;
|
||||
} catch (error) {
|
||||
console.error('Error parsing progress data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setContinueWatchingData({
|
||||
seasonNumber: tvShow.currentEpisode.season,
|
||||
episodeNumber: tvShow.currentEpisode.episode,
|
||||
position: position,
|
||||
duration: duration,
|
||||
hasProgress: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check old progress_tv_* keys for backward compatibility
|
||||
let latestTimestamp = -1;
|
||||
let latestSeason = 1;
|
||||
let latestEpisode = 1;
|
||||
let latestPosition = 0;
|
||||
let latestDuration = 0;
|
||||
|
||||
const considerCandidate = (
|
||||
seasonNumber: number,
|
||||
episodeNumber: number,
|
||||
timestampMs: number,
|
||||
position = 0,
|
||||
duration = 0
|
||||
) => {
|
||||
if (!Number.isFinite(seasonNumber) || !Number.isFinite(episodeNumber)) return;
|
||||
if (seasonNumber <= 0 || episodeNumber <= 0) return;
|
||||
if (!Number.isFinite(timestampMs)) return;
|
||||
|
||||
if (timestampMs > latestTimestamp) {
|
||||
latestTimestamp = timestampMs;
|
||||
latestSeason = seasonNumber;
|
||||
latestEpisode = episodeNumber;
|
||||
latestPosition = Number(position) || 0;
|
||||
latestDuration = Number(duration) || 0;
|
||||
}
|
||||
};
|
||||
|
||||
// First, check the continueWatching localStorage data
|
||||
const continueWatching = JSON.parse(localStorage.getItem('continueWatching') || '{"movies": [], "tv": []}') as {
|
||||
tv?: ContinueWatchingTvEntry[];
|
||||
};
|
||||
|
||||
if (continueWatching.tv && Array.isArray(continueWatching.tv)) {
|
||||
const showIdInt = parseInt(id);
|
||||
const tvShow = continueWatching.tv.find((show) => show.id === showIdInt);
|
||||
|
||||
if (tvShow && tvShow.currentEpisode) {
|
||||
const seasonFromContinue = Number(tvShow.currentEpisode.season);
|
||||
const episodeFromContinue = Number(tvShow.currentEpisode.episode);
|
||||
const continueTs = tvShow.lastAccessed ? Date.parse(tvShow.lastAccessed) : NaN;
|
||||
|
||||
// Try to get detailed progress data for this specific episode
|
||||
const progressKey = `progress_tv_${id}_s${seasonFromContinue}_e${episodeFromContinue}`;
|
||||
const progressValue = Number.isFinite(seasonFromContinue) && Number.isFinite(episodeFromContinue)
|
||||
? localStorage.getItem(progressKey)
|
||||
: null;
|
||||
let position = 0;
|
||||
let duration = 0;
|
||||
let progressTs = NaN;
|
||||
|
||||
if (progressValue) {
|
||||
try {
|
||||
const progressData = JSON.parse(progressValue);
|
||||
position = Number(progressData.position) || 0;
|
||||
duration = Number(progressData.duration) || 0;
|
||||
progressTs = progressData.timestamp ? Date.parse(progressData.timestamp) : NaN;
|
||||
} catch (error) {
|
||||
console.error('Error parsing progress data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveTimestamp = Number.isFinite(progressTs)
|
||||
? progressTs
|
||||
: Number.isFinite(continueTs)
|
||||
? continueTs
|
||||
: NaN;
|
||||
|
||||
considerCandidate(seasonFromContinue, episodeFromContinue, effectiveTimestamp, position, duration);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check all progress_tv_* keys and keep the most recent by timestamp
|
||||
const keyPrefix = `progress_tv_${id}_s`;
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
|
|
@ -3272,13 +3310,13 @@ const TVDetails: React.FC = () => {
|
|||
const progressData = JSON.parse(value);
|
||||
const ts = progressData.timestamp ? Date.parse(progressData.timestamp) : NaN;
|
||||
if (!Number.isFinite(ts)) continue;
|
||||
if (ts > latestTimestamp) {
|
||||
latestTimestamp = ts;
|
||||
latestSeason = season;
|
||||
latestEpisode = episode;
|
||||
latestPosition = Number(progressData.position) || 0;
|
||||
latestDuration = Number(progressData.duration) || 0;
|
||||
}
|
||||
considerCandidate(
|
||||
season,
|
||||
episode,
|
||||
ts,
|
||||
Number(progressData.position) || 0,
|
||||
Number(progressData.duration) || 0
|
||||
);
|
||||
} catch (_) {
|
||||
// ignore malformed entries
|
||||
}
|
||||
|
|
@ -3289,7 +3327,7 @@ const TVDetails: React.FC = () => {
|
|||
let effectiveSeason = latestSeason;
|
||||
let effectiveEpisode = latestEpisode;
|
||||
|
||||
if (availableSeasons.length > 0 && !availableSeasons.includes(effectiveSeason)) {
|
||||
if (!animeMode && availableSeasons.length > 0 && !availableSeasons.includes(effectiveSeason)) {
|
||||
effectiveSeason = defaultStartSeason;
|
||||
effectiveEpisode = 1;
|
||||
}
|
||||
|
|
@ -3316,7 +3354,7 @@ const TVDetails: React.FC = () => {
|
|||
console.error('Error parsing continue watching data:', error);
|
||||
}
|
||||
}
|
||||
}, [id, availableSeasons, defaultStartSeason]);
|
||||
}, [id, animeMode, availableSeasons, defaultStartSeason]);
|
||||
// Fonction pour continuer le visionnage
|
||||
const handleContinueWatching = () => {
|
||||
// Use the progress data if available, otherwise start from the first episode
|
||||
|
|
|
|||
|
|
@ -83,6 +83,21 @@ interface VideoSource {
|
|||
id?: string; // Unique identifier for comparison
|
||||
}
|
||||
|
||||
interface ContinueWatchingTvEntry {
|
||||
id: number;
|
||||
currentEpisode?: {
|
||||
season: number;
|
||||
episode: number;
|
||||
};
|
||||
lastAccessed?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ContinueWatchingStore {
|
||||
movies: unknown[];
|
||||
tv: ContinueWatchingTvEntry[];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculates similarity between two titles to avoid false positives in anime matching
|
||||
|
|
@ -225,6 +240,44 @@ const WatchAnime: React.FC = () => {
|
|||
// État pour suivre si c'est la première sélection automatique
|
||||
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
||||
|
||||
const updateAnimeContinueWatching = useCallback(() => {
|
||||
if (localStorage.getItem('settings_disable_history') === 'true') return;
|
||||
|
||||
const showIdInt = id ? parseInt(id) : NaN;
|
||||
const seasonNumber = Number(season);
|
||||
const episodeNumber = Number(episode);
|
||||
|
||||
if (!Number.isFinite(showIdInt) || !Number.isFinite(seasonNumber) || !Number.isFinite(episodeNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let continueWatching: ContinueWatchingStore;
|
||||
try {
|
||||
continueWatching = JSON.parse(localStorage.getItem('continueWatching') || '{"movies": [], "tv": []}') as ContinueWatchingStore;
|
||||
} catch {
|
||||
continueWatching = { movies: [], tv: [] };
|
||||
}
|
||||
|
||||
if (!Array.isArray(continueWatching.movies)) continueWatching.movies = [];
|
||||
if (!Array.isArray(continueWatching.tv)) continueWatching.tv = [];
|
||||
|
||||
const existingShow = continueWatching.tv.find((tvShow) => tvShow.id === showIdInt);
|
||||
const updatedShow = {
|
||||
...(existingShow || {}),
|
||||
id: showIdInt,
|
||||
currentEpisode: {
|
||||
season: seasonNumber,
|
||||
episode: episodeNumber
|
||||
},
|
||||
lastAccessed: new Date().toISOString()
|
||||
};
|
||||
|
||||
continueWatching.tv = continueWatching.tv.filter((tvShow) => tvShow.id !== showIdInt);
|
||||
continueWatching.tv.unshift(updatedShow);
|
||||
continueWatching.tv = continueWatching.tv.slice(0, 20);
|
||||
localStorage.setItem('continueWatching', JSON.stringify(continueWatching));
|
||||
}, [id, season, episode]);
|
||||
|
||||
// Movix Wrapped 2026 - Track anime viewing time
|
||||
useWrappedTracker({
|
||||
mode: 'viewing',
|
||||
|
|
@ -274,46 +327,6 @@ const WatchAnime: React.FC = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Add anime episode to continueWatching (if history is enabled)
|
||||
if (localStorage.getItem('settings_disable_history') !== 'true') {
|
||||
const continueWatching = JSON.parse(localStorage.getItem('continueWatching') || '{"movies": [], "tv": []}');
|
||||
|
||||
// Ensure structure exists
|
||||
if (!continueWatching.movies) continueWatching.movies = [];
|
||||
if (!continueWatching.tv) continueWatching.tv = [];
|
||||
|
||||
// Find existing TV show entry or create new one
|
||||
const showIdInt = id ? parseInt(id) : null;
|
||||
if (!showIdInt) return;
|
||||
const existingShow = continueWatching.tv.find((tvShow: any) => tvShow.id === showIdInt);
|
||||
|
||||
if (existingShow) {
|
||||
// Update existing show with current episode and last access time
|
||||
existingShow.currentEpisode = {
|
||||
season: Number(season),
|
||||
episode: Number(episode)
|
||||
};
|
||||
existingShow.lastAccessed = new Date().toISOString();
|
||||
// Move to front of array
|
||||
continueWatching.tv = continueWatching.tv.filter((tvShow: any) => tvShow.id !== showIdInt);
|
||||
continueWatching.tv.unshift(existingShow);
|
||||
} else {
|
||||
// Create new TV show entry with last access time
|
||||
const newTvEntry = {
|
||||
id: showIdInt,
|
||||
currentEpisode: {
|
||||
season: Number(season),
|
||||
episode: Number(episode)
|
||||
},
|
||||
lastAccessed: new Date().toISOString()
|
||||
};
|
||||
continueWatching.tv.unshift(newTvEntry);
|
||||
}
|
||||
|
||||
// Keep only last 20 TV shows
|
||||
continueWatching.tv = continueWatching.tv.slice(0, 20);
|
||||
localStorage.setItem('continueWatching', JSON.stringify(continueWatching));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching show details:', error);
|
||||
setError(t('watch.cannotLoadAnimeDetails'));
|
||||
|
|
@ -325,6 +338,10 @@ const WatchAnime: React.FC = () => {
|
|||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
updateAnimeContinueWatching();
|
||||
}, [updateAnimeContinueWatching]);
|
||||
|
||||
// Pas de fetch TMDB pour les détails d'épisode anime - le numérotage ne correspond pas
|
||||
|
||||
// Load anime data with special character handling
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Copy, Share2, MessageSquare, Users, X, Send, Clipboard, Check, AlertTri
|
|||
import ChangeMediaModal from '../components/ChangeMediaModal';
|
||||
import EmojiAutocomplete from '../components/EmojiAutocomplete';
|
||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
import { safeRemarkGfm } from '../utils/markdownPlugins';
|
||||
import { useSafeRemarkGfm } from '../utils/markdownPlugins';
|
||||
import remarkEmoji from 'remark-emoji';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import HLSPlayer, { HLSPlayerRef } from '../components/HLSPlayer';
|
||||
|
|
@ -130,6 +130,11 @@ interface ChatMessageItemProps {
|
|||
|
||||
const ChatMessageItem = React.memo<ChatMessageItemProps>(
|
||||
({ message, isOwnMessage, isHostMessage, isMutedSender, isCurrentUserHost, t, onToggleMute, onDeleteMessage }) => {
|
||||
const safeRemarkGfm = useSafeRemarkGfm();
|
||||
const remarkPlugins = useMemo(
|
||||
() => (safeRemarkGfm ? [safeRemarkGfm, remarkEmoji] : [remarkEmoji]),
|
||||
[safeRemarkGfm],
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col ${message.type === 'system'
|
||||
|
|
@ -179,7 +184,7 @@ const ChatMessageItem = React.memo<ChatMessageItemProps>(
|
|||
|
||||
<div className={`text-sm break-words ${message.type === 'system' ? 'italic' : ''}`}>
|
||||
{message.type === 'system' ? message.text : (
|
||||
<ReactMarkdown remarkPlugins={safeRemarkGfm ? [safeRemarkGfm, remarkEmoji] : [remarkEmoji]} components={CHAT_MD_COMPONENTS}>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={CHAT_MD_COMPONENTS}>
|
||||
{message.text}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
|
|
@ -215,6 +220,11 @@ const WatchPartyRoom: React.FC = () => {
|
|||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const locationState = location.state as LocationState || {};
|
||||
const safeRemarkGfm = useSafeRemarkGfm();
|
||||
const previewRemarkPlugins = useMemo(
|
||||
() => (safeRemarkGfm ? [safeRemarkGfm, remarkEmoji] : [remarkEmoji]),
|
||||
[safeRemarkGfm],
|
||||
);
|
||||
|
||||
// Track page visit for Movix Wrapped
|
||||
useWrappedTracker({
|
||||
|
|
@ -1860,7 +1870,7 @@ const WatchPartyRoom: React.FC = () => {
|
|||
{/* Live markdown preview (debounced 150ms — see debouncedNewMessage) */}
|
||||
{newMessage.trim() && (
|
||||
<div className="px-3 py-2 mb-1.5 rounded-lg bg-white/5 border border-white/5 text-sm max-h-20 overflow-y-auto" data-lenis-prevent>
|
||||
<ReactMarkdown remarkPlugins={safeRemarkGfm ? [safeRemarkGfm, remarkEmoji] : [remarkEmoji]} components={CHAT_MD_COMPONENTS}>
|
||||
<ReactMarkdown remarkPlugins={previewRemarkPlugins} components={CHAT_MD_COMPONENTS}>
|
||||
{debouncedNewMessage}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2798,7 +2798,7 @@ const WrappedPage: React.FC = () => {
|
|||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.36)';
|
||||
ctx.font = '600 26px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
const domainLabel = 'movix.cash';
|
||||
const domainLabel = 'movix.tax';
|
||||
ctx.fillText(domainLabel, width - 78 - ctx.measureText(domainLabel).width, footerTextY);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
|
|
|
|||
|
|
@ -1,46 +1,99 @@
|
|||
/* ─── Mode Léger : désactive TOUS les effets GPU lourds ─────────────────── */
|
||||
/* ─── Mode Léger + préférences granulaires d'animations ─────────────────────
|
||||
Chaque catégorie est pilotée par un attribut `data-*` posé sur <html> par
|
||||
LightModeContext. `data-light-mode="true"` reste un raccourci qui ajoute
|
||||
AUSSI tous les attributs `data-no-*` (calcul fait dans le provider), donc
|
||||
les sélecteurs `[data-light-mode="true"]` ci-dessous sont conservés pour
|
||||
les règles trop spécifiques pour rentrer dans une catégorie. */
|
||||
|
||||
/* Backdrop-blur — attrape toutes les variantes Tailwind (xl, lg, md, sm, arbitraires) */
|
||||
[data-light-mode="true"] [class*="backdrop-blur"] {
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
ANIMATIONS DE FOND (data-no-bg-anim)
|
||||
- canvas (DynamicBackground, particules)
|
||||
- flocons de neige
|
||||
- screensaver kenburns
|
||||
- animations décoratives "rainbow"
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
html[data-no-bg-anim] canvas {
|
||||
display: none !important;
|
||||
}
|
||||
html[data-no-bg-anim] .snow-particle {
|
||||
display: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
html[data-no-bg-anim] .screensaver-kenburns {
|
||||
animation: none !important;
|
||||
}
|
||||
html[data-no-bg-anim] [class*="rainbow"] {
|
||||
animation: none !important;
|
||||
}
|
||||
/* Halo lumineux radial du SquareBackground — coûteux (RAF sur pointermove
|
||||
+ radial-gradient). Inclus dans la catégorie "animations de fond". */
|
||||
html[data-no-bg-anim] .square-bg-halo {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
ANIMATIONS DE CHARGEMENT (data-no-loading-anim)
|
||||
- skeletons pulsants (animate-pulse)
|
||||
- spinners (animate-spin)
|
||||
- rebonds (animate-bounce, animate-ping)
|
||||
On vise les classes Tailwind utilitaires, qui sont employées partout pour
|
||||
l'état "loading".
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
html[data-no-loading-anim] .animate-pulse,
|
||||
html[data-no-loading-anim] .animate-spin,
|
||||
html[data-no-loading-anim] .animate-bounce,
|
||||
html[data-no-loading-anim] .animate-ping,
|
||||
html[data-no-loading-anim] [class*="animate-pulse"],
|
||||
html[data-no-loading-anim] [class*="animate-spin"] {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
EFFETS DE FLOU (data-no-blur)
|
||||
- backdrop-blur (très coûteux GPU, plante les Smart TV)
|
||||
- filter:blur via classes Tailwind ou inline
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
html[data-no-blur] [class*="backdrop-blur"] {
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
background-color: rgba(10, 10, 15, 0.95) !important;
|
||||
}
|
||||
|
||||
/* Filter blur — attrape blur-sm, blur-xl, blur-2xl, blur-3xl, blur-[60px], etc. */
|
||||
[data-light-mode="true"] [class*="blur-"] {
|
||||
html[data-no-blur] [class*="blur-"] {
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
/* Inline styles — framer-motion et styles JSX qui posent filter/backdrop-filter */
|
||||
[data-light-mode="true"] [style*="filter"] {
|
||||
html[data-no-blur] [style*="filter"] {
|
||||
filter: none !important;
|
||||
-webkit-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
/* Stoppe TOUTES les animations CSS */
|
||||
[data-light-mode="true"] [class*="animate-"],
|
||||
[data-light-mode="true"] [class*="rainbow"],
|
||||
[data-light-mode="true"] .screensaver-kenburns,
|
||||
[data-light-mode="true"] .snow-particle {
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
TRANSITIONS (data-no-transitions)
|
||||
- Coupe transitions CSS sur tout l'arbre
|
||||
- framer-motion est géré séparément via <MotionConfig reducedMotion>
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
html[data-no-transitions] *,
|
||||
html[data-no-transitions] *::before,
|
||||
html[data-no-transitions] *::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
transition-delay: 0ms !important;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
LEGACY : raccourci Mode Léger
|
||||
Garde-fou : si `data-light-mode="true"` est présent SANS les autres attrs
|
||||
(ex: provider non monté), on coupe quand même tout l'essentiel. En usage
|
||||
normal, LightModeContext ajoute les attrs granulaires en plus, donc ces
|
||||
règles sont redondantes mais inoffensives.
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
html[data-light-mode="true"] [class*="animate-"] {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Canvas — masque les canvas d'animation (particules, grilles) */
|
||||
[data-light-mode="true"] canvas {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* will-change — supprime la promotion de couches GPU */
|
||||
[data-light-mode="true"] [style*="will-change"],
|
||||
[data-light-mode="true"] [class*="will-change"] {
|
||||
/* will-change : supprime la promotion de couches GPU partout en Mode Léger. */
|
||||
html[data-light-mode="true"] [style*="will-change"],
|
||||
html[data-light-mode="true"] [class*="will-change"] {
|
||||
will-change: auto !important;
|
||||
}
|
||||
|
||||
/* Snow particles */
|
||||
[data-light-mode="true"] .snow-particle {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const BG_STORAGE_KEYS = {
|
|||
squareSize: 'settings_bg_square_size',
|
||||
forceColor: 'settings_bg_force_color',
|
||||
forceSquareSize: 'settings_bg_force_square_size',
|
||||
haloEnabled: 'settings_bg_halo',
|
||||
} as const;
|
||||
|
||||
// ─── Event bus ──────────────────────────────────────────────────────────
|
||||
|
|
@ -56,6 +57,7 @@ export interface BgPrefs {
|
|||
squareSize: number;
|
||||
forceColor: boolean;
|
||||
forceSquareSize: boolean;
|
||||
haloEnabled: boolean;
|
||||
}
|
||||
|
||||
export function readBgPrefs(): BgPrefs {
|
||||
|
|
@ -74,8 +76,12 @@ export function readBgPrefs(): BgPrefs {
|
|||
|
||||
const forceColor = typeof window !== 'undefined' && localStorage.getItem(BG_STORAGE_KEYS.forceColor) === '1';
|
||||
const forceSquareSize = typeof window !== 'undefined' && localStorage.getItem(BG_STORAGE_KEYS.forceSquareSize) === '1';
|
||||
// Default true — le halo est activé par défaut. Désactivable depuis Apparence.
|
||||
const haloEnabled = typeof window === 'undefined'
|
||||
? true
|
||||
: localStorage.getItem(BG_STORAGE_KEYS.haloEnabled) !== '0';
|
||||
|
||||
return { accent, customHex, squareSize, forceColor, forceSquareSize };
|
||||
return { accent, customHex, squareSize, forceColor, forceSquareSize, haloEnabled };
|
||||
}
|
||||
|
||||
export function getBgAccentRgb(prefs: BgPrefs): string {
|
||||
|
|
@ -104,7 +110,7 @@ let cachedKey = '';
|
|||
|
||||
function getSnapshot(): BgPrefs {
|
||||
const next = readBgPrefs();
|
||||
const key = `${next.accent}|${next.customHex}|${next.squareSize}|${next.forceColor}|${next.forceSquareSize}`;
|
||||
const key = `${next.accent}|${next.customHex}|${next.squareSize}|${next.forceColor}|${next.forceSquareSize}|${next.haloEnabled}`;
|
||||
if (cachedPrefs && key === cachedKey) return cachedPrefs;
|
||||
cachedPrefs = next;
|
||||
cachedKey = key;
|
||||
|
|
@ -118,6 +124,7 @@ function getServerSnapshot(): BgPrefs {
|
|||
squareSize: 48,
|
||||
forceColor: false,
|
||||
forceSquareSize: false,
|
||||
haloEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import remarkGfm from 'remark-gfm';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type remarkGfm from 'remark-gfm';
|
||||
|
||||
const hasRegexLookbehindSupport = (() => {
|
||||
try {
|
||||
|
|
@ -9,9 +10,45 @@ const hasRegexLookbehindSupport = (() => {
|
|||
}
|
||||
})();
|
||||
|
||||
// remark-gfm's email autolink uses a positive lookbehind, which Safari < 16.4
|
||||
// and other older engines throw on at regex construction. Fall back to `null`
|
||||
// so callers render markdown without GFM rather than crashing the whole tree.
|
||||
export const safeRemarkGfm: typeof remarkGfm | null = hasRegexLookbehindSupport
|
||||
? remarkGfm
|
||||
: null;
|
||||
type RemarkGfmPlugin = typeof remarkGfm;
|
||||
|
||||
// Module-level cache so every component shares the same plugin instance once loaded.
|
||||
let cachedRemarkGfm: RemarkGfmPlugin | null = null;
|
||||
let pendingLoad: Promise<RemarkGfmPlugin | null> | null = null;
|
||||
|
||||
function loadRemarkGfm(): Promise<RemarkGfmPlugin | null> {
|
||||
if (!hasRegexLookbehindSupport) return Promise.resolve(null);
|
||||
if (cachedRemarkGfm) return Promise.resolve(cachedRemarkGfm);
|
||||
if (!pendingLoad) {
|
||||
pendingLoad = import('remark-gfm')
|
||||
.then((mod) => {
|
||||
cachedRemarkGfm = mod.default;
|
||||
return cachedRemarkGfm;
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
return pendingLoad;
|
||||
}
|
||||
|
||||
// remark-gfm transitively imports mdast-util-gfm-autolink-literal, whose
|
||||
// email-autolink regex uses a positive lookbehind. Safari < 16.4 throws
|
||||
// "Invalid regular expression: invalid group specifier name" while *parsing*
|
||||
// that regex literal at module-load — before any try/catch can run. Gating
|
||||
// `import 'remark-gfm'` behind a runtime feature probe + dynamic import is the
|
||||
// only way to keep older Safari from evaluating the offending module.
|
||||
export function useSafeRemarkGfm(): RemarkGfmPlugin | null {
|
||||
const [plugin, setPlugin] = useState<RemarkGfmPlugin | null>(() => cachedRemarkGfm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRegexLookbehindSupport || cachedRemarkGfm) return;
|
||||
let cancelled = false;
|
||||
loadRemarkGfm().then((p) => {
|
||||
if (!cancelled) setPlugin(() => p);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return plugin;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@
|
|||
// @match https://*.movix.cash/*
|
||||
// @match https://movix.club/*
|
||||
// @match https://*.movix.club/*
|
||||
// @match https://movix.cash/*
|
||||
// @match https://*.movix.cash/*
|
||||
// @match https://movix.tax/*
|
||||
// @match https://*.movix.tax/*
|
||||
// @grant GM_xmlhttpRequest
|
||||
// @grant GM_getValue
|
||||
// @grant GM_setValue
|
||||
|
|
@ -1880,12 +1880,21 @@
|
|||
if (html.includes("File was deleted"))
|
||||
return { success: false, error: "Uqload: File was deleted" };
|
||||
|
||||
const matches = html.match(/https?:\/\/.+\/v\.mp4/g);
|
||||
if (!matches || matches.length === 0)
|
||||
return { success: false, error: "Uqload: MP4 URL not found" };
|
||||
// Préférer le HLS master.m3u8 (multi-bitrate) au mp4 single-quality
|
||||
const m3u8Matches =
|
||||
html.match(/https?:\/\/[^"'\s]+\/master\.m3u8/g) ||
|
||||
html.match(/https?:\/\/[^"'\s]+\.m3u8/g);
|
||||
let videoUrl = m3u8Matches?.[0];
|
||||
|
||||
const mp4Url = matches[0];
|
||||
const result = { m3u8Url: mp4Url, success: true, source: "uqload" };
|
||||
if (!videoUrl) {
|
||||
const mp4Matches = html.match(/https?:\/\/.+\/v\.mp4/g);
|
||||
videoUrl = mp4Matches?.[0];
|
||||
}
|
||||
|
||||
if (!videoUrl)
|
||||
return { success: false, error: "Uqload: video URL not found" };
|
||||
|
||||
const result = { m3u8Url: videoUrl, success: true, source: "uqload" };
|
||||
caches.uqload.set(cacheKey, result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
|
|
@ -2526,7 +2535,7 @@
|
|||
"127.0.0.1",
|
||||
"movix.cash",
|
||||
"movix.club",
|
||||
"movix.cash",
|
||||
"movix.tax",
|
||||
],
|
||||
resourceTypes: [
|
||||
"xmlhttprequest",
|
||||
|
|
@ -2918,8 +2927,8 @@
|
|||
currentHostname.endsWith(".movix.cash") ||
|
||||
currentHostname === "movix.club" ||
|
||||
currentHostname.endsWith(".movix.club") ||
|
||||
currentHostname === "movix.cash" ||
|
||||
currentHostname.endsWith(".movix.cash")
|
||||
currentHostname === "movix.tax" ||
|
||||
currentHostname.endsWith(".movix.tax")
|
||||
) {
|
||||
return (currentOrigin || "https://movix.cash").replace(/\/$/, "");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue