Compare commits

..

6 Commits

Author SHA1 Message Date
f1de2bb065 fix(backend): resolve migration path relative to __dirname
Some checks failed
CI/CD — Build & Deploy / Deploy to VPS (push) Has been cancelled
CI/CD — Build & Deploy / Build (push) Has been cancelled
migrations glob "src/migrations/**/*.ts" was CWD-relative — broken when CLI runs
outside the src/ directory. Using __dirname makes it absolute and portable.
2026-03-14 07:32:39 +01:00
25733ee3db feat(frontend): scaffold Tailwind design system + routing + auth callback
- Tailwind v3 + PostCSS + autoprefixer
- BrowserRouter with Layout shell (Header, theme toggle dark/light)
- Pages: HomePage, CallbackPage (SuperOAuth callback handler)
- hooks/useAuth.ts + lib/api.ts (API client base)
- styles/index.css (Tailwind directives)
- Theme persisted in localStorage (od-theme)
2026-03-14 07:15:19 +01:00
f3e392ff1b feat(backend): mount API routes + cookie-parser + CORS with credentials
- index.ts: mount /api/auth, /api/videos, /api/playlists; add cookie-parser; CORS with credentials + FRONTEND_URL env
- auth.middleware: read token from Bearer header OR od_token httpOnly cookie
- routes: auth (session/logout/me), videos (level-gated), playlists (CRUD + share management)
- deps: cookie-parser + @types/cookie-parser
2026-03-14 07:10:47 +01:00
71d90eb133 feat: initial schema migration — 9 tables + seed roles & plans 2026-03-14 07:02:20 +01:00
2f47be1305 feat: TypeORM entities — User, Role, SubscriptionPlan, Video, Playlist + relations 2026-03-14 06:53:02 +01:00
4f3c0e6433 feat: SuperOAuth token introspection middleware + /api/profile route 2026-03-14 06:40:43 +01:00
34 changed files with 2290 additions and 7 deletions

View File

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.18.3", "express": "^4.18.3",
@@ -19,6 +20,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
@@ -260,6 +262,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cors": { "node_modules/@types/cors": {
"version": "2.8.19", "version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -901,6 +913,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",

View File

@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.18.3", "express": "^4.18.3",
@@ -23,6 +24,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",

View File

@@ -10,7 +10,17 @@ export const AppDataSource = new DataSource({
database: process.env.DB_NAME ?? "originsdigital", database: process.env.DB_NAME ?? "originsdigital",
synchronize: false, synchronize: false,
logging: process.env.NODE_ENV === "development", logging: process.env.NODE_ENV === "development",
entities: ["src/entities/**/*.ts"], entities: [
migrations: ["src/migrations/**/*.ts"], require("../entities/User").User,
require("../entities/Role").Role,
require("../entities/UserRole").UserRole,
require("../entities/SubscriptionPlan").SubscriptionPlan,
require("../entities/UserSubscription").UserSubscription,
require("../entities/Video").Video,
require("../entities/Playlist").Playlist,
require("../entities/PlaylistVideo").PlaylistVideo,
require("../entities/PlaylistShare").PlaylistShare,
],
migrations: [__dirname + "/../migrations/**/*.ts"],
subscribers: [], subscribers: [],
}); });

View File

@@ -0,0 +1,47 @@
import {
Entity, PrimaryGeneratedColumn, Column,
ManyToOne, OneToMany, JoinColumn,
CreateDateColumn, UpdateDateColumn,
} from "typeorm";
import { User } from "./User";
import { PlaylistVideo } from "./PlaylistVideo";
import { PlaylistShare } from "./PlaylistShare";
export type PlaylistVisibility = "private" | "shared" | "public";
@Entity("playlists")
export class Playlist {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid" })
ownerId!: string;
@Column({ type: "varchar", length: 255 })
title!: string;
@Column({ type: "text", nullable: true })
description!: string | null;
// private → visible uniquement par le propriétaire
// shared → visible par les utilisateurs invités via PlaylistShare
// public → visible par tous
@Column({ type: "enum", enum: ["private", "shared", "public"], default: "private" })
visibility!: PlaylistVisibility;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ManyToOne(() => User, (user) => user.playlists, { onDelete: "CASCADE" })
@JoinColumn({ name: "ownerId" })
owner!: User;
@OneToMany(() => PlaylistVideo, (pv) => pv.playlist)
playlistVideos!: PlaylistVideo[];
@OneToMany(() => PlaylistShare, (share) => share.playlist)
shares!: PlaylistShare[];
}

View File

