mirror of
https://github.com/myronblair/kino-app
synced 2026-07-27 21:18:43 -05:00
Merge watch profiles into user accounts — one profile per login, no picker
- Removed the "Who's Watching" sub-profile picker entirely: ProfileProvider now auto-resolves the single profile tied to the account immediately after login - Backend: removed POST/DELETE /profiles (no more creating/deleting extra profiles — lifecycle is entirely driven by user create/delete, already wired in the admin Users CRUD); kept GET (internal) and PATCH (edit your own profile's avatar/kids-mode/rating cap) - Deleted ProfileSelect page and the now-unused ProtectedRoute component - Navbar: removed the "switch profile" action, account avatar is now just a static display - api.js: dropped the now-dead X-Profile-Id header logic (backend already falls back to the account's single profile when the header is absent)
This commit is contained in:
+1
-8
@@ -47,17 +47,10 @@ class AdminUserUpdate(BaseModel):
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
# ---------- Profiles ("Who's Watching") ----------
|
||||
# ---------- Profile (1:1 with account) ----------
|
||||
RATING_ORDER = ["G", "PG", "PG-13", "R", "NR"]
|
||||
|
||||
|
||||
class ProfileCreate(BaseModel):
|
||||
name: str
|
||||
avatar_color: str = "#D9381E"
|
||||
is_kids: bool = False
|
||||
max_rating: str = "NR" # one of RATING_ORDER
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
avatar_color: Optional[str] = None
|
||||
|
||||
+5
-25
@@ -17,7 +17,7 @@ from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from models import (
|
||||
UserCreate, UserLogin, UserPublic, TokenResponse, PasswordChange,
|
||||
AdminUserCreate, AdminUserUpdate,
|
||||
Profile, ProfileCreate, ProfileUpdate, RATING_ORDER,
|
||||
Profile, ProfileUpdate, RATING_ORDER,
|
||||
Movie, MovieCreate, MovieUpdate,
|
||||
Subtitle,
|
||||
WatchlistItem, ProgressUpsert,
|
||||
@@ -308,25 +308,17 @@ async def admin_delete_user(user_id: str, user: dict = Depends(require_admin)):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============ PROFILES ============
|
||||
# ============ PROFILE (1:1 with account — no separate sub-profile management) ============
|
||||
@api.get("/profiles", response_model=List[Profile])
|
||||
async def list_profiles(user: dict = Depends(get_current_user)):
|
||||
docs = await db.profiles.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", 1).to_list(20)
|
||||
"""Always exactly one profile per account; get_active_profile auto-creates it if missing."""
|
||||
docs = await db.profiles.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", 1).to_list(1)
|
||||
return docs
|
||||
|
||||
|
||||
@api.post("/profiles", response_model=Profile)
|
||||
async def create_profile(payload: ProfileCreate, user: dict = Depends(get_current_user)):
|
||||
count = await db.profiles.count_documents({"user_id": user["id"]})
|
||||
if count >= 5:
|
||||
raise HTTPException(status_code=400, detail="Max 5 profiles per account")
|
||||
prof = Profile(user_id=user["id"], **payload.model_dump()).model_dump()
|
||||
await db.profiles.insert_one(prof)
|
||||
return _strip(prof)
|
||||
|
||||
|
||||
@api.patch("/profiles/{pid}", response_model=Profile)
|
||||
async def update_profile(pid: str, payload: ProfileUpdate, user: dict = Depends(get_current_user)):
|
||||
"""Edit your own single profile (avatar color, kids mode, rating cap) — not a multi-profile switcher."""
|
||||
updates = {k: v for k, v in payload.model_dump().items() if v is not None}
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
@@ -340,18 +332,6 @@ async def update_profile(pid: str, payload: ProfileUpdate, user: dict = Depends(
|
||||
return res
|
||||
|
||||
|
||||
@api.delete("/profiles/{pid}")
|
||||
async def delete_profile(pid: str, user: dict = Depends(get_current_user)):
|
||||
if await db.profiles.count_documents({"user_id": user["id"]}) <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the only profile")
|
||||
res = await db.profiles.delete_one({"id": pid, "user_id": user["id"]})
|
||||
if res.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
await db.watchlist.delete_many({"profile_id": pid})
|
||||
await db.progress.delete_many({"profile_id": pid})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============ MOVIES ============
|
||||
def _movie_filter_for_profile(profile: dict) -> dict:
|
||||
cap = profile.get("max_rating", "NR")
|
||||
|
||||
+3
-8
@@ -2,7 +2,6 @@ import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-route
|
||||
import { Toaster } from "sonner";
|
||||
import { AuthProvider, useAuth } from "./lib/auth";
|
||||
import { ProfileProvider, useProfile } from "./lib/profile";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import Navbar from "./components/Navbar";
|
||||
import GrainOverlay from "./components/GrainOverlay";
|
||||
import ServiceStatus from "./components/ServiceStatus";
|
||||
@@ -17,7 +16,6 @@ import Admin from "./pages/Admin";
|
||||
import AdminUpload from "./pages/AdminUpload";
|
||||
import Settings from "./pages/Settings";
|
||||
import RadarrImport from "./pages/RadarrImport";
|
||||
import ProfileSelect from "./pages/ProfileSelect";
|
||||
import TranscodeQueue from "./pages/TranscodeQueue";
|
||||
import Shows from "./pages/Shows";
|
||||
import ShowDetail from "./pages/ShowDetail";
|
||||
@@ -30,11 +28,10 @@ const ProfileGate = ({ children }) => {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { active, loading: profLoading } = useProfile();
|
||||
const loc = useLocation();
|
||||
if (authLoading || profLoading) {
|
||||
if (authLoading || profLoading || (user && !active)) {
|
||||
return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading…</div>;
|
||||
}
|
||||
if (!user) return <Navigate to="/login" state={{ from: loc.pathname }} replace />;
|
||||
if (!active) return <Navigate to="/profile" replace />;
|
||||
return children;
|
||||
};
|
||||
|
||||
@@ -50,7 +47,7 @@ const Shell = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const { active } = useProfile();
|
||||
const loc = useLocation();
|
||||
const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register" || loc.pathname === "/profile";
|
||||
const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register";
|
||||
return (
|
||||
<>
|
||||
{!hideChrome && user && active && <Navbar />}
|
||||
@@ -63,10 +60,9 @@ const Shell = ({ children }) => {
|
||||
|
||||
const RootRedirect = () => {
|
||||
const { user, loading } = useAuth();
|
||||
const { active, loading: pl } = useProfile();
|
||||
const { loading: pl } = useProfile();
|
||||
if (loading || pl) return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading…</div>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (!active) return <Navigate to="/profile" replace />;
|
||||
return <Navigate to="/browse" replace />;
|
||||
};
|
||||
|
||||
@@ -81,7 +77,6 @@ function App() {
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/profile" element={<ProtectedRoute><ProfileSelect /></ProtectedRoute>} />
|
||||
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
|
||||
<Route path="/shows" element={<ProfileGate><Shows /></ProfileGate>} />
|
||||
<Route path="/show/:id" element={<ProfileGate><ShowDetail /></ProfileGate>} />
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Link, NavLink, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { useProfile } from "../lib/profile";
|
||||
import { Search, LogOut, Upload, Users, Settings as SettingsIcon } from "lucide-react";
|
||||
import { Search, LogOut, Upload, Settings as SettingsIcon } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export const Navbar = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const { active, clearActive } = useProfile();
|
||||
const { active } = useProfile();
|
||||
const nav = useNavigate();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
@@ -19,11 +19,6 @@ export const Navbar = () => {
|
||||
const linkClass = ({ isActive }) =>
|
||||
`text-sm tracking-wide transition-colors duration-300 ${isActive ? "text-white" : "text-[#8A8A8A] hover:text-white"}`;
|
||||
|
||||
const switchProfile = () => {
|
||||
clearActive();
|
||||
nav("/profile");
|
||||
};
|
||||
|
||||
return (
|
||||
<header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrolled ? "glass border-b" : ""}`} data-testid="main-navbar">
|
||||
<div className="px-6 md:px-12 py-4 flex items-center justify-between">
|
||||
@@ -58,20 +53,14 @@ export const Navbar = () => {
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
|
||||
</button>
|
||||
<div className="flex items-center gap-3 pl-4 border-l border-[#222]">
|
||||
<button onClick={switchProfile} className="flex items-center gap-2 group" data-testid="nav-switch-profile" title="Switch profile">
|
||||
{active && (
|
||||
<>
|
||||
<div className="hidden sm:flex flex-col items-end leading-tight">
|
||||
<span className="text-xs text-white">{active.name}</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] group-hover:text-white transition-colors">switch</span>
|
||||
</div>
|
||||
<div className="w-8 h-8 flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: active.avatar_color || "#D9381E" }}>
|
||||
{active.name?.[0]?.toUpperCase() || "U"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!active && <Users size={18} strokeWidth={1.5} className="text-[#8A8A8A]" />}
|
||||
</button>
|
||||
{active && (
|
||||
<div className="flex items-center gap-2" data-testid="account-avatar">
|
||||
<span className="hidden sm:inline text-xs text-white">{active.name}</span>
|
||||
<div className="w-8 h-8 flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: active.avatar_color || "#D9381E" }}>
|
||||
{active.name?.[0]?.toUpperCase() || "U"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => { logout(); nav("/login"); }} className="text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-logout-button" aria-label="Logout">
|
||||
<LogOut size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/auth";
|
||||
|
||||
export const ProtectedRoute = ({ children, adminOnly = false }) => {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-[#8A8A8A]" data-testid="auth-loading">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (adminOnly && !user.is_admin) return <Navigate to="/browse" replace />;
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -8,12 +8,6 @@ const instance = axios.create({ baseURL: API });
|
||||
instance.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("kino_token");
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
// Active profile id (set by ProfileProvider via localStorage)
|
||||
const userId = JSON.parse(localStorage.getItem("kino_user_id") || "null");
|
||||
if (userId) {
|
||||
const pid = localStorage.getItem(`kino_profile_${userId}`);
|
||||
if (pid) config.headers["X-Profile-Id"] = pid;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
|
||||
@@ -6,19 +6,15 @@ const ProfileCtx = createContext(null);
|
||||
|
||||
export const ProfileProvider = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [active, setActive] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!user) { setProfiles([]); setActive(null); return; }
|
||||
if (!user) { setActive(null); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/profiles");
|
||||
setProfiles(data);
|
||||
const stored = localStorage.getItem(`kino_profile_${user.id}`);
|
||||
const found = data.find((p) => p.id === stored);
|
||||
setActive(found || null);
|
||||
setActive(data[0] || null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -26,20 +22,8 @@ export const ProfileProvider = ({ children }) => {
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const switchTo = (p) => {
|
||||
if (!user) return;
|
||||
localStorage.setItem(`kino_profile_${user.id}`, p.id);
|
||||
setActive(p);
|
||||
};
|
||||
|
||||
const clearActive = () => {
|
||||
if (!user) return;
|
||||
localStorage.removeItem(`kino_profile_${user.id}`);
|
||||
setActive(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ProfileCtx.Provider value={{ profiles, active, loading, refresh, switchTo, clearActive }}>
|
||||
<ProfileCtx.Provider value={{ active, loading, refresh }}>
|
||||
{children}
|
||||
</ProfileCtx.Provider>
|
||||
);
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import { useProfile } from "../lib/profile";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { Plus, Pencil, Check, X, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const COLORS = ["#D9381E", "#EAB308", "#22C55E", "#3B82F6", "#A855F7", "#EC4899"];
|
||||
|
||||
export default function ProfileSelect() {
|
||||
const { user, logout } = useAuth();
|
||||
const { profiles, refresh, switchTo } = useProfile();
|
||||
const nav = useNavigate();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [draft, setDraft] = useState({ name: "", avatar_color: COLORS[0], is_kids: false, max_rating: "NR" });
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const choose = (p) => {
|
||||
switchTo(p);
|
||||
nav("/browse", { replace: true });
|
||||
};
|
||||
|
||||
const submitNew = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!draft.name.trim()) return;
|
||||
try {
|
||||
await api.post("/profiles", draft);
|
||||
toast.success("Profile created");
|
||||
setCreating(false);
|
||||
setDraft({ name: "", avatar_color: COLORS[0], is_kids: false, max_rating: "NR" });
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not create");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
if (!window.confirm("Delete this profile? Watchlist & history will be removed.")) return;
|
||||
try { await api.delete(`/profiles/${id}`); refresh(); } catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not delete");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] flex flex-col items-center justify-center px-6 py-16" data-testid="profile-select-page">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E] mb-6">Welcome back, {user?.name}</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white text-center">
|
||||
Choose a viewer
|
||||
</h1>
|
||||
|
||||
<div className="mt-16 flex flex-wrap justify-center gap-8 max-w-4xl">
|
||||
{profiles.map((p) => (
|
||||
<div key={p.id} className="flex flex-col items-center gap-3 group" data-testid={`profile-${p.id}`}>
|
||||
<button
|
||||
onClick={() => editing ? null : choose(p)}
|
||||
className="w-28 h-28 md:w-36 md:h-36 flex items-center justify-center text-3xl font-bold text-white transition-all duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
|
||||
style={{ backgroundColor: p.avatar_color }}
|
||||
data-testid={`select-profile-${p.id}`}
|
||||
>
|
||||
{p.name?.[0]?.toUpperCase() || "?"}
|
||||
</button>
|
||||
<span className="font-display text-lg text-white">{p.name}</span>
|
||||
<div className="flex gap-2 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
|
||||
{p.is_kids && <span>Kids</span>}
|
||||
{p.max_rating !== "NR" && <span>· Up to {p.max_rating}</span>}
|
||||
</div>
|
||||
{editing && (
|
||||
<button onClick={() => remove(p.id)} className="text-[#8A8A8A] hover:text-[#fca5a5] transition-colors mt-1" data-testid={`delete-profile-${p.id}`}>
|
||||
<Trash2 size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{profiles.length < 5 && (
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="w-28 h-28 md:w-36 md:h-36 flex flex-col items-center justify-center border border-dashed border-[#333] hover:border-[#D9381E] text-[#8A8A8A] hover:text-white transition-colors duration-300"
|
||||
data-testid="add-profile-button"
|
||||
>
|
||||
<Plus size={32} strokeWidth={1.5} />
|
||||
<span className="text-xs uppercase tracking-[0.2em] mt-2">Add</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 flex gap-4">
|
||||
<button onClick={() => setEditing(!editing)}
|
||||
className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors duration-300"
|
||||
data-testid="manage-profiles-button">
|
||||
{editing ? <Check size={14} strokeWidth={1.5} /> : <Pencil size={14} strokeWidth={1.5} />}
|
||||
{editing ? "Done" : "Manage Profiles"}
|
||||
</button>
|
||||
<button onClick={() => { logout(); nav("/login"); }}
|
||||
className="text-[#8A8A8A] hover:text-white text-xs uppercase tracking-[0.2em] transition-colors duration-300"
|
||||
data-testid="profile-logout-button">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" data-testid="new-profile-modal">
|
||||
<div className="absolute inset-0 bg-black/85 backdrop-blur-md" onClick={() => setCreating(false)} />
|
||||
<form onSubmit={submitNew} className="relative bg-[#0F0F0F] border border-[#222] p-8 w-full max-w-md mx-4 fade-up">
|
||||
<button type="button" onClick={() => setCreating(false)} className="absolute top-4 right-4 text-[#8A8A8A] hover:text-white"><X size={18} /></button>
|
||||
<h2 className="font-display text-3xl font-bold tracking-tight text-white">New profile</h2>
|
||||
|
||||
<label className="block mt-6">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Name</span>
|
||||
<input value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
required className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
|
||||
data-testid="new-profile-name" />
|
||||
</label>
|
||||
|
||||
<div className="mt-5">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Color</span>
|
||||
<div className="mt-2 flex gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button type="button" key={c} onClick={() => setDraft({ ...draft, avatar_color: c })}
|
||||
className={`w-10 h-10 ${draft.avatar_color === c ? "ring-2 ring-white" : ""}`}
|
||||
style={{ backgroundColor: c }} aria-label={`Color ${c}`}
|
||||
data-testid={`color-${c.replace('#', '')}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 mt-5 cursor-pointer">
|
||||
<input type="checkbox" checked={draft.is_kids}
|
||||
onChange={(e) => setDraft({ ...draft, is_kids: e.target.checked, max_rating: e.target.checked ? "PG" : draft.max_rating })}
|
||||
className="accent-[#D9381E]" data-testid="new-profile-kids" />
|
||||
<span className="text-sm text-[#C8C8C8]">Kids profile (limits content to PG)</span>
|
||||
</label>
|
||||
|
||||
<label className="block mt-5">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Max rating</span>
|
||||
<select value={draft.max_rating} onChange={(e) => setDraft({ ...draft, max_rating: e.target.value })}
|
||||
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] text-white px-4 py-3 focus:border-[#D9381E] focus:outline-none"
|
||||
data-testid="new-profile-rating">
|
||||
<option value="G">G</option>
|
||||
<option value="PG">PG</option>
|
||||
<option value="PG-13">PG-13</option>
|
||||
<option value="R">R</option>
|
||||
<option value="NR">No restriction</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="mt-8 w-full bg-[#D9381E] hover:bg-[#ED4B32] text-white py-3 text-sm uppercase tracking-[0.2em]"
|
||||
data-testid="new-profile-submit">Create profile</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user