docker test
Build and Publish Docker Image / build-and-push-image (push) Successful in 1m9s

This commit is contained in:
Zed
2026-07-11 02:24:08 +02:00
parent ce1db937f2
commit 9a63240113
18 changed files with 938 additions and 6 deletions
+12 -3
View File
@@ -1,3 +1,10 @@
# Stack complet Skori, à lancer sur l'hôte 192.168.10.174 :
# docker compose up -d --build
# Accès : http://192.168.10.174:8080
#
# Simplifié pour l'instant : mots de passe en dur (pas de .env), déploiement HTTP
# sur le LAN. nginx sert le frontend et proxifie /api vers le backend (même
# origine), donc le navigateur ne contacte que 192.168.10.174:8080.
version: '3.8'
services:
@@ -21,8 +28,10 @@ services:
environment:
NODE_ENV: production
PORT: 3001
DATABASE_URL: postgres://skori:${POSTGRES_PASSWORD:-skori}@postgres:5432/skori
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
DATABASE_URL: postgres://skori:skori@postgres:5432/skori
JWT_SECRET: skori-lan-secret-please-change-later
# HTTP sur le LAN : le cookie refresh ne doit pas être en Secure
COOKIE_SECURE: "false"
depends_on:
postgres:
condition: service_healthy
@@ -33,7 +42,7 @@ services:
restart: unless-stopped
environment:
POSTGRES_USER: skori
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-skori}
POSTGRES_PASSWORD: skori
POSTGRES_DB: skori
volumes:
- skori-pgdata:/var/lib/postgresql/data
+2
View File
@@ -6,6 +6,7 @@ import { errorHandler } from "./middleware/errorHandler.js";
import { authRouter } from "./modules/auth/routes.js";
import { usersRouter } from "./modules/users/routes.js";
import { syncRouter } from "./modules/sync/routes.js";
import { friendsRouter } from "./modules/friends/routes.js";
export function createApp() {
const app = express();
@@ -24,6 +25,7 @@ export function createApp() {
app.use("/api/v1/auth", authRouter);
app.use("/api/v1", usersRouter);
app.use("/api/v1/sync", syncRouter);
app.use("/api/v1/friends", friendsRouter);
app.use(errorHandler);
+6
View File
@@ -3,6 +3,12 @@ export const env = {
databaseUrl: process.env.DATABASE_URL || "",
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-me",
isProduction: process.env.NODE_ENV === "production",
// Whether the refresh cookie is flagged Secure (HTTPS only). Defaults to
// production, but can be forced off for a plain-HTTP LAN deployment.
cookieSecure:
process.env.COOKIE_SECURE !== undefined
? process.env.COOKIE_SECURE === "true"
: process.env.NODE_ENV === "production",
corsOrigins: (process.env.CORS_ORIGINS || "")
.split(",")
.map((s) => s.trim())
+13
View File
@@ -61,6 +61,19 @@ const STATEMENTS = [
deleted_at bigint
)`,
`CREATE INDEX IF NOT EXISTS sessions_owner_updated_idx ON sessions (owner_id, updated_at)`,
`CREATE TABLE IF NOT EXISTS friendships (
id uuid PRIMARY KEY,
requester_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
addressee_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status text NOT NULL,
created_at bigint NOT NULL,
updated_at bigint NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS friendships_requester_idx ON friendships (requester_id)`,
`CREATE INDEX IF NOT EXISTS friendships_addressee_idx ON friendships (addressee_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS friendships_pair_uidx ON friendships (
LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id)
)`,
];
export async function ensureSchema(db: AppDatabase) {
+21
View File
@@ -74,6 +74,27 @@ export const players = pgTable(
}),
);
export const friendships = pgTable(
"friendships",
{
id: uuid("id").primaryKey(),
requesterId: uuid("requester_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
addresseeId: uuid("addressee_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// "pending" | "accepted" | "blocked"
status: text("status").notNull(),
createdAt: ms("created_at").notNull(),
updatedAt: ms("updated_at").notNull(),
},
(t) => ({
requesterIdx: index("friendships_requester_idx").on(t.requesterId),
addresseeIdx: index("friendships_addressee_idx").on(t.addresseeId),
}),
);
export const sessions = pgTable(
"sessions",
{
+1 -1
View File
@@ -29,7 +29,7 @@ const loginSchema = z.object({
function setRefreshCookie(res: Response, token: string) {
res.cookie(REFRESH_COOKIE_NAME, token, {
httpOnly: true,
secure: env.isProduction,
secure: env.cookieSecure,
sameSite: "strict",
maxAge: REFRESH_TOKEN_TTL_MS,
path: "/api/v1/auth",
+44
View File
@@ -0,0 +1,44 @@
import type { Request, Response } from "express";
import { z } from "zod";
import * as friends from "./service.js";
const sendSchema = z.object({ username: z.string().min(1) });
export async function list(req: Request, res: Response) {
res.json({ friends: await friends.listFriends(req.userId!) });
}
export async function requests(req: Request, res: Response) {
res.json(await friends.listRequests(req.userId!));
}
export async function search(req: Request, res: Response) {
const q = String(req.query.q ?? "");
res.json({ results: await friends.searchUsers(req.userId!, q) });
}
export async function sendRequest(req: Request, res: Response) {
const { username } = sendSchema.parse(req.body);
const result = await friends.sendRequest(req.userId!, username);
res.status(201).json(result);
}
export async function accept(req: Request, res: Response) {
await friends.acceptRequest(req.userId!, req.params.id);
res.json({ ok: true });
}
export async function decline(req: Request, res: Response) {
await friends.declineRequest(req.userId!, req.params.id);
res.json({ ok: true });
}
export async function unfriend(req: Request, res: Response) {
await friends.removeFriend(req.userId!, req.params.id);
res.json({ ok: true });
}
export async function block(req: Request, res: Response) {
await friends.blockFriend(req.userId!, req.params.id);
res.json({ ok: true });
}
+17
View File
@@ -0,0 +1,17 @@
import { Router } from "express";
import { requireAuth } from "../../middleware/auth.js";
import { asyncHandler } from "../../middleware/errorHandler.js";
import * as controller from "./controller.js";
export const friendsRouter = Router();
friendsRouter.use(requireAuth);
friendsRouter.get("/", asyncHandler(controller.list));
friendsRouter.get("/requests", asyncHandler(controller.requests));
friendsRouter.get("/search", asyncHandler(controller.search));
friendsRouter.post("/requests", asyncHandler(controller.sendRequest));
friendsRouter.post("/requests/:id/accept", asyncHandler(controller.accept));
friendsRouter.post("/requests/:id/decline", asyncHandler(controller.decline));
friendsRouter.delete("/:id", asyncHandler(controller.unfriend));
friendsRouter.post("/:id/block", asyncHandler(controller.block));
+220
View File
@@ -0,0 +1,220 @@
import { and, eq, ilike, inArray, or, ne } from "drizzle-orm";
import { getDb } from "../../db/client.js";
import { users, friendships } from "../../db/schema.js";
import { HttpError } from "../../middleware/errorHandler.js";
import { newId } from "../../utils/tokens.js";
export interface FriendUser {
id: string;
username: string | null;
displayName: string;
avatarUrl: string | null;
}
function toFriendUser(u: typeof users.$inferSelect): FriendUser {
return {
id: u.id,
username: u.username,
displayName: u.displayName,
avatarUrl: u.avatarUrl,
};
}
async function usersByIds(ids: string[]): Promise<Map<string, FriendUser>> {
if (ids.length === 0) return new Map();
const db = getDb();
const rows = await db.select().from(users).where(inArray(users.id, ids));
return new Map(rows.map((u) => [u.id, toFriendUser(u)]));
}
// All friendship rows involving `meId`, in either direction.
async function myFriendships(meId: string) {
const db = getDb();
return db
.select()
.from(friendships)
.where(
or(eq(friendships.requesterId, meId), eq(friendships.addresseeId, meId)),
);
}
export async function listFriends(meId: string) {
const rows = (await myFriendships(meId)).filter(
(f) => f.status === "accepted",
);
const otherIds = rows.map((f) =>
f.requesterId === meId ? f.addresseeId : f.requesterId,
);
const userMap = await usersByIds(otherIds);
return rows
.map((f) => {
const otherId = f.requesterId === meId ? f.addresseeId : f.requesterId;
const user = userMap.get(otherId);
return user ? { friendshipId: f.id, user } : null;
})
.filter(Boolean);
}
export async function listRequests(meId: string) {
const rows = (await myFriendships(meId)).filter(
(f) => f.status === "pending",
);
const otherIds = rows.map((f) =>
f.requesterId === meId ? f.addresseeId : f.requesterId,
);
const userMap = await usersByIds(otherIds);
const incoming: any[] = [];
const outgoing: any[] = [];
for (const f of rows) {
const otherId = f.requesterId === meId ? f.addresseeId : f.requesterId;
const user = userMap.get(otherId);
if (!user) continue;
if (f.addresseeId === meId) incoming.push({ friendshipId: f.id, user });
else outgoing.push({ friendshipId: f.id, user });
}
return { incoming, outgoing };
}
export async function searchUsers(meId: string, query: string) {
const db = getDb();
const q = query.trim();
if (q.length < 2) return [];
const found = await db
.select()
.from(users)
.where(and(ilike(users.username, `${q}%`), ne(users.id, meId)))
.limit(10);
// Annotate each result with the current relationship (for button state).
const rels = await myFriendships(meId);
const relByOther = new Map<string, string>();
for (const f of rels) {
const otherId = f.requesterId === meId ? f.addresseeId : f.requesterId;
const label =
f.status === "accepted"
? "friends"
: f.status === "blocked"
? "blocked"
: f.requesterId === meId
? "pending_out"
: "pending_in";
relByOther.set(otherId, label);
}
return found.map((u) => ({
...toFriendUser(u),
relationship: relByOther.get(u.id) ?? "none",
}));
}
export async function sendRequest(meId: string, username: string) {
const db = getDb();
const [target] = await db
.select()
.from(users)
.where(eq(users.username, username))
.limit(1);
if (!target) throw new HttpError(404, "user_not_found");
if (target.id === meId) throw new HttpError(400, "cannot_add_self");
const [existing] = await db
.select()
.from(friendships)
.where(
or(
and(
eq(friendships.requesterId, meId),
eq(friendships.addresseeId, target.id),
),
and(
eq(friendships.requesterId, target.id),
eq(friendships.addresseeId, meId),
),
),
)
.limit(1);
if (existing) {
if (existing.status === "accepted")
throw new HttpError(409, "already_friends");
if (existing.status === "blocked") throw new HttpError(403, "blocked");
throw new HttpError(409, "request_already_exists");
}
const now = Date.now();
const [row] = await db
.insert(friendships)
.values({
id: newId(),
requesterId: meId,
addresseeId: target.id,
status: "pending",
createdAt: now,
updatedAt: now,
})
.returning();
return { friendshipId: row.id, user: toFriendUser(target) };
}
export async function acceptRequest(meId: string, friendshipId: string) {
const db = getDb();
const [f] = await db
.select()
.from(friendships)
.where(eq(friendships.id, friendshipId))
.limit(1);
if (!f || f.addresseeId !== meId || f.status !== "pending") {
throw new HttpError(404, "request_not_found");
}
await db
.update(friendships)
.set({ status: "accepted", updatedAt: Date.now() })
.where(eq(friendships.id, friendshipId));
}
export async function declineRequest(meId: string, friendshipId: string) {
const db = getDb();
const [f] = await db
.select()
.from(friendships)
.where(eq(friendships.id, friendshipId))
.limit(1);
if (!f || f.addresseeId !== meId || f.status !== "pending") {
throw new HttpError(404, "request_not_found");
}
await db.delete(friendships).where(eq(friendships.id, friendshipId));
}
export async function removeFriend(meId: string, friendshipId: string) {
const db = getDb();
const [f] = await db
.select()
.from(friendships)
.where(eq(friendships.id, friendshipId))
.limit(1);
if (!f || (f.requesterId !== meId && f.addresseeId !== meId)) {
throw new HttpError(404, "friendship_not_found");
}
await db.delete(friendships).where(eq(friendships.id, friendshipId));
}
export async function blockFriend(meId: string, friendshipId: string) {
const db = getDb();
const [f] = await db
.select()
.from(friendships)
.where(eq(friendships.id, friendshipId))
.limit(1);
if (!f || (f.requesterId !== meId && f.addresseeId !== meId)) {
throw new HttpError(404, "friendship_not_found");
}
await db
.update(friendships)
.set({ status: "blocked", updatedAt: Date.now() })
.where(eq(friendships.id, friendshipId));
}
+72
View File
@@ -160,6 +160,78 @@ async function main() {
// --- H. Auth required on sync ---
const noAuth = await req("GET", "/sync?since=0");
check("sync without token is 401", noAuth.status === 401);
// --- I. Friends: two accounts befriend each other ---
const emailF = `friend${Date.now()}@test.dev`;
const regF = await req("POST", "/auth/register", {
email: emailF,
password: "supersecret",
displayName: "Zoe",
username: `zoe_${Date.now().toString(36)}`,
});
check("second account registers with username", regF.status === 201);
const tokenF = regF.json.accessToken;
const zoeUsername = regF.json.user.username;
// Alice needs a username too so Zoe could find her back (not required here)
await req("PATCH", "/me", { username: `alice_${Date.now().toString(36)}` }, tokenA);
// Alice searches for Zoe by username
const searchRes = await req(
"GET",
`/friends/search?q=${zoeUsername}`,
undefined,
tokenA,
);
check("search finds the user by username", searchRes.json?.results?.some((u: any) => u.id === regF.json.user.id));
// Alice sends a friend request to Zoe
const sendRes = await req(
"POST",
"/friends/requests",
{ username: zoeUsername },
tokenA,
);
check("friend request created", sendRes.status === 201);
const friendshipId = sendRes.json.friendshipId;
// Duplicate request is rejected
const dupRes = await req(
"POST",
"/friends/requests",
{ username: zoeUsername },
tokenA,
);
check("duplicate friend request rejected", dupRes.status === 409);
// Zoe sees the incoming request
const zoeReqs = await req("GET", "/friends/requests", undefined, tokenF);
check("addressee sees incoming request", zoeReqs.json?.incoming?.some((r: any) => r.friendshipId === friendshipId));
// Alice sees it as outgoing
const aliceReqs = await req("GET", "/friends/requests", undefined, tokenA);
check("requester sees outgoing request", aliceReqs.json?.outgoing?.some((r: any) => r.friendshipId === friendshipId));
// Zoe accepts
const acceptRes = await req(
"POST",
`/friends/requests/${friendshipId}/accept`,
undefined,
tokenF,
);
check("accept returns ok", acceptRes.status === 200);
// Both now list each other as friends
const aliceFriends = await req("GET", "/friends", undefined, tokenA);
const zoeFriends = await req("GET", "/friends", undefined, tokenF);
check("requester now has the friend", aliceFriends.json?.friends?.some((f: any) => f.user.id === regF.json.user.id));
check("addressee now has the friend", zoeFriends.json?.friends?.some((f: any) => f.friendshipId === friendshipId));
// Unfriend
const unfriendRes = await req("DELETE", `/friends/${friendshipId}`, undefined, tokenA);
check("unfriend returns ok", unfriendRes.status === 200);
const aliceAfter = await req("GET", "/friends", undefined, tokenA);
check("friend removed after unfriend", !aliceAfter.json?.friends?.some((f: any) => f.friendshipId === friendshipId));
} finally {
server.close();
}
+2
View File
@@ -10,6 +10,7 @@ import History from "./pages/History";
import Players from "./pages/Players";
import Locations from "./pages/Locations";
import Profiles from "./pages/Profiles";
import Friends from "./pages/Friends";
import Auth from "./pages/Auth";
import Settings from "./pages/Settings";
import Statistics from "./pages/Stats";
@@ -41,6 +42,7 @@ function App() {
<Route path="/players" element={<Players />} />
<Route path="/locations" element={<Locations />} />
<Route path="/profiles" element={<Profiles />} />
<Route path="/friends" element={<Friends />} />
<Route path="/login" element={<Auth />} />
<Route path="/stats" element={<Statistics />} />
<Route path="/settings" element={<Settings />} />
+7
View File
@@ -9,6 +9,7 @@ import {
MapPin,
UserCircle,
ChevronRight,
Users2,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
@@ -101,6 +102,12 @@ export function NavigationMenu() {
>
<MapPin className="w-5 h-5 mr-3 text-primary" /> Emplacements
</button>
<button
onClick={() => nav("/friends")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
>
<Users2 className="w-5 h-5 mr-3 text-primary" /> Amis
</button>
<button
onClick={() => nav("/stats")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
+3 -1
View File
@@ -53,7 +53,9 @@ export default function Auth() {
email: values.email,
password: values.password,
username: values.username || undefined,
displayName: activeProfile?.name,
// Prefer the username as public display name (the local profile name
// defaults to "Moi" and would be identical for everyone).
displayName: values.username || activeProfile?.name,
});
}
navigate("/settings");
+343
View File
@@ -0,0 +1,343 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Users2,
Search,
UserPlus,
Check,
X,
Loader2,
Clock,
LogIn,
AtSign,
} from "lucide-react";
import { useAuthStore } from "../stores/authStore";
import {
useFriendsStore,
SearchResult,
} from "../stores/friendsStore";
import { ApiError } from "../lib/apiClient";
import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Avatar } from "../components/ui/avatar";
import { NavigationMenu } from "../components/NavigationMenu";
export default function Friends() {
const navigate = useNavigate();
const { status, user, updateAccount } = useAuthStore();
const { friends, incoming, outgoing, load, search, sendRequest, accept, decline, unfriend } =
useFriendsStore();
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [usernameInput, setUsernameInput] = useState("");
const [savingUsername, setSavingUsername] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (status === "authenticated") load();
}, [status, load]);
if (status !== "authenticated") {
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
<NavigationMenu />
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
Amis
</h1>
</header>
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
<CardContent className="p-6 text-center space-y-4">
<Users2 className="w-14 h-14 mx-auto opacity-20" />
<p className="font-bold">
Connectez-vous pour ajouter des amis et les retrouver dans vos
parties.
</p>
<Button
className="w-full h-12 rounded-2xl font-black"
onClick={() => navigate("/login")}
>
<LogIn className="w-5 h-5 mr-2" />
Se connecter
</Button>
</CardContent>
</Card>
</div>
);
}
const handleSearch = async () => {
setError(null);
setSearching(true);
try {
setResults(await search(query));
} catch {
setError("Recherche impossible.");
} finally {
setSearching(false);
}
};
const handleSend = async (username: string) => {
setError(null);
try {
await sendRequest(username);
setResults((prev) =>
prev.map((r) =>
r.username === username ? { ...r, relationship: "pending_out" } : r,
),
);
} catch (e) {
const code = e instanceof ApiError ? e.message : "";
setError(
code === "already_friends"
? "Vous êtes déjà amis."
: code === "request_already_exists"
? "Demande déjà envoyée."
: "Impossible d'envoyer la demande.",
);
}
};
const handleSaveUsername = async () => {
const u = usernameInput.trim();
if (!u) return;
setSavingUsername(true);
setError(null);
try {
await updateAccount({ username: u });
} catch (e) {
const code = e instanceof ApiError ? e.message : "";
setError(
code === "username_taken"
? "Ce pseudo est déjà pris."
: "Impossible d'enregistrer le pseudo.",
);
} finally {
setSavingUsername(false);
}
};
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
<NavigationMenu />
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
Amis
</h1>
</header>
{error && (
<p className="text-sm text-destructive font-medium bg-destructive/10 rounded-xl p-3">
{error}
</p>
)}
{/* Prompt to set a username so others can find you */}
{!user?.username ? (
<Card className="border-0 shadow-lg bg-primary/5 backdrop-blur-md">
<CardContent className="p-4 space-y-3">
<p className="text-sm font-bold flex items-center">
<AtSign className="w-4 h-4 mr-2 text-primary" />
Choisissez un pseudo pour que vos amis vous trouvent
</p>
<div className="flex space-x-2">
<Input
value={usernameInput}
onChange={(e) => setUsernameInput(e.target.value)}
placeholder="votre_pseudo"
className="h-12"
/>
<Button
onClick={handleSaveUsername}
disabled={savingUsername || !usernameInput.trim()}
className="rounded-2xl font-bold"
>
{savingUsername ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Check className="w-5 h-5" />
)}
</Button>
</div>
</CardContent>
</Card>
) : (
<p className="text-sm text-muted-foreground px-2">
Votre pseudo : <span className="font-bold">@{user.username}</span>
</p>
)}
{/* Search */}
<div className="flex space-x-2">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Rechercher un pseudo…"
className="h-12"
/>
<Button
onClick={handleSearch}
disabled={searching || query.trim().length < 2}
className="rounded-2xl font-bold"
>
{searching ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Search className="w-5 h-5" />
)}
</Button>
</div>
{results.length > 0 && (
<div className="space-y-2">
{results.map((r) => (
<Card key={r.id} className="border-0 shadow-md bg-background/90">
<CardContent className="p-3 flex items-center justify-between">
<div className="flex items-center min-w-0">
<Avatar src={r.avatarUrl ?? undefined} name={r.displayName} size="md" className="mr-3" />
<div className="min-w-0">
<p className="font-bold truncate">{r.displayName}</p>
{r.username && (
<p className="text-xs text-muted-foreground truncate">
@{r.username}
</p>
)}
</div>
</div>
{r.relationship === "friends" ? (
<span className="text-xs font-bold text-emerald-600">Ami</span>
) : r.relationship === "pending_out" ? (
<span className="text-xs font-bold text-muted-foreground">
Envoyée
</span>
) : r.relationship === "pending_in" ? (
<span className="text-xs font-bold text-primary">
Vous a ajouté
</span>
) : (
<Button
size="sm"
className="rounded-full font-bold"
onClick={() => r.username && handleSend(r.username)}
>
<UserPlus className="w-4 h-4 mr-1" />
Ajouter
</Button>
)}
</CardContent>
</Card>
))}
</div>
)}
{/* Incoming requests */}
{incoming.length > 0 && (
<section className="space-y-2">
<h2 className="text-lg font-bold px-1">Demandes reçues</h2>
{incoming.map((r) => (
<Card key={r.friendshipId} className="border-0 shadow-md bg-background/90">
<CardContent className="p-3 flex items-center justify-between">
<div className="flex items-center min-w-0">
<Avatar src={r.user.avatarUrl ?? undefined} name={r.user.displayName} size="md" className="mr-3" />
<div className="min-w-0">
<p className="font-bold truncate">{r.user.displayName}</p>
{r.user.username && (
<p className="text-xs text-muted-foreground truncate">
@{r.user.username}
</p>
)}
</div>
</div>
<div className="flex space-x-1 shrink-0">
<Button
size="icon"
variant="ghost"
className="text-green-600 bg-green-500/10 hover:bg-green-500/20 rounded-xl"
onClick={() => accept(r.friendshipId)}
>
<Check className="w-5 h-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive hover:bg-destructive/10 rounded-xl"
onClick={() => decline(r.friendshipId)}
>
<X className="w-5 h-5" />
</Button>
</div>
</CardContent>
</Card>
))}
</section>
)}
{/* Outgoing requests */}
{outgoing.length > 0 && (
<section className="space-y-2">
<h2 className="text-lg font-bold px-1">Demandes envoyées</h2>
{outgoing.map((r) => (
<Card key={r.friendshipId} className="border-0 shadow-sm bg-background/70">
<CardContent className="p-3 flex items-center justify-between">
<div className="flex items-center min-w-0">
<Avatar src={r.user.avatarUrl ?? undefined} name={r.user.displayName} size="md" className="mr-3 opacity-70" />
<p className="font-bold truncate">{r.user.displayName}</p>
</div>
<span className="flex items-center text-xs font-bold text-muted-foreground shrink-0">
<Clock className="w-3.5 h-3.5 mr-1" />
En attente
</span>
</CardContent>
</Card>
))}
</section>
)}
{/* Friends list */}
<section className="space-y-2">
<h2 className="text-lg font-bold px-1">
Mes amis {friends.length > 0 && `(${friends.length})`}
</h2>
{friends.length === 0 ? (
<div className="text-center text-muted-foreground py-8 bg-background/60 backdrop-blur-sm rounded-[2rem]">
<Users2 className="w-12 h-12 mx-auto mb-2 opacity-20" />
<p className="text-sm font-medium">
Aucun ami pour l'instant. Recherchez un pseudo ci-dessus.
</p>
</div>
) : (
friends.map((f) => (
<Card key={f.friendshipId} className="border-0 shadow-md bg-background/90">
<CardContent className="p-3 flex items-center justify-between">
<div className="flex items-center min-w-0">
<Avatar src={f.user.avatarUrl ?? undefined} name={f.user.displayName} size="md" className="mr-3" />
<div className="min-w-0">
<p className="font-bold truncate">{f.user.displayName}</p>
{f.user.username && (
<p className="text-xs text-muted-foreground truncate">
@{f.user.username}
</p>
)}
</div>
</div>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:bg-destructive/10 rounded-full text-xs font-bold shrink-0"
onClick={() => unfriend(f.friendshipId)}
>
Retirer
</Button>
</CardContent>
</Card>
))
)}
</section>
</div>
);
}
+60 -1
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
ChevronLeft,
@@ -17,6 +17,8 @@ import { db } from "../database/db";
import { getGameConfig } from "../games";
import { useGameStore } from "../stores/gameStore";
import { useProfileStore } from "../stores/profileStore";
import { useAuthStore } from "../stores/authStore";
import { useFriendsStore, FriendEntry } from "../stores/friendsStore";
import { useGameTheme } from "../hooks/useGameTheme";
import { Player, SavedPlayer } from "../types";
import { Button } from "../components/ui/button";
@@ -85,6 +87,12 @@ export default function NewGame() {
const startNewGame = useGameStore((state) => state.startNewGame);
const activeProfileId = useProfileStore((state) => state.activeProfileId);
const authStatus = useAuthStore((state) => state.status);
const { friends, load: loadFriends } = useFriendsStore();
useEffect(() => {
if (authStatus === "authenticated") loadFriends();
}, [authStatus, loadFriends]);
const [players, setPlayers] = useState<Player[]>([
{ id: generateId(), name: "" },
@@ -123,6 +131,9 @@ export default function NewGame() {
(p) => p.name.trim().toLowerCase() === sp.name.trim().toLowerCase(),
),
);
const availableFriends = friends.filter(
(f) => !players.some((p) => p.linkedUserId === f.user.id),
);
if (!gameConfig) {
return <div className="p-4 text-center">Jeu introuvable</div>;
@@ -158,6 +169,25 @@ export default function NewGame() {
}
};
const handleQuickAddFriend = (friend: FriendEntry) => {
// Snapshot the friend's name/avatar into the session, tagged with their
// server account id (linkedUserId).
const newPlayer: Player = {
id: generateId(),
name: friend.user.displayName,
avatar: friend.user.avatarUrl ?? undefined,
linkedUserId: friend.user.id,
};
const emptyIndex = players.findIndex((p) => p.name.trim() === "");
if (emptyIndex !== -1) {
const newPlayers = [...players];
newPlayers[emptyIndex] = newPlayer;
setPlayers(newPlayers);
} else if (players.length < gameConfig.maxPlayers) {
setPlayers([...players, newPlayer]);
}
};
const handleRemovePlayer = (id: string) => {
if (players.length > gameConfig.minPlayers) {
setPlayers(players.filter((p) => p.id !== id));
@@ -359,6 +389,35 @@ export default function NewGame() {
</div>
</div>
)}
{availableFriends.length > 0 && (
<div className="mt-6 pt-2 px-1">
<p className="text-sm font-bold text-muted-foreground mb-3 flex items-center">
<Users className="w-4 h-4 mr-2" /> Amis
</p>
<div className="flex flex-wrap gap-2">
{availableFriends.map((f) => (
<div
key={f.user.id}
className="flex items-center bg-background/80 backdrop-blur-md hover:bg-primary/10 hover:text-primary px-3 py-2 rounded-full cursor-pointer active:scale-95 transition-all text-sm font-bold border-2 border-transparent hover:border-primary/20 shadow-sm"
onClick={() => handleQuickAddFriend(f)}
>
{f.user.avatarUrl ? (
<Avatar
src={f.user.avatarUrl}
name={f.user.displayName}
size="sm"
className="mr-2 border-none w-6 h-6 shadow-sm"
/>
) : (
<Plus className="w-4 h-4 mr-1.5 opacity-50" />
)}
{f.user.displayName}
</div>
))}
</div>
</div>
)}
</section>
{locations.length > 0 && (
+13
View File
@@ -35,6 +35,11 @@ interface AuthState {
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
restore: () => Promise<void>;
updateAccount: (changes: {
username?: string;
displayName?: string;
avatarUrl?: string | null;
}) => Promise<void>;
}
async function linkActiveProfileToUser(userId: string) {
@@ -93,6 +98,14 @@ export const useAuthStore = create<AuthState>((set) => ({
set({ user: null, status: "anonymous" });
},
updateAccount: async (changes) => {
const res = await apiJson<{ user: AuthUser }>("/me", {
method: "PATCH",
body: JSON.stringify(changes),
});
set({ user: res.user });
},
// On boot: try to silently resume a session via the refresh cookie.
restore: async () => {
try {
+101
View File
@@ -0,0 +1,101 @@
import { create } from "zustand";
import { apiJson, getAccessToken } from "../lib/apiClient";
export interface FriendUser {
id: string;
username: string | null;
displayName: string;
avatarUrl: string | null;
}
export interface FriendEntry {
friendshipId: string;
user: FriendUser;
}
export interface SearchResult extends FriendUser {
relationship:
| "none"
| "pending_out"
| "pending_in"
| "friends"
| "blocked";
}
interface FriendsState {
friends: FriendEntry[];
incoming: FriendEntry[];
outgoing: FriendEntry[];
loading: boolean;
load: () => Promise<void>;
search: (q: string) => Promise<SearchResult[]>;
sendRequest: (username: string) => Promise<void>;
accept: (friendshipId: string) => Promise<void>;
decline: (friendshipId: string) => Promise<void>;
unfriend: (friendshipId: string) => Promise<void>;
}
export const useFriendsStore = create<FriendsState>((set, get) => ({
friends: [],
incoming: [],
outgoing: [],
loading: false,
load: async () => {
if (!getAccessToken()) {
set({ friends: [], incoming: [], outgoing: [] });
return;
}
set({ loading: true });
try {
const [friendsRes, requestsRes] = await Promise.all([
apiJson<{ friends: FriendEntry[] }>("/friends"),
apiJson<{ incoming: FriendEntry[]; outgoing: FriendEntry[] }>(
"/friends/requests",
),
]);
set({
friends: friendsRes.friends,
incoming: requestsRes.incoming,
outgoing: requestsRes.outgoing,
});
} finally {
set({ loading: false });
}
},
search: async (q) => {
if (!getAccessToken() || q.trim().length < 2) return [];
const res = await apiJson<{ results: SearchResult[] }>(
`/friends/search?q=${encodeURIComponent(q.trim())}`,
);
return res.results;
},
sendRequest: async (username) => {
await apiJson("/friends/requests", {
method: "POST",
body: JSON.stringify({ username }),
});
await get().load();
},
accept: async (friendshipId) => {
await apiJson(`/friends/requests/${friendshipId}/accept`, {
method: "POST",
});
await get().load();
},
decline: async (friendshipId) => {
await apiJson(`/friends/requests/${friendshipId}/decline`, {
method: "POST",
});
await get().load();
},
unfriend: async (friendshipId) => {
await apiJson(`/friends/${friendshipId}`, { method: "DELETE" });
await get().load();
},
}));
+1
View File
@@ -47,6 +47,7 @@ export interface Player {
name: string;
color?: string;
avatar?: string;
linkedUserId?: string; // compte serveur de l'ami tagué comme co-joueur (Phase 4)
}
export interface RoundScore {