feat: local device profile (name + photo) replaces animal naming
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
- Users set their own device name and optional profile picture - Profile persisted in localStorage (no account needed) - Auto-detect device type from user agent (iPhone, Mac, Android...) - Server uses client-provided profile instead of generating names - PeerAvatar shows photo or device icon instead of animal emojis - ProfileSetup modal on first visit + editable from header
This commit is contained in:
parent
953f3cb8a1
commit
4dbdfedae0
@ -4,10 +4,10 @@ import { networkInterfaces } from "node:os";
|
|||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import {
|
import {
|
||||||
generateDisplayName,
|
|
||||||
type ClientMessage,
|
type ClientMessage,
|
||||||
type ServerMessage,
|
type ServerMessage,
|
||||||
type PeerInfo,
|
type PeerInfo,
|
||||||
|
type DeviceType,
|
||||||
} from "@anydrop/shared";
|
} from "@anydrop/shared";
|
||||||
import { RoomManager, type Client } from "./rooms.js";
|
import { RoomManager, type Client } from "./rooms.js";
|
||||||
|
|
||||||
@ -17,6 +17,7 @@ const BASE_URL = process.env.BASE_URL || "http://localhost:5173";
|
|||||||
const RATE_LIMIT_CONNECTIONS_PER_IP = 10;
|
const RATE_LIMIT_CONNECTIONS_PER_IP = 10;
|
||||||
const RATE_LIMIT_WINDOW_MS = 60_000;
|
const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||||
const RATE_LIMIT_MESSAGES = 100;
|
const RATE_LIMIT_MESSAGES = 100;
|
||||||
|
const MAX_AVATAR_SIZE = 100_000; // 100KB max for avatar data URLs
|
||||||
|
|
||||||
// Track connection rate per IP
|
// Track connection rate per IP
|
||||||
const connectionCounts = new Map<string, { count: number; windowStart: number }>();
|
const connectionCounts = new Map<string, { count: number; windowStart: number }>();
|
||||||
@ -62,14 +63,6 @@ function detectLanSubnet(): string | null {
|
|||||||
const serverLanSubnet = detectLanSubnet();
|
const serverLanSubnet = detectLanSubnet();
|
||||||
console.log(`[init] detected LAN subnet: ${serverLanSubnet ?? "none"}`);
|
console.log(`[init] detected LAN subnet: ${serverLanSubnet ?? "none"}`);
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the LAN grouping key from an IP.
|
|
||||||
* - Loopback (::1, 127.x): use the server's own LAN subnet so localhost
|
|
||||||
* clients end up in the same room as LAN clients.
|
|
||||||
* - Private IPs: use the /24 subnet (e.g. "192.168.1") so all devices
|
|
||||||
* on the same WiFi share one LAN room.
|
|
||||||
* - Public IPs: use the full IP (devices behind the same NAT share one).
|
|
||||||
*/
|
|
||||||
function getLanGroupKey(rawIP: string): string {
|
function getLanGroupKey(rawIP: string): string {
|
||||||
const ip = normalizeIP(rawIP);
|
const ip = normalizeIP(rawIP);
|
||||||
if (isLoopback(ip) || ip === "unknown") {
|
if (isLoopback(ip) || ip === "unknown") {
|
||||||
@ -119,6 +112,25 @@ function checkMessageRate(client: Client): boolean {
|
|||||||
return client.messageCount <= RATE_LIMIT_MESSAGES;
|
return client.messageCount <= RATE_LIMIT_MESSAGES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sanitize client-provided display name */
|
||||||
|
function sanitizeName(name: string): string {
|
||||||
|
return name.trim().slice(0, 30) || "Appareil";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate device type */
|
||||||
|
function validateDeviceType(dt: string): DeviceType {
|
||||||
|
const valid: DeviceType[] = ["phone", "tablet", "laptop", "desktop"];
|
||||||
|
return valid.includes(dt as DeviceType) ? (dt as DeviceType) : "laptop";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate avatar (must be a small data URL or undefined) */
|
||||||
|
function validateAvatar(avatar: unknown): string | undefined {
|
||||||
|
if (typeof avatar !== "string") return undefined;
|
||||||
|
if (!avatar.startsWith("data:image/")) return undefined;
|
||||||
|
if (avatar.length > MAX_AVATAR_SIZE) return undefined;
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
|
||||||
// ── HTTP server (health check) ──
|
// ── HTTP server (health check) ──
|
||||||
|
|
||||||
const httpServer = createServer((req, res) => {
|
const httpServer = createServer((req, res) => {
|
||||||
@ -149,13 +161,14 @@ wss.on("connection", (ws, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const peerId = nanoid(8);
|
const peerId = nanoid(8);
|
||||||
const displayName = generateDisplayName();
|
|
||||||
const lanRoomId = roomManager.getLanRoomId(hashIP(groupKey));
|
const lanRoomId = roomManager.getLanRoomId(hashIP(groupKey));
|
||||||
|
|
||||||
|
// Client object created with placeholder name — filled in on "hello"
|
||||||
const client: Client = {
|
const client: Client = {
|
||||||
ws,
|
ws,
|
||||||
peerId,
|
peerId,
|
||||||
displayName,
|
displayName: "Appareil",
|
||||||
|
deviceType: "laptop",
|
||||||
ip,
|
ip,
|
||||||
lanRoomId,
|
lanRoomId,
|
||||||
publicRoomId: null,
|
publicRoomId: null,
|
||||||
@ -180,7 +193,7 @@ wss.on("connection", (ws, req) => {
|
|||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "hello":
|
case "hello":
|
||||||
handleHello(client, msg.joinCode);
|
handleHello(client, msg);
|
||||||
break;
|
break;
|
||||||
case "create-public-room":
|
case "create-public-room":
|
||||||
handleCreatePublicRoom(client);
|
handleCreatePublicRoom(client);
|
||||||
@ -200,7 +213,12 @@ wss.on("connection", (ws, req) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleHello(client: Client, joinCode?: string): void {
|
function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): void {
|
||||||
|
// Apply client-provided profile
|
||||||
|
client.displayName = sanitizeName(msg.deviceName);
|
||||||
|
client.deviceType = validateDeviceType(msg.deviceType);
|
||||||
|
client.avatar = validateAvatar(msg.avatar);
|
||||||
|
|
||||||
// Join LAN room
|
// Join LAN room
|
||||||
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip)));
|
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip)));
|
||||||
roomManager.addClientToRoom(lanRoom, client);
|
roomManager.addClientToRoom(lanRoom, client);
|
||||||
@ -208,28 +226,49 @@ function handleHello(client: Client, joinCode?: string): void {
|
|||||||
const peers: PeerInfo[] = [];
|
const peers: PeerInfo[] = [];
|
||||||
for (const peer of lanRoom.clients.values()) {
|
for (const peer of lanRoom.clients.values()) {
|
||||||
if (peer.peerId !== client.peerId) {
|
if (peer.peerId !== client.peerId) {
|
||||||
peers.push({ peerId: peer.peerId, displayName: peer.displayName });
|
peers.push({
|
||||||
|
peerId: peer.peerId,
|
||||||
|
displayName: peer.displayName,
|
||||||
|
deviceType: peer.deviceType,
|
||||||
|
avatar: peer.avatar,
|
||||||
|
});
|
||||||
// Notify existing peers
|
// Notify existing peers
|
||||||
send(peer.ws, { type: "peer-joined", peerId: client.peerId, displayName: client.displayName });
|
send(peer.ws, {
|
||||||
|
type: "peer-joined",
|
||||||
|
peerId: client.peerId,
|
||||||
|
displayName: client.displayName,
|
||||||
|
deviceType: client.deviceType,
|
||||||
|
avatar: client.avatar,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`[hello] peer=${client.peerId} (${client.displayName}) room=${lanRoom.id} peers_in_room=${lanRoom.clients.size}`);
|
console.log(`[hello] peer=${client.peerId} (${client.displayName}) room=${lanRoom.id} peers_in_room=${lanRoom.clients.size}`);
|
||||||
|
|
||||||
// Join public room if code provided
|
// Join public room if code provided
|
||||||
if (joinCode) {
|
if (msg.joinCode) {
|
||||||
const pubRoom = roomManager.getPublicRoomByCode(joinCode);
|
const pubRoom = roomManager.getPublicRoomByCode(msg.joinCode);
|
||||||
if (!pubRoom) {
|
if (!pubRoom) {
|
||||||
send(client.ws, { type: "error", code: "room-not-found", message: `Room "${joinCode}" not found or expired` });
|
send(client.ws, { type: "error", code: "room-not-found", message: `Room "${msg.joinCode}" not found or expired` });
|
||||||
} else {
|
} else {
|
||||||
client.publicRoomId = pubRoom.id;
|
client.publicRoomId = pubRoom.id;
|
||||||
roomManager.addClientToRoom(pubRoom, client);
|
roomManager.addClientToRoom(pubRoom, client);
|
||||||
for (const peer of pubRoom.clients.values()) {
|
for (const peer of pubRoom.clients.values()) {
|
||||||
if (peer.peerId !== client.peerId) {
|
if (peer.peerId !== client.peerId) {
|
||||||
// Avoid duplicate if peer is already in LAN peers
|
|
||||||
if (!peers.find((p) => p.peerId === peer.peerId)) {
|
if (!peers.find((p) => p.peerId === peer.peerId)) {
|
||||||
peers.push({ peerId: peer.peerId, displayName: peer.displayName });
|
peers.push({
|
||||||
|
peerId: peer.peerId,
|
||||||
|
displayName: peer.displayName,
|
||||||
|
deviceType: peer.deviceType,
|
||||||
|
avatar: peer.avatar,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
send(peer.ws, { type: "peer-joined", peerId: client.peerId, displayName: client.displayName });
|
send(peer.ws, {
|
||||||
|
type: "peer-joined",
|
||||||
|
peerId: client.peerId,
|
||||||
|
displayName: client.displayName,
|
||||||
|
deviceType: client.deviceType,
|
||||||
|
avatar: client.avatar,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -238,7 +277,6 @@ function handleHello(client: Client, joinCode?: string): void {
|
|||||||
send(client.ws, {
|
send(client.ws, {
|
||||||
type: "welcome",
|
type: "welcome",
|
||||||
peerId: client.peerId,
|
peerId: client.peerId,
|
||||||
displayName: client.displayName,
|
|
||||||
roomId: client.lanRoomId,
|
roomId: client.lanRoomId,
|
||||||
peers,
|
peers,
|
||||||
});
|
});
|
||||||
@ -270,14 +308,12 @@ function handleSignal(client: Client, to: string, data: unknown): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleLeave(client: Client): void {
|
function handleLeave(client: Client): void {
|
||||||
// Remove from LAN room
|
|
||||||
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip)));
|
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip)));
|
||||||
roomManager.removeClientFromRoom(lanRoom, client.peerId);
|
roomManager.removeClientFromRoom(lanRoom, client.peerId);
|
||||||
for (const peer of lanRoom.clients.values()) {
|
for (const peer of lanRoom.clients.values()) {
|
||||||
send(peer.ws, { type: "peer-left", peerId: client.peerId });
|
send(peer.ws, { type: "peer-left", peerId: client.peerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from public room
|
|
||||||
if (client.publicRoomId) {
|
if (client.publicRoomId) {
|
||||||
const pubRoom = roomManager.getRoomById(client.publicRoomId);
|
const pubRoom = roomManager.getRoomById(client.publicRoomId);
|
||||||
if (pubRoom) {
|
if (pubRoom) {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { customAlphabet } from "nanoid";
|
import { customAlphabet } from "nanoid";
|
||||||
|
import type { DeviceType } from "@anydrop/shared";
|
||||||
import {
|
import {
|
||||||
SHORT_CODE_ALPHABET,
|
SHORT_CODE_ALPHABET,
|
||||||
SHORT_CODE_LENGTH,
|
SHORT_CODE_LENGTH,
|
||||||
@ -15,6 +16,8 @@ export interface Client {
|
|||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
peerId: string;
|
peerId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
deviceType: DeviceType;
|
||||||
|
avatar?: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
lanRoomId: string;
|
lanRoomId: string;
|
||||||
publicRoomId: string | null;
|
publicRoomId: string | null;
|
||||||
|
|||||||
@ -1,21 +1,39 @@
|
|||||||
const ADJECTIVES = [
|
/** Device types detected from user agent */
|
||||||
"Rouge", "Bleu", "Vert", "Doré", "Violet",
|
export type DeviceType = "phone" | "tablet" | "laptop" | "desktop";
|
||||||
"Blanc", "Noir", "Rose", "Gris", "Orange",
|
|
||||||
"Rapide", "Calme", "Brave", "Agile", "Sage",
|
|
||||||
"Vif", "Fier", "Noble", "Grand", "Petit",
|
|
||||||
];
|
|
||||||
|
|
||||||
const ANIMALS = [
|
/** Detect device type from user agent string (client-side only) */
|
||||||
"Renard", "Tigre", "Ours", "Loup", "Aigle",
|
export function detectDeviceType(ua: string): DeviceType {
|
||||||
"Dauphin", "Faucon", "Lynx", "Panda", "Lion",
|
const lower = ua.toLowerCase();
|
||||||
"Chat", "Hibou", "Cerf", "Koala", "Phoque",
|
|
||||||
"Colibri", "Loutre", "Requin", "Corbeau", "Furet",
|
|
||||||
];
|
|
||||||
|
|
||||||
function pick<T>(arr: T[]): T {
|
// Tablets first (before phone check since some tablets have "mobile")
|
||||||
return arr[Math.floor(Math.random() * arr.length)];
|
if (/ipad/.test(lower) || (/android/.test(lower) && !/mobile/.test(lower))) {
|
||||||
|
return "tablet";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phones
|
||||||
|
if (/iphone|android.*mobile|windows phone/.test(lower)) {
|
||||||
|
return "phone";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else is laptop/desktop — we can't really distinguish,
|
||||||
|
// so default to "laptop" (more common for file sharing use case)
|
||||||
|
return "laptop";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateDisplayName(): string {
|
/** Default device name based on type */
|
||||||
return `${pick(ANIMALS)} ${pick(ADJECTIVES)}`;
|
export function getDefaultDeviceName(ua: string): string {
|
||||||
|
const lower = ua.toLowerCase();
|
||||||
|
|
||||||
|
// Try to extract specific device/OS name
|
||||||
|
if (/iphone/.test(lower)) return "iPhone";
|
||||||
|
if (/ipad/.test(lower)) return "iPad";
|
||||||
|
if (/macintosh|mac os/.test(lower)) return "Mac";
|
||||||
|
if (/windows/.test(lower)) return "PC Windows";
|
||||||
|
if (/android/.test(lower)) {
|
||||||
|
return /mobile/.test(lower) ? "Android" : "Tablette Android";
|
||||||
|
}
|
||||||
|
if (/linux/.test(lower)) return "Linux";
|
||||||
|
if (/chromeos|cros/.test(lower)) return "Chromebook";
|
||||||
|
|
||||||
|
return "Appareil";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
|
import type { DeviceType } from "./names.js";
|
||||||
|
|
||||||
// ── Client → Server messages ──
|
// ── Client → Server messages ──
|
||||||
|
|
||||||
export interface HelloMessage {
|
export interface HelloMessage {
|
||||||
type: "hello";
|
type: "hello";
|
||||||
|
deviceName: string;
|
||||||
|
deviceType: DeviceType;
|
||||||
|
avatar?: string; // base64 data URL (small, <50KB)
|
||||||
joinCode?: string;
|
joinCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,12 +35,13 @@ export type ClientMessage =
|
|||||||
export interface PeerInfo {
|
export interface PeerInfo {
|
||||||
peerId: string;
|
peerId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
deviceType: DeviceType;
|
||||||
|
avatar?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WelcomeMessage {
|
export interface WelcomeMessage {
|
||||||
type: "welcome";
|
type: "welcome";
|
||||||
peerId: string;
|
peerId: string;
|
||||||
displayName: string;
|
|
||||||
roomId: string;
|
roomId: string;
|
||||||
peers: PeerInfo[];
|
peers: PeerInfo[];
|
||||||
}
|
}
|
||||||
@ -51,6 +57,8 @@ export interface PeerJoinedMessage {
|
|||||||
type: "peer-joined";
|
type: "peer-joined";
|
||||||
peerId: string;
|
peerId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
deviceType: DeviceType;
|
||||||
|
avatar?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PeerLeftMessage {
|
export interface PeerLeftMessage {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -1,29 +1,30 @@
|
|||||||
|
import type { DeviceType } from "@anydrop/shared";
|
||||||
|
|
||||||
interface PeerAvatarProps {
|
interface PeerAvatarProps {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
deviceType: DeviceType;
|
||||||
|
avatar?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANIMAL_EMOJIS: Record<string, string> = {
|
const DEVICE_ICONS: Record<DeviceType, string> = {
|
||||||
Renard: "🦊", Tigre: "🐯", Ours: "🐻", Loup: "🐺", Aigle: "🦅",
|
phone: "📱",
|
||||||
Dauphin: "🐬", Faucon: "🦅", Lynx: "🐱", Panda: "🐼", Lion: "🦁",
|
tablet: "📱",
|
||||||
Chat: "🐱", Hibou: "🦉", Cerf: "🦌", Koala: "🐨", Phoque: "🦭",
|
laptop: "💻",
|
||||||
Colibri: "🐦", Loutre: "🦦", Requin: "🦈", Corbeau: "🐦⬛", Furet: "🐾",
|
desktop: "🖥️",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getEmoji(displayName: string): string {
|
|
||||||
const animal = displayName.split(" ")[0];
|
|
||||||
return ANIMAL_EMOJIS[animal] || "📱";
|
|
||||||
}
|
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: "w-12 h-12 text-xl",
|
sm: { container: "w-12 h-12", icon: "text-xl", img: "w-12 h-12" },
|
||||||
md: "w-16 h-16 text-2xl",
|
md: { container: "w-16 h-16", icon: "text-2xl", img: "w-16 h-16" },
|
||||||
lg: "w-20 h-20 text-3xl",
|
lg: { container: "w-20 h-20", icon: "text-3xl", img: "w-20 h-20" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PeerAvatar({ displayName, onClick, isSelected, size = "md" }: PeerAvatarProps) {
|
export default function PeerAvatar({ displayName, deviceType, avatar, onClick, isSelected, size = "md" }: PeerAvatarProps) {
|
||||||
|
const s = sizeClasses[size];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@ -34,16 +35,25 @@ export default function PeerAvatar({ displayName, onClick, isSelected, size = "m
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
${sizeClasses[size]}
|
${s.container}
|
||||||
rounded-full flex items-center justify-center
|
rounded-full flex items-center justify-center overflow-hidden
|
||||||
transition-all duration-200
|
transition-all duration-200
|
||||||
${isSelected
|
${isSelected
|
||||||
? "bg-brand-500 ring-2 ring-brand-400 ring-offset-2 ring-offset-slate-950"
|
? "ring-2 ring-brand-400 ring-offset-2 ring-offset-slate-950"
|
||||||
: "bg-slate-800 hover:bg-slate-700"
|
: ""
|
||||||
}
|
}
|
||||||
|
${avatar ? "" : isSelected ? "bg-brand-500" : "bg-slate-800 hover:bg-slate-700"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{getEmoji(displayName)}
|
{avatar ? (
|
||||||
|
<img
|
||||||
|
src={avatar}
|
||||||
|
alt={displayName}
|
||||||
|
className={`${s.img} rounded-full object-cover`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={s.icon}>{DEVICE_ICONS[deviceType]}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-slate-300 group-hover:text-white transition-colors max-w-[80px] truncate">
|
<span className="text-xs text-slate-300 group-hover:text-white transition-colors max-w-[80px] truncate">
|
||||||
{displayName}
|
{displayName}
|
||||||
|
|||||||
@ -28,6 +28,8 @@ export default function PeerList({ onPeerSelect }: PeerListProps) {
|
|||||||
<PeerAvatar
|
<PeerAvatar
|
||||||
key={peer.peerId}
|
key={peer.peerId}
|
||||||
displayName={peer.displayName}
|
displayName={peer.displayName}
|
||||||
|
deviceType={peer.deviceType}
|
||||||
|
avatar={peer.avatar}
|
||||||
isSelected={selectedPeerId === peer.peerId}
|
isSelected={selectedPeerId === peer.peerId}
|
||||||
onClick={() => onPeerSelect(peer.peerId)}
|
onClick={() => onPeerSelect(peer.peerId)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
144
web/src/components/ProfileSetup.tsx
Normal file
144
web/src/components/ProfileSetup.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { useProfileStore } from "../stores/useProfileStore";
|
||||||
|
|
||||||
|
const MAX_AVATAR_SIZE = 80_000; // ~80KB after base64
|
||||||
|
|
||||||
|
function resizeImage(file: File, maxSize: number): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
// Crop to square, max 128px
|
||||||
|
const side = Math.min(img.width, img.height);
|
||||||
|
const sx = (img.width - side) / 2;
|
||||||
|
const sy = (img.height - side) / 2;
|
||||||
|
let outSize = Math.min(side, 128);
|
||||||
|
|
||||||
|
canvas.width = outSize;
|
||||||
|
canvas.height = outSize;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.drawImage(img, sx, sy, side, side, 0, 0, outSize, outSize);
|
||||||
|
|
||||||
|
// Try JPEG at decreasing quality until small enough
|
||||||
|
let quality = 0.8;
|
||||||
|
let dataUrl = canvas.toDataURL("image/jpeg", quality);
|
||||||
|
while (dataUrl.length > maxSize && quality > 0.2) {
|
||||||
|
quality -= 0.1;
|
||||||
|
dataUrl = canvas.toDataURL("image/jpeg", quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataUrl.length > maxSize) {
|
||||||
|
// Shrink further
|
||||||
|
outSize = 64;
|
||||||
|
canvas.width = outSize;
|
||||||
|
canvas.height = outSize;
|
||||||
|
ctx.drawImage(img, sx, sy, side, side, 0, 0, outSize, outSize);
|
||||||
|
dataUrl = canvas.toDataURL("image/jpeg", 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(dataUrl);
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileSetupProps {
|
||||||
|
onDone: () => void;
|
||||||
|
isEditing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileSetup({ onDone, isEditing }: ProfileSetupProps) {
|
||||||
|
const { deviceName, avatar, setDeviceName, setAvatar, setUp } = useProfileStore();
|
||||||
|
const [name, setName] = useState(deviceName);
|
||||||
|
const [preview, setPreview] = useState<string | null>(avatar);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handlePhoto = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const dataUrl = await resizeImage(file, MAX_AVATAR_SIZE);
|
||||||
|
setPreview(dataUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setDeviceName(name);
|
||||||
|
setAvatar(preview);
|
||||||
|
setUp();
|
||||||
|
onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-sm shadow-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-white text-center mb-6">
|
||||||
|
{isEditing ? "Modifier le profil" : "Votre appareil"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="relative w-24 h-24 rounded-full bg-slate-800 hover:bg-slate-700
|
||||||
|
flex items-center justify-center overflow-hidden
|
||||||
|
transition-colors border-2 border-dashed border-slate-600 hover:border-brand-400"
|
||||||
|
>
|
||||||
|
{preview ? (
|
||||||
|
<img src={preview} alt="Avatar" className="w-full h-full object-cover rounded-full" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center text-slate-400">
|
||||||
|
<span className="text-2xl">📷</span>
|
||||||
|
<span className="text-[10px] mt-1">Photo</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handlePhoto}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove photo */}
|
||||||
|
{preview && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPreview(null)}
|
||||||
|
className="block mx-auto mb-4 text-xs text-slate-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
Supprimer la photo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-xs text-slate-400 mb-1.5">Nom de l'appareil</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
maxLength={30}
|
||||||
|
placeholder="ex: iPhone d'Arthur"
|
||||||
|
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-xl
|
||||||
|
text-white text-sm placeholder:text-slate-500
|
||||||
|
focus:outline-none focus:border-brand-500 transition-colors"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="w-full py-2.5 bg-brand-600 hover:bg-brand-500 text-white
|
||||||
|
rounded-xl text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{isEditing ? "Enregistrer" : "C'est parti"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import {
|
|||||||
createFileReceiver,
|
createFileReceiver,
|
||||||
} from "../lib/fileTransfer";
|
} from "../lib/fileTransfer";
|
||||||
import { useStore } from "../stores/useStore";
|
import { useStore } from "../stores/useStore";
|
||||||
|
import { useProfileStore } from "../stores/useProfileStore";
|
||||||
|
|
||||||
function downloadBlob(blob: Blob, fileName: string) {
|
function downloadBlob(blob: Blob, fileName: string) {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@ -52,11 +53,8 @@ export function useSignaling(joinCode?: string) {
|
|||||||
updateTransfer(fileId, { progress, status: "transferring" });
|
updateTransfer(fileId, { progress, status: "transferring" });
|
||||||
},
|
},
|
||||||
onTransferRequest: (msg: TransferRequestMessage) => {
|
onTransferRequest: (msg: TransferRequestMessage) => {
|
||||||
// Find the peer who sent this
|
|
||||||
const store = useStore.getState();
|
|
||||||
const fromPeer = store.peers.find(() => true); // We'll set this from data handler
|
|
||||||
setIncomingRequest({
|
setIncomingRequest({
|
||||||
peerId: "", // Will be set from the data handler
|
peerId: "",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
files: msg.files,
|
files: msg.files,
|
||||||
text: msg.text,
|
text: msg.text,
|
||||||
@ -64,10 +62,8 @@ export function useSignaling(joinCode?: string) {
|
|||||||
},
|
},
|
||||||
onTransferResponse: (msg: TransferResponseMessage) => {
|
onTransferResponse: (msg: TransferResponseMessage) => {
|
||||||
if (msg.accepted) {
|
if (msg.accepted) {
|
||||||
// Start sending files
|
|
||||||
startSending();
|
startSending();
|
||||||
} else {
|
} else {
|
||||||
// Clean up pending transfers
|
|
||||||
const store = useStore.getState();
|
const store = useStore.getState();
|
||||||
for (const transfer of store.transfers) {
|
for (const transfer of store.transfers) {
|
||||||
if (transfer.direction === "send" && transfer.status === "pending") {
|
if (transfer.direction === "send" && transfer.status === "pending") {
|
||||||
@ -77,8 +73,6 @@ export function useSignaling(joinCode?: string) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onText: (text) => {
|
onText: (text) => {
|
||||||
// Show received text to user
|
|
||||||
const store = useStore.getState();
|
|
||||||
setIncomingRequest({
|
setIncomingRequest({
|
||||||
peerId: "",
|
peerId: "",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
@ -118,11 +112,16 @@ export function useSignaling(joinCode?: string) {
|
|||||||
const handleMessage = (msg: ServerMessage) => {
|
const handleMessage = (msg: ServerMessage) => {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "welcome":
|
case "welcome":
|
||||||
setConnection(msg.peerId, msg.displayName, msg.roomId);
|
setConnection(msg.peerId, msg.roomId);
|
||||||
setPeers(msg.peers);
|
setPeers(msg.peers);
|
||||||
break;
|
break;
|
||||||
case "peer-joined":
|
case "peer-joined":
|
||||||
addPeer({ peerId: msg.peerId, displayName: msg.displayName });
|
addPeer({
|
||||||
|
peerId: msg.peerId,
|
||||||
|
displayName: msg.displayName,
|
||||||
|
deviceType: msg.deviceType,
|
||||||
|
avatar: msg.avatar,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case "peer-left":
|
case "peer-left":
|
||||||
removePeer(msg.peerId);
|
removePeer(msg.peerId);
|
||||||
@ -140,7 +139,18 @@ export function useSignaling(joinCode?: string) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const signaling = new SignalingClient(handleMessage, joinCode);
|
// Get profile from store
|
||||||
|
const profile = useProfileStore.getState();
|
||||||
|
|
||||||
|
const signaling = new SignalingClient(
|
||||||
|
handleMessage,
|
||||||
|
{
|
||||||
|
deviceName: profile.deviceName,
|
||||||
|
deviceType: profile.deviceType,
|
||||||
|
avatar: profile.avatar || undefined,
|
||||||
|
},
|
||||||
|
joinCode,
|
||||||
|
);
|
||||||
signalingRef.current = signaling;
|
signalingRef.current = signaling;
|
||||||
|
|
||||||
const peerManager = new PeerManager(signaling, {
|
const peerManager = new PeerManager(signaling, {
|
||||||
@ -151,7 +161,6 @@ export function useSignaling(joinCode?: string) {
|
|||||||
console.log(`P2P disconnected from ${peerId}`);
|
console.log(`P2P disconnected from ${peerId}`);
|
||||||
},
|
},
|
||||||
onData: (peerId, data) => {
|
onData: (peerId, data) => {
|
||||||
// Update incoming request with correct peerId
|
|
||||||
const store = useStore.getState();
|
const store = useStore.getState();
|
||||||
const peerInfo = store.peers.find((p) => p.peerId === peerId);
|
const peerInfo = store.peers.find((p) => p.peerId === peerId);
|
||||||
|
|
||||||
@ -209,7 +218,6 @@ export function useSignaling(joinCode?: string) {
|
|||||||
const signaling = signalingRef.current;
|
const signaling = signalingRef.current;
|
||||||
if (!pm || !signaling) return;
|
if (!pm || !signaling) return;
|
||||||
|
|
||||||
// Ensure P2P connection exists
|
|
||||||
let peer = pm.getPeer(peerId);
|
let peer = pm.getPeer(peerId);
|
||||||
if (!peer) {
|
if (!peer) {
|
||||||
peer = pm.createPeer(peerId, true);
|
peer = pm.createPeer(peerId, true);
|
||||||
@ -217,9 +225,7 @@ export function useSignaling(joinCode?: string) {
|
|||||||
|
|
||||||
const request = createTransferRequest(files);
|
const request = createTransferRequest(files);
|
||||||
|
|
||||||
// Add transfers to store
|
|
||||||
for (const fileMeta of request.files) {
|
for (const fileMeta of request.files) {
|
||||||
const file = files.find((f) => f.name === fileMeta.name)!;
|
|
||||||
addTransfer({
|
addTransfer({
|
||||||
id: fileMeta.id,
|
id: fileMeta.id,
|
||||||
peerId,
|
peerId,
|
||||||
@ -232,16 +238,13 @@ export function useSignaling(joinCode?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store files for when accepted
|
|
||||||
pendingFilesRef.current.set(peerId, { files });
|
pendingFilesRef.current.set(peerId, { files });
|
||||||
|
|
||||||
// Wait for connection then send request
|
|
||||||
const sendRequest = () => {
|
const sendRequest = () => {
|
||||||
const p = pm.getPeer(peerId);
|
const p = pm.getPeer(peerId);
|
||||||
if (p && (p as any)._channel?.readyState === "open") {
|
if (p && (p as any)._channel?.readyState === "open") {
|
||||||
p.send(JSON.stringify(request));
|
p.send(JSON.stringify(request));
|
||||||
} else {
|
} else {
|
||||||
// Peer is connected but channel might not be ready
|
|
||||||
setTimeout(sendRequest, 100);
|
setTimeout(sendRequest, 100);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -283,7 +286,6 @@ export function useSignaling(joinCode?: string) {
|
|||||||
const peer = pm.getPeer(peerId);
|
const peer = pm.getPeer(peerId);
|
||||||
if (!peer) return;
|
if (!peer) return;
|
||||||
|
|
||||||
// Add receive transfers
|
|
||||||
const store = useStore.getState();
|
const store = useStore.getState();
|
||||||
const request = store.incomingRequest;
|
const request = store.incomingRequest;
|
||||||
if (request) {
|
if (request) {
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import type { ClientMessage, ServerMessage } from "@anydrop/shared";
|
import type { ClientMessage, ServerMessage, DeviceType } from "@anydrop/shared";
|
||||||
|
|
||||||
|
export interface ProfileData {
|
||||||
|
deviceName: string;
|
||||||
|
deviceType: DeviceType;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type SignalingHandler = (msg: ServerMessage) => void;
|
export type SignalingHandler = (msg: ServerMessage) => void;
|
||||||
|
|
||||||
@ -8,11 +14,13 @@ export class SignalingClient {
|
|||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private handler: SignalingHandler;
|
private handler: SignalingHandler;
|
||||||
private joinCode?: string;
|
private joinCode?: string;
|
||||||
|
private profile: ProfileData;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private intentionalClose = false;
|
private intentionalClose = false;
|
||||||
|
|
||||||
constructor(handler: SignalingHandler, joinCode?: string) {
|
constructor(handler: SignalingHandler, profile: ProfileData, joinCode?: string) {
|
||||||
this.handler = handler;
|
this.handler = handler;
|
||||||
|
this.profile = profile;
|
||||||
this.joinCode = joinCode;
|
this.joinCode = joinCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,7 +29,13 @@ export class SignalingClient {
|
|||||||
this.ws = new WebSocket(WS_URL);
|
this.ws = new WebSocket(WS_URL);
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
this.send({ type: "hello", joinCode: this.joinCode });
|
this.send({
|
||||||
|
type: "hello",
|
||||||
|
deviceName: this.profile.deviceName,
|
||||||
|
deviceType: this.profile.deviceType,
|
||||||
|
avatar: this.profile.avatar,
|
||||||
|
joinCode: this.joinCode,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
|
|||||||
@ -1,18 +1,19 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useSignaling } from "../hooks/useSignaling";
|
import { useSignaling } from "../hooks/useSignaling";
|
||||||
import { useStore } from "../stores/useStore";
|
import { useStore } from "../stores/useStore";
|
||||||
|
import { useProfileStore } from "../stores/useProfileStore";
|
||||||
import PeerList from "../components/PeerList";
|
import PeerList from "../components/PeerList";
|
||||||
import DropZone from "../components/DropZone";
|
import DropZone from "../components/DropZone";
|
||||||
import TransferProgress from "../components/TransferProgress";
|
import TransferProgress from "../components/TransferProgress";
|
||||||
import TextShareModal from "../components/TextShareModal";
|
import TextShareModal from "../components/TextShareModal";
|
||||||
import ReceiveDialog from "../components/ReceiveDialog";
|
import ReceiveDialog from "../components/ReceiveDialog";
|
||||||
import PublicRoomPanel from "../components/PublicRoomPanel";
|
import PublicRoomPanel from "../components/PublicRoomPanel";
|
||||||
|
import ProfileSetup from "../components/ProfileSetup";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom } =
|
const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom } =
|
||||||
useSignaling();
|
useSignaling();
|
||||||
|
|
||||||
const displayName = useStore((s) => s.displayName);
|
|
||||||
const selectedPeerId = useStore((s) => s.selectedPeerId);
|
const selectedPeerId = useStore((s) => s.selectedPeerId);
|
||||||
const showTextModal = useStore((s) => s.showTextModal);
|
const showTextModal = useStore((s) => s.showTextModal);
|
||||||
const incomingRequest = useStore((s) => s.incomingRequest);
|
const incomingRequest = useStore((s) => s.incomingRequest);
|
||||||
@ -21,6 +22,9 @@ export default function Home() {
|
|||||||
const setShowTextModal = useStore((s) => s.setShowTextModal);
|
const setShowTextModal = useStore((s) => s.setShowTextModal);
|
||||||
const setError = useStore((s) => s.setError);
|
const setError = useStore((s) => s.setError);
|
||||||
|
|
||||||
|
const { deviceName, avatar, isSetUp } = useProfileStore();
|
||||||
|
const [showProfileEdit, setShowProfileEdit] = useState(false);
|
||||||
|
|
||||||
const handlePeerSelect = useCallback(
|
const handlePeerSelect = useCallback(
|
||||||
(peerId: string) => {
|
(peerId: string) => {
|
||||||
setSelectedPeerId(selectedPeerId === peerId ? null : peerId);
|
setSelectedPeerId(selectedPeerId === peerId ? null : peerId);
|
||||||
@ -70,11 +74,23 @@ export default function Home() {
|
|||||||
Any<span className="text-brand-400">Drop</span>
|
Any<span className="text-brand-400">Drop</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-500 text-sm">Partage instantané, sans compte</p>
|
<p className="text-slate-500 text-sm">Partage instantané, sans compte</p>
|
||||||
{displayName && (
|
|
||||||
<p className="mt-3 text-sm text-slate-400">
|
{/* Profile badge — tap to edit */}
|
||||||
Vous êtes <span className="text-brand-300 font-medium">{displayName}</span>
|
<button
|
||||||
</p>
|
onClick={() => setShowProfileEdit(true)}
|
||||||
)}
|
className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full
|
||||||
|
bg-slate-800/50 hover:bg-slate-800 transition-colors group"
|
||||||
|
>
|
||||||
|
{avatar ? (
|
||||||
|
<img src={avatar} alt="" className="w-5 h-5 rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm">📱</span>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-slate-400 group-hover:text-white transition-colors">
|
||||||
|
{deviceName}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-600">✎</span>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Error banner */}
|
{/* Error banner */}
|
||||||
@ -129,6 +145,14 @@ export default function Home() {
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Profile setup — first time or edit */}
|
||||||
|
{!isSetUp && (
|
||||||
|
<ProfileSetup onDone={() => {}} />
|
||||||
|
)}
|
||||||
|
{showProfileEdit && (
|
||||||
|
<ProfileSetup isEditing onDone={() => setShowProfileEdit(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
{showTextModal && selectedPeerId && (
|
{showTextModal && selectedPeerId && (
|
||||||
<TextShareModal
|
<TextShareModal
|
||||||
|
|||||||
@ -1,18 +1,21 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useSignaling } from "../hooks/useSignaling";
|
import { useSignaling } from "../hooks/useSignaling";
|
||||||
import { useStore } from "../stores/useStore";
|
import { useStore } from "../stores/useStore";
|
||||||
|
import { useProfileStore } from "../stores/useProfileStore";
|
||||||
import PeerList from "../components/PeerList";
|
import PeerList from "../components/PeerList";
|
||||||
import DropZone from "../components/DropZone";
|
import DropZone from "../components/DropZone";
|
||||||
import TransferProgress from "../components/TransferProgress";
|
import TransferProgress from "../components/TransferProgress";
|
||||||
import TextShareModal from "../components/TextShareModal";
|
import TextShareModal from "../components/TextShareModal";
|
||||||
import ReceiveDialog from "../components/ReceiveDialog";
|
import ReceiveDialog from "../components/ReceiveDialog";
|
||||||
|
import ProfileSetup from "../components/ProfileSetup";
|
||||||
|
|
||||||
export default function JoinRoom() {
|
export default function JoinRoom() {
|
||||||
const { code } = useParams<{ code: string }>();
|
const { code } = useParams<{ code: string }>();
|
||||||
const { sendFiles, sendText, acceptTransfer, rejectTransfer } = useSignaling(code);
|
const { sendFiles, sendText, acceptTransfer, rejectTransfer } = useSignaling(code);
|
||||||
|
|
||||||
const displayName = useStore((s) => s.displayName);
|
const { deviceName, avatar, isSetUp } = useProfileStore();
|
||||||
|
const [showProfileEdit, setShowProfileEdit] = useState(false);
|
||||||
const selectedPeerId = useStore((s) => s.selectedPeerId);
|
const selectedPeerId = useStore((s) => s.selectedPeerId);
|
||||||
const showTextModal = useStore((s) => s.showTextModal);
|
const showTextModal = useStore((s) => s.showTextModal);
|
||||||
const incomingRequest = useStore((s) => s.incomingRequest);
|
const incomingRequest = useStore((s) => s.incomingRequest);
|
||||||
@ -72,11 +75,21 @@ export default function JoinRoom() {
|
|||||||
<p className="text-slate-500 text-sm">
|
<p className="text-slate-500 text-sm">
|
||||||
Room <span className="text-brand-300 font-mono font-bold">{code?.toUpperCase()}</span>
|
Room <span className="text-brand-300 font-mono font-bold">{code?.toUpperCase()}</span>
|
||||||
</p>
|
</p>
|
||||||
{displayName && (
|
<button
|
||||||
<p className="mt-3 text-sm text-slate-400">
|
onClick={() => setShowProfileEdit(true)}
|
||||||
Vous êtes <span className="text-brand-300 font-medium">{displayName}</span>
|
className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full
|
||||||
</p>
|
bg-slate-800/50 hover:bg-slate-800 transition-colors group"
|
||||||
)}
|
>
|
||||||
|
{avatar ? (
|
||||||
|
<img src={avatar} alt="" className="w-5 h-5 rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm">📱</span>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-slate-400 group-hover:text-white transition-colors">
|
||||||
|
{deviceName}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-600">✎</span>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Error banner */}
|
{/* Error banner */}
|
||||||
@ -133,6 +146,14 @@ export default function JoinRoom() {
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Profile setup — first time or edit */}
|
||||||
|
{!isSetUp && (
|
||||||
|
<ProfileSetup onDone={() => {}} />
|
||||||
|
)}
|
||||||
|
{showProfileEdit && (
|
||||||
|
<ProfileSetup isEditing onDone={() => setShowProfileEdit(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
{showTextModal && selectedPeerId && (
|
{showTextModal && selectedPeerId && (
|
||||||
<TextShareModal
|
<TextShareModal
|
||||||
|
|||||||
35
web/src/stores/useProfileStore.ts
Normal file
35
web/src/stores/useProfileStore.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import type { DeviceType } from "@anydrop/shared";
|
||||||
|
import { detectDeviceType, getDefaultDeviceName } from "@anydrop/shared";
|
||||||
|
|
||||||
|
interface ProfileState {
|
||||||
|
deviceName: string;
|
||||||
|
deviceType: DeviceType;
|
||||||
|
avatar: string | null; // base64 data URL
|
||||||
|
isSetUp: boolean; // true once user has confirmed their profile at least once
|
||||||
|
|
||||||
|
setDeviceName: (name: string) => void;
|
||||||
|
setAvatar: (avatar: string | null) => void;
|
||||||
|
setUp: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
|
||||||
|
export const useProfileStore = create<ProfileState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
deviceName: getDefaultDeviceName(ua),
|
||||||
|
deviceType: detectDeviceType(ua),
|
||||||
|
avatar: null,
|
||||||
|
isSetUp: false,
|
||||||
|
|
||||||
|
setDeviceName: (deviceName) => set({ deviceName: deviceName.trim() || getDefaultDeviceName(ua) }),
|
||||||
|
setAvatar: (avatar) => set({ avatar }),
|
||||||
|
setUp: () => set({ isSetUp: true }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "anydrop-profile",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@ -23,7 +23,6 @@ interface AppState {
|
|||||||
// Connection
|
// Connection
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
peerId: string | null;
|
peerId: string | null;
|
||||||
displayName: string | null;
|
|
||||||
roomId: string | null;
|
roomId: string | null;
|
||||||
|
|
||||||
// Peers
|
// Peers
|
||||||
@ -44,7 +43,7 @@ interface AppState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setConnection: (peerId: string, displayName: string, roomId: string) => void;
|
setConnection: (peerId: string, roomId: string) => void;
|
||||||
setConnected: (connected: boolean) => void;
|
setConnected: (connected: boolean) => void;
|
||||||
addPeer: (peer: PeerInfo) => void;
|
addPeer: (peer: PeerInfo) => void;
|
||||||
removePeer: (peerId: string) => void;
|
removePeer: (peerId: string) => void;
|
||||||
@ -64,7 +63,6 @@ interface AppState {
|
|||||||
const initialState = {
|
const initialState = {
|
||||||
connected: false,
|
connected: false,
|
||||||
peerId: null,
|
peerId: null,
|
||||||
displayName: null,
|
|
||||||
roomId: null,
|
roomId: null,
|
||||||
peers: [],
|
peers: [],
|
||||||
publicRoomCode: null,
|
publicRoomCode: null,
|
||||||
@ -80,8 +78,8 @@ const initialState = {
|
|||||||
export const useStore = create<AppState>((set) => ({
|
export const useStore = create<AppState>((set) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
|
|
||||||
setConnection: (peerId, displayName, roomId) =>
|
setConnection: (peerId, roomId) =>
|
||||||
set({ peerId, displayName, roomId, connected: true }),
|
set({ peerId, roomId, connected: true }),
|
||||||
|
|
||||||
setConnected: (connected) => set({ connected }),
|
setConnected: (connected) => set({ connected }),
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user