@@ -0,0 +1,46 @@
import {
Entity, PrimaryGeneratedColumn, Column,
ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn,
} from "typeorm";
import { Playlist } from "./Playlist";
import { User } from "./User";
export type SharePermission = "view" | "edit";
export type ShareStatus = "pending" | "active" | "revoked";
@Entity("playlist_shares")
export class PlaylistShare {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid" })
playlistId!: string;
@Column({ type: "uuid" })
userId!: string;
// Permissions gérées par le propriétaire de la playlist
@Column({ type: "enum", enum: ["view", "edit"], default: "view" })
permission!: SharePermission;
// Statut géré par le propriétaire :
// pending → invitation envoyée, pas encore acceptée
// active → invité a accepté
// revoked → propriétaire a révoqué l'accès
@Column({ type: "enum", enum: ["pending", "active", "revoked"], default: "pending" })
status!: ShareStatus;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ManyToOne(() => Playlist, (playlist) => playlist.shares, { onDelete: "CASCADE" })
@JoinColumn({ name: "playlistId" })
playlist!: Playlist;
@ManyToOne(() => User, (user) => user.playlistShares, { onDelete: "CASCADE" })
@JoinColumn({ name: "userId" })
user!: User;
}

View File

@@ -0,0 +1,24 @@
import { Entity, ManyToOne, JoinColumn, PrimaryColumn, Column } from "typeorm";
import { Playlist } from "./Playlist";
import { Video } from "./Video";
@Entity("playlist_videos")
export class PlaylistVideo {
@PrimaryColumn({ type: "uuid" })
playlistId!: string;
@PrimaryColumn({ type: "uuid" })
videoId!: string;
// Ordre d'affichage dans la playlist — géré par le propriétaire ou les éditeurs
@Column({ type: "int", unsigned: true, default: 0 })
position!: number;
@ManyToOne(() => Playlist, (playlist) => playlist.playlistVideos, { onDelete: "CASCADE" })
@JoinColumn({ name: "playlistId" })
playlist!: Playlist;
@ManyToOne(() => Video, (video) => video.playlistVideos, { onDelete: "CASCADE" })
@JoinColumn({ name: "videoId" })
video!: Video;
}

View File

@@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
import { UserRole } from "./UserRole";
export type RoleSlug = "user" | "moderator" | "admin" | "super_admin";
@Entity("roles")
export class Role {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "varchar", length: 50, unique: true })
slug!: RoleSlug;
@Column({ type: "varchar", length: 100 })
name!: string;
@OneToMany(() => UserRole, (userRole) => userRole.role)
userRoles!: UserRole[];
}

View File

@@ -0,0 +1,35 @@
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
import { UserSubscription } from "./UserSubscription";
export type PlanSlug = "free" | "basic" | "pro" | "enterprise";
@Entity("subscription_plans")
export class SubscriptionPlan {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "varchar", length: 50, unique: true })
slug!: PlanSlug;
@Column({ type: "varchar", length: 100 })
name!: string;
// Niveau d'accès — comparaison entière : userPlanLevel >= video.requiredLevel
// free=0, basic=1, pro=2, enterprise=3
@Column({ type: "tinyint", unsigned: true, default: 0 })
level!: number;
// Prix en centimes (0 = gratuit). Évite les flottants en DB.
@Column({ type: "int", unsigned: true, default: 0 })
priceInCents!: number;
// Features libres — permet la personnalisation marque blanche sans migration
@Column({ type: "json", nullable: true })
features!: Record<string, unknown> | null;
@Column({ type: "boolean", default: true })
isActive!: boolean;
@OneToMany(() => UserSubscription, (sub) => sub.plan)
subscriptions!: UserSubscription[];
}

View File

