This commit is contained in:
+12
-3
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
}));
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user