@@ -0,0 +1,45 @@
import {
Entity, PrimaryGeneratedColumn, Column,
OneToMany, CreateDateColumn, UpdateDateColumn,
} from "typeorm";
import { UserRole } from "./UserRole";
import { UserSubscription } from "./UserSubscription";
import { Playlist } from "./Playlist";
import { PlaylistShare } from "./PlaylistShare";
@Entity("users")
export class User {
@PrimaryGeneratedColumn("uuid")
id!: string;
// Identifiant SuperOAuth — clé de réconciliation avec le service auth
@Column({ type: "varchar", length: 255, unique: true })
superOAuthId!: string;
@Column({ type: "varchar", length: 255, nullable: true })
email!: string | null;
@Column({ type: "varchar", length: 100 })
nickname!: string;
@Column({ type: "boolean", default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@OneToMany(() => UserRole, (userRole) => userRole.user)
userRoles!: UserRole[];
@OneToMany(() => UserSubscription, (sub) => sub.user)
subscriptions!: UserSubscription[];
@OneToMany(() => Playlist, (playlist) => playlist.owner)
playlists!: Playlist[];
@OneToMany(() => PlaylistShare, (share) => share.user)
playlistShares!: PlaylistShare[];
}

View File

@@ -0,0 +1,20 @@
import { Entity, ManyToOne, JoinColumn, PrimaryColumn } from "typeorm";
import { User } from "./User";
import { Role } from "./Role";
@Entity("user_roles")
export class UserRole {
@PrimaryColumn({ type: "uuid" })
userId!: string;
@PrimaryColumn({ type: "uuid" })
roleId!: string;
@ManyToOne(() => User, (user) => user.userRoles, { onDelete: "CASCADE" })
@JoinColumn({ name: "userId" })
user!: User;
@ManyToOne(() => Role, (role) => role.userRoles, { onDelete: "CASCADE" })
@JoinColumn({ name: "roleId" })
role!: Role;
}

View File

@@ -0,0 +1,41 @@
import {
Entity, PrimaryGeneratedColumn, Column,
ManyToOne, JoinColumn, CreateDateColumn,
} from "typeorm";
import { User } from "./User";
import { SubscriptionPlan } from "./SubscriptionPlan";
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial";
@Entity("user_subscriptions")
export class UserSubscription {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid" })
userId!: string;
@Column({ type: "uuid" })
planId!: string;
@Column({ type: "enum", enum: ["active", "expired", "cancelled", "trial"], default: "active" })
status!: SubscriptionStatus;
@Column({ type: "datetime" })
startsAt!: Date;
// null = pas d'expiration (plan gratuit permanent)
@Column({ type: "datetime", nullable: true })
endsAt!: Date | null;
@CreateDateColumn()
createdAt!: Date;
@ManyToOne(() => User, (user) => user.subscriptions, { onDelete: "CASCADE" })
@JoinColumn({ name: "userId" })
user!: User;
@ManyToOne(() => SubscriptionPlan, (plan) => plan.subscriptions)
@JoinColumn({ name: "planId" })
plan!: SubscriptionPlan;
}

View File

@@ -0,0 +1,58 @@
import {
Entity, PrimaryGeneratedColumn, Column,
OneToMany, CreateDateColumn, UpdateDateColumn,
} from "typeorm";
import { PlaylistVideo } from "./PlaylistVideo";
export type StorageType = "youtube" | "s3" | "local" | "external";
@Entity("videos")
export class Video {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "varchar", length: 255 })
title!: string;
@Column({ type: "text", nullable: true })
description!: string | null;
@Column({ type: "varchar", length: 500, nullable: true })
thumbnailUrl!: string | null;
// Durée en secondes
@Column({ type: "int", unsigned: true, nullable: true })
duration!: number | null;
@Column({ type: "enum", enum: ["youtube", "s3", "local", "external"] })
storageType!: StorageType;
// Référence flexible selon storageType :
// youtube → videoId YouTube (ex: "dQw4w9WgXcQ")
// s3 → clé S3 (ex: "videos/2026/my-video.mp4")
// local → chemin relatif (ex: "uploads/my-video.mp4")
// external → URL complète
@Column({ type: "varchar", length: 500 })
storageKey!: string;
// Niveau de plan minimum requis pour accéder à cette vidéo
// 0 = libre, 1 = basic, 2 = pro, 3 = enterprise
// Comparaison : userSubscription.plan.level >= video.requiredLevel
@Column({ type: "tinyint", unsigned: true, default: 0 })
requiredLevel!: number;
@Column({ type: "boolean", default: false })
isPublished!: boolean;
@Column({ type: "datetime", nullable: true })
publishedAt!: Date | null;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@OneToMany(() => PlaylistVideo, (pv) => pv.video)
playlistVideos!: PlaylistVideo[];
}

View File

@@ -1,21 +1,33 @@
import "reflect-metadata"; import "reflect-metadata";
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
import cookieParser from "cookie-parser";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { AppDataSource } from "./config/data-source"; import { AppDataSource } from "./config/data-source";
import authRoutes from "./routes/auth.routes";
import videoRoutes from "./routes/video.routes";
import playlistRoutes from "./routes/playlist.routes";
dotenv.config(); dotenv.config();
const app = express(); const app = express();
const PORT = parseInt(process.env.PORT ?? "4000"); const PORT = parseInt(process.env.PORT ?? "4000");
app.use(cors()); app.use(cors({
origin: process.env.FRONTEND_URL ?? "http://localhost:3000",
credentials: true,
}));
app.use(express.json()); app.use(express.json());
app.use(cookieParser());
app.get("/api/health", (_req, res) => { app.get("/api/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() }); res.json({ status: "ok", timestamp: new Date().toISOString() });
}); });
app.use("/api/auth", authRoutes);
app.use("/api/videos", videoRoutes);
app.use("/api/playlists", playlistRoutes);
AppDataSource.initialize() AppDataSource.initialize()
.then(() => { .then(() => {
console.log("Database connected"); console.log("Database connected");

View File

@@ -0,0 +1,71 @@
import { Request, Response, NextFunction } from "express";
export interface AuthenticatedUser {
id: string;
email: string | null;
nickname: string;
isActive: boolean;
linkedProviders: string[];
}
export interface AuthenticatedRequest extends Request {
user: AuthenticatedUser;
}
/**
* Middleware d'authentification — Token Introspection via SuperOAuth
*
* Valide le Bearer token auprès de SuperOAuth (POST /api/auth/token/validate).
* Aucun secret JWT partagé — SuperOAuth garde le contrôle total.
*
* Flow :
* 1. Extraire le Bearer token du header Authorization
* 2. Appeler SuperOAuth /api/auth/token/validate
* 3. Si valid → attacher req.user et continuer
* 4. Sinon → 401
*/
export const requireAuth = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
const token =
req.headers.authorization?.split(" ")[1] ??
(req.cookies as Record<string, string>)?.od_token;
if (!token) {
res.status(401).json({ success: false, error: "UNAUTHORIZED", message: "Access token required" });
return;
}
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
if (!superOAuthUrl) {
console.error("SUPER_OAUTH_URL not configured");
res.status(500).json({ success: false, error: "INTERNAL_ERROR", message: "Auth service not configured" });
return;
}
try {
const response = await fetch(`${superOAuthUrl}/api/auth/token/validate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await response.json() as {
success: boolean;
data?: { valid: boolean; user?: AuthenticatedUser };
error?: string;
};
if (!response.ok || !data.data?.valid || !data.data.user) {
res.status(401).json({ success: false, error: data.error ?? "INVALID_TOKEN", message: "Invalid or expired token" });
return;
}
(req as AuthenticatedRequest).user = data.data.user;
next();
} catch {
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE", message: "Authentication service unreachable" });
}
};

View File

@@ -0,0 +1,168 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class InitialSchema1710374400000 implements MigrationInterface {
name = "InitialSchema1710374400000";
public async up(queryRunner: QueryRunner): Promise<void> {
// roles
await queryRunner.query(`
CREATE TABLE roles (
id VARCHAR(36) NOT NULL PRIMARY KEY,
slug VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// subscription_plans
await queryRunner.query(`
CREATE TABLE subscription_plans (
id VARCHAR(36) NOT NULL PRIMARY KEY,
slug VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
level TINYINT UNSIGNED NOT NULL DEFAULT 0,
priceInCents INT UNSIGNED NOT NULL DEFAULT 0,
features JSON NULL,
isActive BOOLEAN NOT NULL DEFAULT TRUE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// users
await queryRunner.query(`
CREATE TABLE users (
id VARCHAR(36) NOT NULL PRIMARY KEY,
superOAuthId VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NULL,
nickname VARCHAR(100) NOT NULL,
isActive BOOLEAN NOT NULL DEFAULT TRUE,
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_users_superOAuthId (superOAuthId),
INDEX idx_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// user_roles (pivot)
await queryRunner.query(`
CREATE TABLE user_roles (
userId VARCHAR(36) NOT NULL,
roleId VARCHAR(36) NOT NULL,
PRIMARY KEY (userId, roleId),
CONSTRAINT fk_user_roles_user FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role FOREIGN KEY (roleId) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// user_subscriptions
await queryRunner.query(`
CREATE TABLE user_subscriptions (
id VARCHAR(36) NOT NULL PRIMARY KEY,
userId VARCHAR(36) NOT NULL,
planId VARCHAR(36) NOT NULL,
status ENUM('active','expired','cancelled','trial') NOT NULL DEFAULT 'active',
startsAt DATETIME NOT NULL,
endsAt DATETIME NULL,
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX idx_user_subscriptions_userId (userId),
INDEX idx_user_subscriptions_status (status),
CONSTRAINT fk_user_subscriptions_user FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_subscriptions_plan FOREIGN KEY (planId) REFERENCES subscription_plans(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// videos
await queryRunner.query(`
CREATE TABLE videos (
id VARCHAR(36) NOT NULL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
thumbnailUrl VARCHAR(500) NULL,
duration INT UNSIGNED NULL,
storageType ENUM('youtube','s3','local','external') NOT NULL,
storageKey VARCHAR(500) NOT NULL,
requiredLevel TINYINT UNSIGNED NOT NULL DEFAULT 0,
isPublished BOOLEAN NOT NULL DEFAULT FALSE,
publishedAt DATETIME NULL,
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_videos_requiredLevel (requiredLevel),
INDEX idx_videos_isPublished (isPublished)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// playlists
await queryRunner.query(`
CREATE TABLE playlists (
id VARCHAR(36) NOT NULL PRIMARY KEY,
ownerId VARCHAR(36) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
visibility ENUM('private','shared','public') NOT NULL DEFAULT 'private',
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_playlists_ownerId (ownerId),
INDEX idx_playlists_visibility (visibility),
CONSTRAINT fk_playlists_owner FOREIGN KEY (ownerId) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// playlist_videos (pivot ordonné)
await queryRunner.query(`
CREATE TABLE playlist_videos (
playlistId VARCHAR(36) NOT NULL,
videoId VARCHAR(36) NOT NULL,
position INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (playlistId, videoId),
INDEX idx_playlist_videos_position (playlistId, position),
CONSTRAINT fk_playlist_videos_playlist FOREIGN KEY (playlistId) REFERENCES playlists(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_videos_video FOREIGN KEY (videoId) REFERENCES videos(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// playlist_shares
await queryRunner.query(`
CREATE TABLE playlist_shares (
id VARCHAR(36) NOT NULL PRIMARY KEY,
playlistId VARCHAR(36) NOT NULL,
userId VARCHAR(36) NOT NULL,
permission ENUM('view','edit') NOT NULL DEFAULT 'view',
status ENUM('pending','active','revoked') NOT NULL DEFAULT 'pending',
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE KEY uq_playlist_shares (playlistId, userId),
INDEX idx_playlist_shares_userId (userId),
INDEX idx_playlist_shares_status (status),
CONSTRAINT fk_playlist_shares_playlist FOREIGN KEY (playlistId) REFERENCES playlists(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_shares_user FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// Données initiales — plans et rôles
await queryRunner.query(`
INSERT INTO roles (id, slug, name) VALUES
(UUID(), 'user', 'Utilisateur'),
(UUID(), 'moderator', 'Modérateur'),
(UUID(), 'admin', 'Administrateur'),
(UUID(), 'super_admin', 'Super Administrateur')
`);
await queryRunner.query(`
INSERT INTO subscription_plans (id, slug, name, level, priceInCents, features) VALUES
(UUID(), 'free', 'Gratuit', 0, 0, JSON_OBJECT('maxPlaylists', 3)),
(UUID(), 'basic', 'Basic', 1, 499, JSON_OBJECT('maxPlaylists', 20)),
(UUID(), 'pro', 'Pro', 2, 999, JSON_OBJECT('maxPlaylists', 999)),
(UUID(), 'enterprise', 'Enterprise', 3, 4999, JSON_OBJECT('maxPlaylists', 999, 'whiteLabel', true))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS playlist_shares`);
await queryRunner.query(`DROP TABLE IF EXISTS playlist_videos`);
await queryRunner.query(`DROP TABLE IF EXISTS playlists`);
await queryRunner.query(`DROP TABLE IF EXISTS videos`);
await queryRunner.query(`DROP TABLE IF EXISTS user_subscriptions`);
await queryRunner.query(`DROP TABLE IF EXISTS user_roles`);
await queryRunner.query(`DROP TABLE IF EXISTS users`);
await queryRunner.query(`DROP TABLE IF EXISTS subscription_plans`);
await queryRunner.query(`DROP TABLE IF EXISTS roles`);
}
}

View File

@@ -0,0 +1,76 @@
import { Router, Request, Response } from "express";
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
const router = Router();
const COOKIE_NAME = "od_token";
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict" as const,
maxAge: 15 * 60 * 1000, // 15 min — durée de vie du token SuperOAuth
};
/**
* POST /api/auth/session
* Reçoit le token depuis le callback SuperOAuth,
* le valide, puis le pose en httpOnly cookie.
*/
router.post("/session", async (req: Request, res: Response): Promise<void> => {
const { token } = req.body as { token?: string };
if (!token) {
res.status(400).json({ success: false, error: "MISSING_TOKEN" });
return;
}
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
if (!superOAuthUrl) {
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
return;
}
try {
const response = await fetch(`${superOAuthUrl}/api/auth/token/validate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await response.json() as {
success: boolean;
data?: { valid: boolean; user?: object };
error?: string;
};
if (!response.ok || !data.data?.valid) {
res.status(401).json({ success: false, error: "INVALID_TOKEN" });
return;
}
res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS);
res.json({ success: true, data: { user: data.data.user } });
} catch {
res.status(500).json({ success: false, error: "AUTH_SERVICE_UNAVAILABLE" });
}
});
/**
* POST /api/auth/logout
* Supprime le cookie de session.
*/
router.post("/logout", (_req: Request, res: Response): void => {
res.clearCookie(COOKIE_NAME);
res.json({ success: true });
});
/**
* GET /api/auth/me
* Retourne l'utilisateur courant (cookie ou Bearer).
*/
router.get("/me", requireAuth, (req: Request, res: Response): void => {
const { user } = req as AuthenticatedRequest;
res.json({ success: true, data: { user } });
});
export default router;

View File

@@ -0,0 +1,194 @@
import { Router, Request, Response } from "express";
import { AppDataSource } from "../config/data-source";
import { Playlist } from "../entities/Playlist";
import { PlaylistShare } from "../entities/PlaylistShare";
import { requireAuth, AuthenticatedRequest } from "../middleware/auth.middleware";
const router = Router();
/**
* GET /api/playlists
* Playlists publiques + playlists partagées avec l'utilisateur connecté.
*/
router.get("/", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
try {
const owned = await AppDataSource.getRepository(Playlist).find({
where: { ownerId: user.id },
order: { createdAt: "DESC" },
});
const shared = await AppDataSource.getRepository(PlaylistShare).find({
where: { userId: user.id, status: "active" },
relations: ["playlist"],
});
res.json({
success: true,
data: {
owned,
shared: shared.map((s) => ({ ...s.playlist, permission: s.permission })),
},
});
} catch {
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* POST /api/playlists
* Crée une playlist.
*/
router.post("/", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
const { title, description, visibility } = req.body as {
title?: string;
description?: string;
visibility?: "private" | "shared" | "public";
};
if (!title?.trim()) {
res.status(400).json({ success: false, error: "MISSING_TITLE" });
return;
}
try {
const playlist = AppDataSource.getRepository(Playlist).create({
id: require("crypto").randomUUID(),
ownerId: user.id,
title: title.trim(),
description: description ?? null,
visibility: visibility ?? "private",
});
await AppDataSource.getRepository(Playlist).save(playlist);
res.status(201).json({ success: true, data: { playlist } });
} catch {
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* GET /api/playlists/:id
* Détail d'une playlist + ses vidéos.
* Accès : propriétaire, invité actif, ou playlist publique.
*/
router.get("/:id", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
try {
const playlist = await AppDataSource.getRepository(Playlist).findOne({
where: { id: req.params.id },
relations: ["playlistVideos", "playlistVideos.video", "shares"],
});
if (!playlist) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
const isOwner = playlist.ownerId === user.id;
const share = playlist.shares.find((s) => s.userId === user.id && s.status === "active");
const isPublic = playlist.visibility === "public";
if (!isOwner && !share && !isPublic) {
res.status(403).json({ success: false, error: "FORBIDDEN" });
return;
}
const videos = playlist.playlistVideos
.sort((a, b) => a.position - b.position)
.map((pv) => pv.video);
res.json({
success: true,
data: {
playlist: { ...playlist, shares: undefined },
videos,
permission: isOwner ? "owner" : share?.permission ?? "view",
},
});
} catch {
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* POST /api/playlists/:id/share
* Invite un utilisateur à une playlist (propriétaire uniquement).
*/
router.post("/:id/share", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
const { userId, permission } = req.body as { userId?: string; permission?: "view" | "edit" };
try {
const playlist = await AppDataSource.getRepository(Playlist).findOneBy({ id: req.params.id });
if (!playlist) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
if (playlist.ownerId !== user.id) {
res.status(403).json({ success: false, error: "FORBIDDEN" });
return;
}
if (!userId) {
res.status(400).json({ success: false, error: "MISSING_USER_ID" });
return;
}
const share = AppDataSource.getRepository(PlaylistShare).create({
id: require("crypto").randomUUID(),
playlistId: playlist.id,
userId,
permission: permission ?? "view",
status: "pending",
});
await AppDataSource.getRepository(PlaylistShare).save(share);
res.status(201).json({ success: true, data: { share } });
} catch {
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* PATCH /api/playlists/:id/share/:shareId
* Modifie permission ou status d'un invité (propriétaire uniquement).
*/
router.patch("/:id/share/:shareId", requireAuth, async (req: Request, res: Response): Promise<void> => {
const { user } = req as AuthenticatedRequest;
const { permission, status } = req.body as {
permission?: "view" | "edit";
status?: "active" | "revoked";
};
try {
const playlist = await AppDataSource.getRepository(Playlist).findOneBy({ id: req.params.id });
if (!playlist || playlist.ownerId !== user.id) {
res.status(403).json({ success: false, error: "FORBIDDEN" });
return;
}
const share = await AppDataSource.getRepository(PlaylistShare).findOneBy({ id: req.params.shareId });
if (!share || share.playlistId !== playlist.id) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
if (permission) share.permission = permission;
if (status) share.status = status;
await AppDataSource.getRepository(PlaylistShare).save(share);
res.json({ success: true, data: { share } });
} catch {
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
export default router;

View File

@@ -0,0 +1,83 @@
import { Router, Request, Response } from "express";
import { AppDataSource } from "../config/data-source";
import { Video } from "../entities/Video";
import { requireAuth, AuthenticatedRequest, AuthenticatedUser } from "../middleware/auth.middleware";
import { UserSubscription } from "../entities/UserSubscription";
const router = Router();
/** Récupère le niveau de plan actif d'un user (0 = free si aucun abonnement actif) */
async function getUserPlanLevel(userId: string): Promise<number> {
const sub = await AppDataSource.getRepository(UserSubscription).findOne({
where: { userId, status: "active" },
relations: ["plan"],
order: { startsAt: "DESC" },
});
return sub?.plan.level ?? 0;
}
/**
* GET /api/videos
* Liste les vidéos publiées. Filtre selon le niveau de plan de l'utilisateur.
* Sans auth → niveau 0 (free uniquement).
*/
router.get("/", async (req: Request, res: Response): Promise<void> => {
try {
const user = (req as AuthenticatedRequest).user as AuthenticatedUser | undefined;
const userLevel = user ? await getUserPlanLevel(user.id) : 0;
const videos = await AppDataSource.getRepository(Video).find({
where: { isPublished: true },
order: { publishedAt: "DESC" },
select: ["id", "title", "description", "thumbnailUrl", "duration",
"storageType", "storageKey", "requiredLevel", "publishedAt"],
});
// Injequer un flag `locked` côté client pour les vidéos hors niveau
const result = videos.map((v) => ({
...v,
locked: v.requiredLevel > userLevel,
// Ne pas exposer storageKey si la vidéo est verrouillée
storageKey: v.requiredLevel > userLevel ? null : v.storageKey,
}));
res.json({ success: true, data: { videos: result } });
} catch {
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/**
* GET /api/videos/:id
* Détail d'une vidéo. Retourne 403 si requiredLevel > userLevel.
*/
router.get("/:id", async (req: Request, res: Response): Promise<void> => {
try {
const user = (req as AuthenticatedRequest).user as AuthenticatedUser | undefined;
const userLevel = user ? await getUserPlanLevel(user.id) : 0;
const video = await AppDataSource.getRepository(Video).findOne({
where: { id: req.params.id, isPublished: true },
});
if (!video) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
if (video.requiredLevel > userLevel) {
res.status(403).json({
success: false,
error: "INSUFFICIENT_PLAN",
data: { requiredLevel: video.requiredLevel, userLevel },
});
return;
}
res.json({ success: true, data: { video } });
} catch {
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
export default router;

View File

@@ -29,6 +29,7 @@ services:
DB_USER: ${DB_USER:-originsdigital} DB_USER: ${DB_USER:-originsdigital}
DB_PASSWORD: ${DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
SUPER_OAUTH_URL: ${SUPER_OAUTH_URL:-https://superoauth.tetardtek.com}
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy

3
frontend/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# URL complète d'autorisation OAuth — SuperOAuth
# Format : https://superoauth.tetardtek.com/oauth/authorize?client_id=XXX&redirect_uri=http://localhost:5173/callback&response_type=token
VITE_SUPEROAUTH_AUTHORIZE_URL=

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,9 @@
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "^5.4.3", "typescript": "^5.4.3",
"vite": "^5.2.8" "vite": "^5.2.8"
} }

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,9 +1,32 @@
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Layout from './components/layout/Layout';
import HomePage from './pages/HomePage';
import CallbackPage from './pages/CallbackPage';
type Theme = 'dark' | 'light';
function App() { function App() {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('od-theme') as Theme) ?? 'dark';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('od-theme', theme);
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
return ( return (
<main> <BrowserRouter>
<h1>OriginsDigital v2</h1> <Routes>
<p>Refonte en cours.</p> <Route element={<Layout theme={theme} onToggleTheme={toggleTheme} />}>
</main> <Route path="/" element={<HomePage />} />
<Route path="/callback" element={<CallbackPage />} />
</Route>
</Routes>
</BrowserRouter>
); );
} }

View File

@@ -0,0 +1,70 @@
import { Link } from 'react-router-dom';
import type { User } from '../../hooks/useAuth';
interface HeaderProps {
theme: 'dark' | 'light';
onToggleTheme: () => void;
user: User | null;
}
export default function Header({ theme, onToggleTheme, user }: HeaderProps) {
const loginUrl = import.meta.env.VITE_SUPEROAUTH_AUTHORIZE_URL;
return (
<header className="border-b border-od-border bg-od-surface">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
{/* Logo */}
<Link to="/" className="flex items-center gap-2 group">
<span className="font-mono text-xs font-bold text-od-accent tracking-widest group-hover:text-od-accent-dim transition-colors">
OD
</span>
<span className="text-sm font-semibold text-od-text">
OriginsDigital
</span>
</Link>
{/* Navigation */}
<nav className="flex gap-6">
<Link
to="/"
className="text-sm text-od-muted hover:text-od-text transition-colors"
>
Accueil
</Link>
<Link
to="/videos"
className="text-sm text-od-muted hover:text-od-text transition-colors"
>
Vidéos
</Link>
</nav>
{/* Right — thème + auth */}
<div className="flex items-center gap-4">
<button
onClick={onToggleTheme}
aria-label="Changer le thème"
className="font-mono text-xs text-od-muted hover:text-od-text transition-colors"
>
{theme === 'dark' ? '◑' : '◐'}
</button>
{user ? (
<span className="font-mono text-xs text-od-accent">
{user.nickname}
</span>
) : (
<a
href={loginUrl}
className="rounded border border-od-border px-3 py-1 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-colors"
>
Connexion
</a>
)}
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,25 @@
import { Outlet } from 'react-router-dom';
import Header from './Header';
import { useAuth } from '../../hooks/useAuth';
interface LayoutProps {
theme: 'dark' | 'light';
onToggleTheme: () => void;
}
export default function Layout({ theme, onToggleTheme }: LayoutProps) {
const { user, loading } = useAuth();
return (
<div className="min-h-screen bg-od-bg text-od-text">
<Header
theme={theme}
onToggleTheme={onToggleTheme}
user={loading ? null : user}
/>
<main className="mx-auto max-w-5xl px-4 py-8">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useState, useEffect } from 'react';
import { apiFetch } from '../lib/api';
export interface User {
id: number;
email: string;
nickname: string;
}
interface AuthState {
user: User | null;
loading: boolean;
}
export function useAuth(): AuthState {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
apiFetch<User>('/profile')
.then((u) => { if (!cancelled) setUser(u); })
.catch(() => { if (!cancelled) setUser(null); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
return { user, loading };
}

18
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,18 @@
const BASE = '/api';
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include', // transmet le cookie httpOnly automatiquement
...init,
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
});
if (!res.ok) {
throw new Error(`API ${res.status}: ${path}`);
}
return res.json() as Promise<T>;
}

View File

@@ -1,5 +1,6 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./styles/index.css";
import App from "./App"; import App from "./App";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiFetch } from '../lib/api';
export default function CallbackPage() {
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
// Pas de token dans l'URL → retour silencieux
if (!token) {
navigate('/', { replace: true });
return;
}
// Envoie le token au backend → backend valide + pose le cookie httpOnly
apiFetch<void>('/auth/session', {
method: 'POST',
body: JSON.stringify({ token }),
})
.then(() => navigate('/', { replace: true }))
.catch(() => setError("Échec de l'authentification. Réessaie."));
}, [navigate]);
if (error) {
return (
<div className="flex flex-col items-center gap-4 pt-20">
<p className="text-od-crit">{error}</p>
<a
href="/"
className="font-mono text-xs text-od-muted hover:text-od-text transition-colors"
>
Retour à l'accueil
</a>
</div>
);
}
return (
<div className="flex items-center justify-center pt-20">
<p className="font-mono text-sm text-od-muted">Connexion en cours</p>
</div>
);
}

View File

@@ -0,0 +1,56 @@
export default function HomePage() {
return (
<div className="flex flex-col gap-10">
{/* Hero */}
<section className="border-b border-od-border pb-8">
<h1 className="text-2xl font-semibold text-od-text">
Vidéos & formations
</h1>
<p className="mt-2 text-sm text-od-muted">
Contenu libre et premium connecte-toi pour accéder aux formations complètes.
</p>
</section>
{/* Accès libre */}
<section>
<h2 className="mb-4 font-mono text-xs uppercase tracking-widest text-od-muted">
Accès libre
</h2>
<div className="grid gap-4 sm:grid-cols-2">
<VideoCardPlaceholder tier="free" />
<VideoCardPlaceholder tier="free" />
</div>
</section>
{/* Premium */}
<section>
<h2 className="mb-4 font-mono text-xs uppercase tracking-widest text-od-accent">
Premium
</h2>
<div className="grid gap-4 sm:grid-cols-2">
<VideoCardPlaceholder tier="premium" />
<VideoCardPlaceholder tier="premium" />
</div>
</section>
</div>
);
}
function VideoCardPlaceholder({ tier }: { tier: 'free' | 'premium' }) {
return (
<div className="flex flex-col gap-3 rounded border border-od-border bg-od-surface p-4">
{/* Thumbnail */}
<div className="h-28 rounded bg-od-surface-hi" />
{/* Title skeleton */}
<div className="h-3 w-3/4 rounded bg-od-surface-hi" />
<div className="h-2 w-1/2 rounded bg-od-surface-hi" />
{tier === 'premium' && (
<span className="self-start rounded border border-od-accent px-2 py-0.5 font-mono text-xs text-od-accent">
Premium
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ─── Void Dark (défaut) ─────────────────────────────────────────────────── */
:root,
[data-theme="dark"] {
--od-bg: #0a0a0d; /* fond principal — quasi-noir cool */
--od-surface: #111115; /* panneaux, cartes */
--od-surface-hi: #191920; /* survol, éléments élevés */
--od-border: #222228; /* séparateurs subtils */
--od-text: #dddde8; /* texte principal */
--od-muted: #62626e; /* texte secondaire, labels */
--od-accent: #d4a853; /* or chaud — premium */
--od-accent-dim: #a07830; /* survol accent */
--od-crit: #d95f5f; /* erreurs */
--od-ok: #5fc875; /* succès */
}
/* ─── Void Light ─────────────────────────────────────────────────────────── */
[data-theme="light"] {
--od-bg: #f2f2f5;
--od-surface: #ffffff;
--od-surface-hi: #e8e8ee;
--od-border: #d0d0da;
--od-text: #14141a;
--od-muted: #6a6a78;
--od-accent: #a07830;
--od-accent-dim: #7a5c20;
--od-crit: #c04040;
--od-ok: #3aa855;
}
/* ─── Base ───────────────────────────────────────────────────────────────── */
body {
background-color: var(--od-bg);
color: var(--od-text);
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPEROAUTH_AUTHORIZE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,31 @@
import type { Config } from 'tailwindcss';
// Design system "Void" — palette custom OriginsDigital
// Les couleurs sont définies comme variables CSS dans src/styles/index.css
// → thème sombre/clair géré via data-theme="dark|light" sur <html>
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
od: {
bg: 'var(--od-bg)',
surface: 'var(--od-surface)',
'surface-hi': 'var(--od-surface-hi)',
border: 'var(--od-border)',
text: 'var(--od-text)',
muted: 'var(--od-muted)',
accent: 'var(--od-accent)',
'accent-dim': 'var(--od-accent-dim)',
crit: 'var(--od-crit)',
ok: 'var(--od-ok)',
},
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', '"Fira Code"', 'ui-monospace', 'monospace'],
},
},
},
plugins: [],
} satisfies Config;