v3: added multiple stylesheets, improved user profiles

This commit is contained in:
khannurien
2026-04-06 15:36:04 +00:00
parent a69788c15b
commit 3b6980a8fc
24 changed files with 2182 additions and 714 deletions

View File

@@ -15,6 +15,7 @@ import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import {
createUser,
getInviteTree,
getUserById,
getUserByUsername,
searchUsers,
@@ -30,7 +31,10 @@ import {
getVotedDumpsByUser,
} from "../services/dump-service.ts";
import { listPlaylistsByUser } from "../services/playlist-service.ts";
import { getFollowedPlaylistsByUser } from "../services/follow-service.ts";
import {
getFollowedPlaylistsByUser,
getFollowedUsersByUser,
} from "../services/follow-service.ts";
// Users router
const router = new Router({ prefix: "/api/users" });
@@ -189,6 +193,21 @@ router.get("/by-id/:userId", (ctx) => {
ctx.response.body = { success: true, data: publicUser };
});
// Followed users for a user (public)
router.get("/:username/followed-users", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedUsersByUser(user.id, page, limit);
ctx.response.body = {
success: true,
data: {
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
total,
hasMore: page * limit < total,
},
};
});
// Followed playlists for a user (public only)
router.get("/:username/followed-playlists", (ctx) => {
const user = getUserByUsername(ctx.params.username);
@@ -225,6 +244,13 @@ router.get("/:username/playlists", async (ctx) => {
};
});
// Invite tree for a user (public)
router.get("/:username/invitees", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const tree = getInviteTree(user.id);
ctx.response.body = { success: true, data: tree };
});
// Public user profile by username (no passwordHash)
router.get("/:username", (ctx) => {
const user = getUserByUsername(ctx.params.username);

View File

@@ -4,6 +4,7 @@ import {
type Dump,
type FollowStatus,
type Playlist,
type User,
} from "../model/interfaces.ts";
import {
notifyPlaylistOwnerNewFollower,
@@ -15,7 +16,9 @@ import {
isDumpRow,
isFollowRow,
isPlaylistRow,
isUserRow,
playlistRowToApi,
userRowToApi,
} from "../model/db.ts";
// Mirrors dump-service SELECT_COLS_ALIASED — kept local to avoid circular imports
@@ -256,3 +259,39 @@ export function getFollowedPlaylistsByUser(
}
return { items: rawRows.map(playlistRowToApi), total: totalRow?.count ?? 0 };
}
// ── Followed users (as user objects) ─────────────────────────────────────────
export function getFollowedUsersByUser(
userId: string,
page: number,
limit: number,
): { items: User[]; total: number } {
const offset = (page - 1) * limit;
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM follows WHERE follower_id = ? AND followed_user_id IS NOT NULL;`,
).get(userId) as { count: number } | undefined;
const rawRows = db.prepare(
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at,
u.avatar_mime, u.description, u.invited_by, u.email,
i.username as invited_by_username
FROM users u
LEFT JOIN users i ON i.id = u.invited_by
INNER JOIN follows f ON f.followed_user_id = u.id
WHERE f.follower_id = ?
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?;`,
).all(userId, limit, offset);
if (!rawRows.every(isUserRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed user data",
);
}
return { items: rawRows.map(userRowToApi), total: totalRow?.count ?? 0 };
}

View File

@@ -178,6 +178,44 @@ export function updateUserAvatar(userId: string, mime: string): void {
}
}
export function getInviteTree(
userId: string,
): {
id: string;
username: string;
avatarMime?: string;
invitedById: string;
createdAt: Date;
}[] {
const rows = db.prepare(`
WITH RECURSIVE tree AS (
SELECT id, username, avatar_mime, invited_by, created_at, 0 AS depth
FROM users
WHERE invited_by = ?
UNION ALL
SELECT u.id, u.username, u.avatar_mime, u.invited_by, u.created_at, t.depth + 1
FROM users u
INNER JOIN tree t ON u.invited_by = t.id
WHERE t.depth < 10
)
SELECT * FROM tree ORDER BY created_at;
`).all(userId) as {
id: string;
username: string;
avatar_mime: string | null;
invited_by: string;
created_at: string;
}[];
return rows.map((r) => ({
id: r.id,
username: r.username,
avatarMime: r.avatar_mime ?? undefined,
invitedById: r.invited_by,
createdAt: new Date(r.created_at),
}));
}
export function deleteUser(userId: string): void {
disconnectUser(userId);

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Saira:ital,wght@0,100..900;1,100..900&family=Space+Grotesk:wght@400;700;900&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111827" />
<link rel="manifest" href="/manifest.webmanifest" />
@@ -14,6 +14,7 @@
</head>
<body>
<div id="root"></div>
<script>(function(){var s=localStorage.getItem("style"),c=localStorage.getItem("color-scheme");if(s==="brutalist")document.documentElement.setAttribute("data-style",s);if(c&&c!=="auto")document.documentElement.setAttribute("data-color-scheme",c);})();</script>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -1272,6 +1272,12 @@ body.has-player .fab-new {
gap: 1.5rem;
}
.profile-tabs {
margin-top: 0.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--color-border);
}
.profile-columns {
display: grid;
grid-template-columns: 1fr;
@@ -1651,7 +1657,7 @@ body.has-player .fab-new {
align-items: flex-start;
gap: 0.6rem;
background: var(--color-danger-bg);
color: var(--color-on-accent);
color: var(--color-text);
padding: 0.7rem 1rem;
border-radius: 8px;
font-size: 0.9rem;
@@ -1694,7 +1700,7 @@ body.has-player .fab-new {
border: none;
border-radius: 10px;
background: var(--color-danger-bg);
color: var(--color-on-accent);
color: var(--color-text);
line-height: 1.5;
overflow-wrap: anywhere;
word-break: break-word;
@@ -1747,6 +1753,7 @@ body.has-player .fab-new {
/* ── Shared header ── */
.app-header {
position: relative;
display: flex;
align-items: center;
gap: 1rem;
@@ -1834,6 +1841,22 @@ body.has-player .fab-new {
display: none;
}
.app-header-center:has(.search-bar--expanded) .header-center-slot {
flex: 1;
justify-content: center;
}
/* Expanded: pin center absolutely so it aligns with the feed's visual center */
@media (min-width: 860px) {
.app-header-center:has(.search-bar--expanded) {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 420px;
pointer-events: auto;
}
}
/* As the center column shrinks (viewport narrow, search collapsed),
shed content in order: presence first, then tabs (still in .index-below-header) */
@container (max-width: 460px) {
@@ -1868,6 +1891,16 @@ body.has-player .fab-new {
flex-wrap: nowrap;
}
/* Normalize all buttons in the header nav to the same height */
.app-header-nav button {
padding: 0.35rem 0.85rem;
font-size: 0.95rem;
}
.app-header-nav .btn-primary {
padding: 0.35rem 1rem;
}
/* Text links — visible only at wide viewports */
.nav-links {
display: none;
@@ -2882,7 +2915,7 @@ body.has-player .fab-new {
.playlist-dump-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 1rem;
}
.playlist-dump-item {
@@ -3277,6 +3310,111 @@ body.has-player .fab-new {
padding: 0.2rem 0;
}
/* ── Followed user list ── */
.followed-user-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.followed-user-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.4rem 0.5rem;
border-radius: 6px;
}
.followed-user-card-link {
display: flex;
align-items: center;
gap: 0.6rem;
text-decoration: none;
color: var(--color-text);
min-width: 0;
flex: 1;
}
.followed-user-card-link:hover .followed-user-card-name {
color: var(--color-accent);
}
.followed-user-card-name {
font-size: 0.9rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Invite tree ── */
.invite-tree {
list-style: none;
padding: 0;
margin: 0;
}
.invite-tree-node {
position: relative;
padding: 0.2rem 0;
}
.invite-tree-node:last-child {
padding-bottom: 0;
}
/* Child list: indented, left edge aligns with parent avatar center */
.invite-tree-node > .invite-tree {
position: relative;
margin-left: 11px; /* center 2px line on 24px avatar: 12px - 1px */
padding-left: calc(12px + 0.5rem);
}
/* Vertical connector: starts below parent avatar, stops at last child avatar center */
.invite-tree-node > .invite-tree::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 12px;
width: 2px;
background: var(--color-border-subtle);
}
/* Horizontal connector: from vertical line to each child's avatar left edge */
.invite-tree-node > .invite-tree > .invite-tree-node::before {
content: "";
position: absolute;
left: calc(-12px - 0.5rem);
top: calc(0.2rem + 11px); /* centered on 24px avatar */
width: calc(12px + 0.5rem);
height: 2px;
background: var(--color-border-subtle);
}
.invite-tree-user {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.9rem;
font-weight: 600;
}
.invite-tree-user:hover {
color: var(--color-accent);
}
.invite-tree-empty {
color: var(--color-text-muted);
font-size: 0.9rem;
}
/* ── Follow button ── */
.follow-btn {
padding: 0.25rem 0.9rem;
@@ -3977,7 +4115,7 @@ body.has-player .fab-new {
align-items: center;
gap: 0.4rem;
width: 100%;
max-width: 380px;
max-width: 420px;
}
.search-bar--collapsible {
@@ -3987,7 +4125,7 @@ body.has-player .fab-new {
.search-bar--collapsible.search-bar--expanded {
width: 100%;
max-width: 320px;
max-width: 420px;
}
.search-bar-input {
@@ -4021,7 +4159,7 @@ body.has-player .fab-new {
/* Expanded state: animate it open */
.search-bar--collapsible.search-bar--expanded .search-bar-input {
max-width: 280px;
max-width: 380px;
opacity: 1;
padding-left: 0.7rem;
padding-right: 0.7rem;
@@ -4115,3 +4253,21 @@ body.has-player .fab-new {
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Profile appearance settings ── */
.profile-appearance-grid {
display: grid;
grid-template-columns: max-content max-content;
gap: 0.75rem 1rem;
align-items: center;
}
.profile-appearance-row {
display: contents;
}
.profile-appearance-label {
font-size: 0.85rem;
font-weight: 600;
opacity: 0.7;
}

View File

@@ -19,6 +19,7 @@ import { AuthProvider } from "./contexts/AuthProvider.tsx";
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
import { WSProvider } from "./contexts/WSProvider.tsx";
import { FollowProvider } from "./contexts/FollowProvider.tsx";
import { ThemeProvider } from "./contexts/ThemeProvider.tsx";
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
import "./App.css";
@@ -75,18 +76,20 @@ function AppRoutes() {
function App() {
return (
<AuthProvider>
<PlayerProvider>
<WSProvider>
<FollowProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</FollowProvider>
</WSProvider>
<GlobalPlayer />
</PlayerProvider>
</AuthProvider>
<ThemeProvider>
<AuthProvider>
<PlayerProvider>
<WSProvider>
<FollowProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</FollowProvider>
</WSProvider>
<GlobalPlayer />
</PlayerProvider>
</AuthProvider>
</ThemeProvider>
);
}

View File

@@ -4,12 +4,13 @@ import { SearchBar } from "./SearchBar.tsx";
interface PageShellProps {
children: ReactNode;
feed?: ReactNode;
centered?: boolean;
centerSlot?: ReactNode;
}
export function PageShell(
{ children, centered = false, centerSlot }: PageShellProps,
{ children, feed, centered = false, centerSlot }: PageShellProps,
) {
return (
<div className="page-shell">
@@ -19,6 +20,7 @@ export function PageShell(
>
{children}
</main>
{feed}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { createContext } from "react";
export type StyleName = "smooth" | "brutalist";
export type ColorScheme = "auto" | "light" | "dark";
export interface ThemeContextValue {
style: StyleName;
colorScheme: ColorScheme;
setStyle(s: StyleName): void;
setColorScheme(c: ColorScheme): void;
}
export const ThemeContext = createContext<ThemeContextValue>({
style: "smooth",
colorScheme: "auto",
setStyle: () => {},
setColorScheme: () => {},
});

View File

@@ -0,0 +1,52 @@
import { useEffect, useState } from "react";
import {
type ColorScheme,
type StyleName,
ThemeContext,
} from "./ThemeContext.ts";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [style, setStyleState] = useState<StyleName>(() => {
const stored = localStorage.getItem("style");
// migrate legacy "default" value
if (stored === "default" || stored === null) return "smooth";
return stored as StyleName;
});
const [colorScheme, setColorSchemeState] = useState<ColorScheme>(
() =>
(localStorage.getItem("color-scheme") as ColorScheme | null) ?? "auto",
);
useEffect(() => {
const el = document.documentElement;
if (style === "smooth") {
el.removeAttribute("data-style");
} else {
el.setAttribute("data-style", style);
}
localStorage.setItem("style", style);
}, [style]);
useEffect(() => {
const el = document.documentElement;
if (colorScheme === "auto") {
el.removeAttribute("data-color-scheme");
} else {
el.setAttribute("data-color-scheme", colorScheme);
}
localStorage.setItem("color-scheme", colorScheme);
}, [colorScheme]);
return (
<ThemeContext.Provider
value={{
style,
colorScheme,
setStyle: setStyleState,
setColorScheme: setColorSchemeState,
}}
>
{children}
</ThemeContext.Provider>
);
}

6
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,6 @@
import { useContext } from "react";
import { ThemeContext } from "../contexts/ThemeContext.ts";
export function useTheme() {
return useContext(ThemeContext);
}

View File

@@ -1,78 +1,3 @@
:root {
font-family: "Saira", sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
line-height: 1.5;
color-scheme: light dark;
/* Text */
--color-text: #f9fafb;
--color-text-secondary: #9ca3af;
--color-text-muted: #6b7280;
--color-on-accent: #fff;
/* Surfaces */
--color-bg: #111827;
--color-surface: #1f2937;
/* Borders */
--color-border: transparent;
--color-border-subtle: rgba(128, 128, 128, 0.18);
/* Accent */
--color-accent: #7c83ff;
--color-accent-hover: #4a50e0;
/* Links */
--color-link: #646cff;
--color-link-hover: #535bf2;
/* Danger */
--color-danger: #e55;
--color-danger-bg: #a02b2b;
--color-danger-hover: #c03030;
/* Success */
--color-success: #4caf7d;
--color-success-bg: #1a6b3a;
--color-success-hover: #2d9e58;
/* Overlays */
--color-overlay: rgba(0, 0, 0, 0.45);
--color-header-user-bg: rgba(0, 0, 0, 0.2);
--color-header-user-bg-hover: rgba(0, 0, 0, 0.32);
/* Misc */
--color-option-bg: #37366e;
--color-option-border: #111827;
/* Service brand colors (fixed, not theme-dependent) */
--color-youtube: #c00;
--color-youtube-hover: #f00;
--color-bandcamp: #1da0c3;
--color-bandcamp-hover: #25c8f0;
--color-soundcloud: #f50;
--color-soundcloud-hover: #f73;
}
@media (prefers-color-scheme: light) {
:root {
--color-text: #213547;
--color-text-secondary: #64748b;
--color-text-muted: #94a3b8;
--color-bg: #ffffff;
--color-surface: #f9f9f9;
--color-border: transparent;
--color-link-hover: #747bff;
--color-option-bg: #f5f5f5;
--color-option-border: #cccccc;
--color-header-user-bg: rgba(0, 0, 0, 0.08);
--color-header-user-bg-hover: rgba(0, 0, 0, 0.15);
}
}
*,
*::before,
*::after {
@@ -83,6 +8,7 @@ body {
margin: 0;
min-height: 100vh;
overflow-x: clip;
scrollbar-gutter: stable;
background-color: var(--color-bg);
color: var(--color-text);
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

5
src/locales/fr.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@ import App from "./App.tsx";
import { i18n, loadCatalog } from "./i18n.ts";
import "./index.css";
import "./themes/smooth.css";
import "./themes/brutalist.css";
await loadCatalog();

View File

@@ -114,6 +114,24 @@ export function deserializePublicUser(raw: RawPublicUser): PublicUser {
// Alias so call sites using deserializeUser continue to work.
export const deserializeUser = deserializePublicUser;
export interface InviteTreeEntry {
id: string;
username: string;
avatarMime?: string;
invitedById: string;
createdAt: Date;
}
export type RawInviteTreeEntry = Omit<InviteTreeEntry, "createdAt"> & {
createdAt: string;
};
export function deserializeInviteTreeEntry(
raw: RawInviteTreeEntry,
): InviteTreeEntry {
return { ...raw, createdAt: new Date(raw.createdAt) };
}
/**
* Authentication
*/

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { Trans } from "@lingui/react/macro";
import { Link, useParams } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
@@ -70,33 +70,9 @@ export function UserDumps() {
const { profileUser, items: dumps, hasMore, loadingMore } = state;
return (
<PageShell>
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title={t`Dumps`}
actions={isOwnProfile && (
<button
type="button"
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
<Trans>+ New dump</Trans>
</button>
)}
/>
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
{dumps.length === 0
? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: (
<PageShell
feed={dumps.length > 0 && (
<>
<ul className="dump-feed">
{dumps.map((dump) => (
<DumpCard
@@ -111,21 +87,40 @@ export function UserDumps() {
/>
))}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
{hasMore && <div ref={sentinelRef} />}
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
)}
{!hasMore && dumps.length > 0 && (
<p className="index-status">
<Trans>
All <Plural value={dumps.length} one="# dump" other="# dumps" />
{" "}
loaded.
</Trans>
>
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title={t`Dumps`}
actions={isOwnProfile && (
<button
type="button"
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
<Trans>+ New dump</Trans>
</button>
)}
/>
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
{dumps.length === 0 && (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)}
</PageShell>

View File

@@ -411,12 +411,17 @@ export function UserPlaylists() {
))}
</ul>
)}
<div ref={createdSentinelRef} />
{created.hasMore && <div ref={createdSentinelRef} />}
{created.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!created.hasMore && created.items.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</section>
<section className="profile-section">
@@ -441,12 +446,17 @@ export function UserPlaylists() {
))}
</ul>
)}
<div ref={followedSentinelRef} />
{followed.hasMore && <div ref={followedSentinelRef} />}
{followed.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!followed.hasMore && followed.items.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</section>
{confirmDeleteId && (

View File

@@ -2,6 +2,7 @@ import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -10,15 +11,22 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, DEFAULT_PAGE_SIZE, VALIDATION } from "../config/api.ts";
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
import type {
Dump,
InviteTreeEntry,
PaginatedData,
PublicUser,
} from "../model.ts";
import {
deserializeAuthResponse,
deserializeDump,
deserializeInviteTreeEntry,
deserializePublicUser,
hydrateDump,
hydratePlaylist,
parseAPIResponse,
type RawDump,
type RawInviteTreeEntry,
type RawPublicUser,
type UpdateUserRequest,
} from "../model.ts";
@@ -29,6 +37,7 @@ import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useTheme } from "../hooks/useTheme.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
@@ -118,10 +127,38 @@ type ProfileState =
playlists: PaginatedList<Playlist>;
};
type FollowedState =
| null
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
users: PaginatedList<PublicUser>;
playlists: PaginatedList<Playlist>;
};
type InviteTreeState =
| null
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; entries: InviteTreeEntry[] };
type InviteTreeNode = InviteTreeEntry & { children: InviteTreeNode[] };
function buildInviteTree(
entries: InviteTreeEntry[],
parentId: string,
): InviteTreeNode[] {
return entries
.filter((e) => e.invitedById === parentId)
.map((e) => ({ ...e, children: buildInviteTree(entries, e.id) }));
}
export function UserPublicProfile() {
const { username } = useParams();
const navigate = useNavigate();
const { user: me, authFetch, login, logout, token } = useAuth();
const { style, colorScheme, setStyle, setColorScheme } = useTheme();
const {
voteCounts,
myVotes,
@@ -244,11 +281,25 @@ export function UserPublicProfile() {
const [emailError, setEmailError] = useState<string | null>(null);
const prevMyVotesRef = useRef<Set<string> | null>(null);
const [tab, setTab] = useState<
"dumps" | "playlists" | "followed" | "invitees" | "settings"
>("dumps");
const [followedState, setFollowedState] = useState<FollowedState>(null);
const [inviteTreeState, setInviteTreeState] = useState<InviteTreeState>(null);
const inviteTreeNodes = useMemo(() => {
if (!profileUserId || inviteTreeState?.status !== "loaded") return [];
return buildInviteTree(inviteTreeState.entries, profileUserId);
}, [inviteTreeState, profileUserId]);
const [prevUsername, setPrevUsername] = useState(username);
if (prevUsername !== username) {
setPrevUsername(username);
setState({ status: "loading" });
prevMyVotesRef.current = null;
setTab("dumps");
setFollowedState(null);
setInviteTreeState(null);
}
useEffect(() => {
@@ -468,6 +519,85 @@ export function UserPublicProfile() {
);
}, [playlistFeed, savePlaylists]);
// Lazy-load followed users + playlists when the Followed tab is first opened
useEffect(() => {
if (
tab !== "followed" || followedState !== null || state.status !== "loaded"
) {
return;
}
setFollowedState({ status: "loading" });
const controller = new AbortController();
Promise.all([
fetch(
`${API_URL}/api/users/${username}/followed-users?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ signal: controller.signal },
),
fetch(
`${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ signal: controller.signal },
),
])
.then(([ur, pr]) => Promise.all([ur.json(), pr.json()]))
.then(([ub, pb]) => {
const usersData = ub.success
? ub.data as PaginatedData<RawPublicUser>
: { items: [], hasMore: false };
const playlistsData = pb.success
? pb.data as PaginatedData<RawPlaylist>
: { items: [], hasMore: false };
setFollowedState({
status: "loaded",
users: initialList(
usersData.items.map(deserializePublicUser),
usersData.hasMore,
),
playlists: initialList(
playlistsData.items.map(deserializePlaylist),
playlistsData.hasMore,
),
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setFollowedState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
// followedState intentionally omitted: it's a read-once guard, not a trigger.
// Including it would abort the in-flight fetch when state changes to "loading".
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, state.status, username]);
// Lazy-load invite tree when the Invitees tab is first opened
useEffect(() => {
if (
tab !== "invitees" || inviteTreeState !== null ||
state.status !== "loaded"
) {
return;
}
setInviteTreeState({ status: "loading" });
const controller = new AbortController();
fetch(
`${API_URL}/api/users/${username}/invitees`,
{ signal: controller.signal },
)
.then((r) => r.json())
.then((body) => {
const entries: InviteTreeEntry[] = body.success
? (body.data as RawInviteTreeEntry[]).map(deserializeInviteTreeEntry)
: [];
setInviteTreeState({ status: "loaded", entries });
})
.catch((err) => {
if (err.name === "AbortError") return;
setInviteTreeState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
// inviteTreeState intentionally omitted — same read-once guard pattern.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, state.status, username]);
// Restore scroll position after cache restoration
const scrollRestored = useRef(false);
useLayoutEffect(() => {
@@ -826,80 +956,312 @@ export function UserPublicProfile() {
</div>
)}
<div className="profile-columns">
<DumpList
title={t`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
dumps={dumps.items}
voteCounts={voteCounts}
myVotes={myVotes}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
isOwnProfile={isOwnProfile}
viewAllHref={`/users/${profileUser.username}/dumps`}
/>
<UpvotedDumpList
title={t`Upvoted (${votes.items.length}${votes.hasMore ? "+" : ""})`}
dumps={votes.items}
profileUserId={profileUserId}
isOwnProfile={isOwnProfile}
voteCounts={voteCounts}
myVotes={myVotes}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
viewAllHref={`/users/${profileUser.username}/upvoted`}
/>
<div className="profile-tabs feed-sort">
<button
type="button"
className={`feed-sort-btn${tab === "dumps" ? " active" : ""}`}
onClick={() => setTab("dumps")}
>
<Trans>Dumps</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${tab === "playlists" ? " active" : ""}`}
onClick={() => setTab("playlists")}
>
<Trans>Playlists</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
onClick={() => setTab("followed")}
>
<Trans>Followed</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${tab === "invitees" ? " active" : ""}`}
onClick={() => setTab("invitees")}
>
<Trans>Invitees</Trans>
</button>
{isOwnProfile && (
<button
type="button"
className={`feed-sort-btn${tab === "settings" ? " active" : ""}`}
onClick={() => setTab("settings")}
>
<Trans>Settings</Trans>
</button>
)}
</div>
<section className="profile-section" id="playlists">
<div className="profile-section-header">
<h2 className="profile-section-title">
<Trans>
Playlists ({playlists.items.length}
{playlists.hasMore ? "+" : ""})
</Trans>
</h2>
{isOwnProfile && (
<NewPlaylistForm
onCreated={(p) =>
setState((s) => {
if (s.status !== "loaded") return s;
if (s.playlists.items.some((pl) => pl.id === p.id)) return s;
return {
...s,
playlists: {
...s.playlists,
items: [p, ...s.playlists.items],
},
};
})}
/>
)}
{tab === "dumps" && (
<div className="profile-columns">
<DumpList
title={t`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
dumps={dumps.items}
voteCounts={voteCounts}
myVotes={myVotes}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
isOwnProfile={isOwnProfile}
viewAllHref={`/users/${profileUser.username}/dumps`}
/>
<UpvotedDumpList
title={t`Upvoted (${votes.items.length}${
votes.hasMore ? "+" : ""
})`}
dumps={votes.items}
profileUserId={profileUserId}
isOwnProfile={isOwnProfile}
voteCounts={voteCounts}
myVotes={myVotes}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
viewAllHref={`/users/${profileUser.username}/upvoted`}
/>
</div>
{playlists.items.length === 0
? (
<p className="empty-state">
<Trans>No playlists yet.</Trans>
</p>
)
: (
<ul className="dump-feed">
{playlists.items.map((p) => (
<PlaylistCard key={p.id} playlist={p} isOwner={isOwnProfile} />
))}
</ul>
)}
{tab === "playlists" && (
<section className="profile-section" id="playlists">
<div className="profile-section-header">
<h2 className="profile-section-title">
<Trans>
Playlists ({playlists.items.length}
{playlists.hasMore ? "+" : ""})
</Trans>
</h2>
{isOwnProfile && (
<NewPlaylistForm
onCreated={(p) =>
setState((s) => {
if (s.status !== "loaded") return s;
if (
s.playlists.items.some((pl) =>
pl.id === p.id
)
) {
return s;
}
return {
...s,
playlists: {
...s.playlists,
items: [p, ...s.playlists.items],
},
};
})}
/>
)}
</div>
{playlists.items.length === 0
? (
<p className="empty-state">
<Trans>No playlists yet.</Trans>
</p>
)
: (
<ul className="dump-feed">
{playlists.items.map((p) => (
<PlaylistCard
key={p.id}
playlist={p}
isOwner={isOwnProfile}
/>
))}
</ul>
)}
{playlists.items.length > 0 && (
<Link
to={`/users/${profileUser.username}/playlists`}
className="profile-view-all"
>
<Trans>View all </Trans>
</Link>
)}
{playlists.items.length > 0 && (
<Link
to={`/users/${profileUser.username}/playlists`}
className="profile-view-all"
>
<Trans>View all </Trans>
</Link>
)}
</section>
</section>
)}
{tab === "followed" && (
<div className="profile-columns">
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Following</Trans>
</h2>
{followedState === null || followedState.status === "loading"
? (
<p className="index-status">
<Trans>Loading</Trans>
</p>
)
: followedState.status === "error"
? (
<ErrorCard
title={t`Failed to load`}
message={followedState.error}
/>
)
: followedState.users.items.length === 0
? (
<p className="empty-state">
<Trans>Not following anyone yet.</Trans>
</p>
)
: (
<>
<ul className="followed-user-list">
{followedState.users.items.map((u) => (
<FollowedUserCard key={u.id} user={u} />
))}
</ul>
{followedState.users.hasMore && (
<Link
to={`/users/${profileUser.username}/following`}
className="profile-view-all"
>
<Trans>View all </Trans>
</Link>
)}
</>
)}
</section>
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Followed playlists</Trans>
</h2>
{followedState === null || followedState.status === "loading"
? (
<p className="index-status">
<Trans>Loading</Trans>
</p>
)
: followedState.status === "error"
? (
<ErrorCard
title={t`Failed to load`}
message={followedState.error}
/>
)
: followedState.playlists.items.length === 0
? (
<p className="empty-state">
<Trans>No followed playlists yet.</Trans>
</p>
)
: (
<>
<ul className="dump-feed">
{followedState.playlists.items.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</ul>
{followedState.playlists.hasMore && (
<Link
to={`/users/${profileUser.username}/playlists`}
className="profile-view-all"
>
<Trans>View all </Trans>
</Link>
)}
</>
)}
</section>
</div>
)}
{tab === "invitees" && (
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Invitees</Trans>
</h2>
{inviteTreeState === null || inviteTreeState.status === "loading"
? (
<p className="index-status">
<Trans>Loading</Trans>
</p>
)
: inviteTreeState.status === "error"
? (
<ErrorCard
title={t`Failed to load`}
message={inviteTreeState.error}
/>
)
: inviteTreeState.entries.length === 0
? (
<p className="empty-state invite-tree-empty">
<Trans>No invitees yet.</Trans>
</p>
)
: <InviteTreeList nodes={inviteTreeNodes} />}
</section>
)}
{tab === "settings" && isOwnProfile && (
<>
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Appearance</Trans>
</h2>
<div className="profile-appearance-grid">
<div className="profile-appearance-row">
<span className="profile-appearance-label">
<Trans>Style</Trans>
</span>
<div className="visibility-toggle">
<button
type="button"
className={style === "smooth" ? "active" : ""}
onClick={() => setStyle("smooth")}
>
Smooth
</button>
<button
type="button"
className={style === "brutalist" ? "active" : ""}
onClick={() => setStyle("brutalist")}
>
Brutalist
</button>
</div>
</div>
<div className="profile-appearance-row">
<span className="profile-appearance-label">
<Trans>Color scheme</Trans>
</span>
<div className="visibility-toggle">
<button
type="button"
className={colorScheme === "auto" ? "active" : ""}
onClick={() => setColorScheme("auto")}
>
<Trans>Auto</Trans>
</button>
<button
type="button"
className={colorScheme === "light" ? "active" : ""}
onClick={() => setColorScheme("light")}
>
<Trans>Light</Trans>
</button>
<button
type="button"
className={colorScheme === "dark" ? "active" : ""}
onClick={() => setColorScheme("dark")}
>
<Trans>Dark</Trans>
</button>
</div>
</div>
</div>
</section>
</>
)}
</PageShell>
);
}
@@ -1109,3 +1471,52 @@ function UpvotedDumpList(
</section>
);
}
// ── Invite tree ──────────────────────────────────────────────────────────────
function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) {
return (
<ul className="invite-tree">
{nodes.map((node) => (
<li key={node.id} className="invite-tree-node">
<Link
to={`/users/${node.username}`}
className="invite-tree-user"
>
<Avatar
userId={node.id}
username={node.username}
hasAvatar={!!node.avatarMime}
size={24}
/>
@{node.username}
</Link>
{node.children.length > 0 && <InviteTreeList nodes={node.children} />}
</li>
))}
</ul>
);
}
// ── Followed user card ───────────────────────────────────────────────────────
function FollowedUserCard({ user }: { user: PublicUser }) {
return (
<li className="followed-user-card">
<Link to={`/users/${user.username}`} className="followed-user-card-link">
<Avatar
userId={user.id}
username={user.username}
hasAvatar={!!user.avatarMime}
size={36}
version={user.updatedAt?.getTime()}
/>
<span className="followed-user-card-name">@{user.username}</span>
</Link>
<FollowUserButton
targetUserId={user.id}
targetUsername={user.username}
/>
</li>
);
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
@@ -145,20 +145,9 @@ export function UserUpvoted() {
);
return (
<PageShell>
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title={t`Upvoted`}
/>
{visibleDumps.length === 0
? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: (
<PageShell
feed={visibleDumps.length > 0 && (
<>
<ul className="dump-feed">
{visibleDumps.map((dump) => {
const phase = fading[dump.id];
@@ -182,25 +171,28 @@ export function UserUpvoted() {
);
})}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
{hasMore && <div ref={sentinelRef} />}
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
)}
{!hasMore && visibleDumps.length > 0 && (
<p className="index-status">
<Trans>
All{" "}
<Plural
value={votes.length}
one="# upvoted dump"
other="# upvoted dumps"
/>{" "}
loaded.
</Trans>
>
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title={t`Upvoted`}
/>
{visibleDumps.length === 0 && (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)}
</PageShell>

View File

@@ -103,12 +103,17 @@ function FollowedSubFeed({
/>
))}
</ul>
<div ref={sentinelRef} />
{state.hasMore && <div ref={sentinelRef} />}
{state.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!state.hasMore && state.dumps.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
);
}

479
src/themes/brutalist.css Normal file
View File

@@ -0,0 +1,479 @@
/* ══════════════════════════════════════════════════════════════════════
Brutalist theme — pastel palette, hard edges, bold type
Palette: Eggshell · Burnt Peach · Twilight Indigo · Muted Teal · Apricot Cream
══════════════════════════════════════════════════════════════════════ */
/* ── Dark variant (default for this theme) ────────────────────────── */
:root[data-style="brutalist"] {
--color-text: #f4f1de; /* Eggshell */
--color-text-secondary: #c8c5b2;
--color-text-muted: #8e8c80;
--color-on-accent: #3d405b; /* Twilight Indigo on Apricot Cream */
--color-bg: #252838; /* darkened Twilight Indigo */
--color-surface: #2e3145;
--color-border: #4a4e6a;
--color-border-subtle: rgba(74, 78, 106, 0.4);
--color-accent: #f2cc8f; /* Apricot Cream */
--color-accent-hover: #d4a860;
--color-link: #f2cc8f;
--color-link-hover: #d4a860;
--color-danger: #e07a5f; /* Burnt Peach */
--color-danger-bg: #3a1e18;
--color-danger-hover: #c85a40;
--color-success: #81b29a; /* Muted Teal */
--color-success-bg: #1a2e26;
--color-success-hover: #5a9878;
--color-overlay: rgba(0, 0, 0, 0.6);
--color-header-user-bg: rgba(242, 204, 143, 0.07);
--color-header-user-bg-hover: rgba(242, 204, 143, 0.14);
--color-option-bg: #222436;
--color-option-border: #252838;
}
/* ── Light via system preference (auto color-scheme) ──────────────── */
@media (prefers-color-scheme: light) {
:root[data-style="brutalist"]:not([data-color-scheme]) {
--color-text: #3d405b; /* Twilight Indigo */
--color-text-secondary: #5a5e80;
--color-text-muted: #7a7e9a;
--color-on-accent: #f4f1de; /* Eggshell on Burnt Peach */
--color-bg: #f4f1de; /* Eggshell */
--color-surface: #f8f5e8;
--color-border: #8a8da8;
--color-border-subtle: rgba(61, 64, 91, 0.25);
--color-accent: #e07a5f; /* Burnt Peach — visible against Eggshell */
--color-accent-hover: #c05040;
--color-link: #b85a3a; /* darkened Burnt Peach for legibility */
--color-link-hover: #943020;
--color-danger: #c04030;
--color-danger-bg: #fae4e0;
--color-danger-hover: #a02820;
--color-success: #3a7860;
--color-success-bg: #e0f0ea;
--color-success-hover: #286048;
--color-overlay: rgba(0, 0, 0, 0.5);
--color-header-user-bg: rgba(224, 122, 95, 0.08);
--color-header-user-bg-hover: rgba(224, 122, 95, 0.16);
--color-option-bg: #ede9d4;
--color-option-border: #e0dcc8;
}
}
/* ── Manual dark override ─────────────────────────────────────────── */
:root[data-style="brutalist"][data-color-scheme="dark"] {
color-scheme: dark;
--color-text: #f4f1de;
--color-text-secondary: #c8c5b2;
--color-text-muted: #8e8c80;
--color-on-accent: #3d405b;
--color-bg: #252838;
--color-surface: #2e3145;
--color-border: #4a4e6a;
--color-border-subtle: rgba(74, 78, 106, 0.4);
--color-accent: #f2cc8f;
--color-accent-hover: #d4a860;
--color-link: #f2cc8f;
--color-link-hover: #d4a860;
--color-danger: #e07a5f;
--color-danger-bg: #3a1e18;
--color-danger-hover: #c85a40;
--color-success: #81b29a;
--color-success-bg: #1a2e26;
--color-success-hover: #5a9878;
--color-overlay: rgba(0, 0, 0, 0.6);
--color-header-user-bg: rgba(242, 204, 143, 0.07);
--color-header-user-bg-hover: rgba(242, 204, 143, 0.14);
--color-option-bg: #222436;
--color-option-border: #252838;
}
/* ── Manual light override ────────────────────────────────────────── */
:root[data-style="brutalist"][data-color-scheme="light"] {
color-scheme: light;
--color-text: #3d405b;
--color-text-secondary: #5a5e80;
--color-text-muted: #7a7e9a;
--color-on-accent: #f4f1de;
--color-bg: #f4f1de;
--color-surface: #f8f5e8;
--color-border: #8a8da8;
--color-border-subtle: rgba(61, 64, 91, 0.25);
--color-accent: #e07a5f;
--color-accent-hover: #c05040;
--color-link: #b85a3a;
--color-link-hover: #943020;
--color-danger: #c04030;
--color-danger-bg: #fae4e0;
--color-danger-hover: #a02820;
--color-success: #3a7860;
--color-success-bg: #e0f0ea;
--color-success-hover: #286048;
--color-overlay: rgba(0, 0, 0, 0.5);
--color-header-user-bg: rgba(224, 122, 95, 0.08);
--color-header-user-bg-hover: rgba(224, 122, 95, 0.16);
--color-option-bg: #ede9d4;
--color-option-border: #e0dcc8;
}
/* ══════════════════════════════════════════════════════════════════════
Structural overrides
══════════════════════════════════════════════════════════════════════ */
/* ── Typography ──────────────────────────────────────────────────── */
[data-style="brutalist"] {
font-family: "Space Grotesk", "Saira", sans-serif;
font-weight: 500;
}
/* Only section-level headings get the uppercase treatment */
[data-style="brutalist"] h1 {
font-weight: 900;
letter-spacing: 0.03em;
}
/* ── Buttons — square corners, modest hard shadow ────────────────── */
[data-style="brutalist"] button {
border-radius: 0;
border: 2px solid var(--color-border);
font-weight: 700;
box-shadow: 2px 2px 0 var(--color-border);
transition: box-shadow 0.08s ease, transform 0.08s ease, border-color 0.1s;
}
[data-style="brutalist"] button:hover:not(:disabled) {
border-color: var(--color-border);
box-shadow: none;
transform: translate(2px, 2px);
}
/* Ghost / icon-only buttons — keep borderless, no press effect */
[data-style="brutalist"] .modal-close-btn,
[data-style="brutalist"] .playlist-remove-btn,
[data-style="brutalist"] .playlist-card-delete-btn,
[data-style="brutalist"] .vote-btn,
[data-style="brutalist"] .emoji-picker-float [frimousse-emoji],
[data-style="brutalist"] .emoji-picker-close-btn,
[data-style="brutalist"] .new-playlist-toggle,
[data-style="brutalist"] .btn--ghost,
[data-style="brutalist"] .audio-player-btn,
[data-style="brutalist"] .audio-player-vol-btn,
[data-style="brutalist"] .rich-content-thumbnail-btn {
border: none;
box-shadow: none;
}
[data-style="brutalist"] .modal-close-btn:hover,
[data-style="brutalist"] .playlist-remove-btn:hover,
[data-style="brutalist"] .playlist-card-delete-btn:hover,
[data-style="brutalist"] .vote-btn:hover:not(:disabled),
[data-style="brutalist"] .emoji-picker-float [frimousse-emoji]:hover,
[data-style="brutalist"] .emoji-picker-close-btn:hover,
[data-style="brutalist"] .new-playlist-toggle:hover:not(:disabled),
[data-style="brutalist"] .btn--ghost:hover:not(:disabled),
[data-style="brutalist"] .audio-player-btn:hover:not(:disabled),
[data-style="brutalist"] .audio-player-vol-btn:hover:not(:disabled),
[data-style="brutalist"] .rich-content-thumbnail-btn:hover:not(:disabled) {
transform: none;
box-shadow: none;
}
/* ── Segmented controls / visibility-toggle ──────────────────────── */
[data-style="brutalist"] .visibility-toggle {
border-radius: 0;
border: 2px solid var(--color-border);
box-shadow: 2px 2px 0 var(--color-border);
}
[data-style="brutalist"] .visibility-toggle button {
border-radius: 0;
border: none;
box-shadow: none;
}
[data-style="brutalist"] .visibility-toggle button:hover:not(:disabled) {
transform: none;
box-shadow: none;
}
/* ── Cards ───────────────────────────────────────────────────────── */
[data-style="brutalist"] .dump-card,
[data-style="brutalist"] .playlist-card {
border-radius: 0;
border: 2px solid var(--color-border);
box-shadow: 3px 3px 0 var(--color-border);
transition:
box-shadow 0.08s ease,
transform 0.08s ease,
border-color 0.1s,
grid-template-rows 0.32s ease,
opacity 0.25s ease,
filter 0.3s ease;
}
[data-style="brutalist"] .dump-card:hover,
[data-style="brutalist"] .playlist-card:hover {
border-color: var(--color-accent);
box-shadow: 3px 3px 0 var(--color-accent);
transform: translate(-2px, -2px);
}
/* Don't shift cards that are animating out */
[data-style="brutalist"] .dump-card--fading,
[data-style="brutalist"] .dump-card--dismissing {
transform: none;
}
/* ── Dump post detail header ─────────────────────────────────────── */
[data-style="brutalist"] .dump-post-header {
border-radius: 0;
border: 2px solid var(--color-border);
box-shadow: 3px 3px 0 var(--color-border);
}
/* ── App header ──────────────────────────────────────────────────── */
[data-style="brutalist"] .app-header {
border-bottom: 2px solid var(--color-border);
}
[data-style="brutalist"] .app-header-user {
border-radius: 0;
border: 2px solid var(--color-border);
box-shadow: 2px 2px 0 var(--color-border);
transition: box-shadow 0.08s ease, transform 0.08s ease, background 0.1s;
}
[data-style="brutalist"] .app-header-user:hover {
box-shadow: none;
transform: translate(2px, 2px);
}
/* ── Inputs / textareas ──────────────────────────────────────────── */
[data-style="brutalist"]
input:not([type="range"]):not([type="file"]):not([type="checkbox"]):not(
[type="radio"]
),
[data-style="brutalist"] textarea {
border-radius: 0 !important;
border-width: 2px !important;
border-color: var(--color-border) !important;
}
[data-style="brutalist"] .dump-form input:focus,
[data-style="brutalist"] .dump-form textarea:focus,
[data-style="brutalist"] .auth-form input:focus,
[data-style="brutalist"] .auth-form textarea:focus,
[data-style="brutalist"] .mention-textarea-wrap textarea:focus {
box-shadow: 2px 2px 0 var(--color-accent);
}
/* ── Search bar ──────────────────────────────────────────────────── */
[data-style="brutalist"] .search-bar-input,
[data-style="brutalist"] .search-bar-btn {
border-radius: 0;
}
/* ── Modals ──────────────────────────────────────────────────────── */
[data-style="brutalist"] .modal-card,
[data-style="brutalist"] .confirm-modal {
border-radius: 0;
border: 2px solid var(--color-border);
box-shadow: 4px 4px 0 var(--color-border);
}
[data-style="brutalist"] .modal-header {
border-bottom: 2px solid var(--color-border);
}
/* ── Section headings ────────────────────────────────────────────── */
[data-style="brutalist"] .profile-section > h2,
[data-style="brutalist"] .profile-section-header,
[data-style="brutalist"] .comment-section-title {
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 700;
}
[data-style="brutalist"] .profile-section > h2,
[data-style="brutalist"] .profile-section-header {
border-bottom-width: 2px;
}
/* ── Avatars — square ────────────────────────────────────────────── */
[data-style="brutalist"] .avatar-img,
[data-style="brutalist"] .avatar-initials {
border-radius: 0;
}
[data-style="brutalist"] .avatar-change-overlay {
border-radius: 0;
}
/* ── Rich content cards ──────────────────────────────────────────── */
[data-style="brutalist"] .rich-content-card {
border-radius: 0;
box-shadow: 3px 3px 0 var(--color-border);
}
/* Neutralise provider brand border-colors so they don't fight the hard shadow */
[data-style="brutalist"] .rich-content-card--youtube,
[data-style="brutalist"] .rich-content-card--bandcamp,
[data-style="brutalist"] .rich-content-card--soundcloud {
border-color: var(--color-border);
}
[data-style="brutalist"]
.rich-content-card--youtube:has(.rich-content-body:hover),
[data-style="brutalist"]
.rich-content-card--bandcamp:has(.rich-content-body:hover),
[data-style="brutalist"]
.rich-content-card--soundcloud:has(.rich-content-body:hover) {
border-color: var(--color-accent);
box-shadow: 3px 3px 0 var(--color-accent);
}
/* ── File drop zone ──────────────────────────────────────────────── */
[data-style="brutalist"] .fdz {
border-radius: 0;
}
/* ── Global persistent player ────────────────────────────────────── */
[data-style="brutalist"] .global-player {
border-radius: 0;
border: 2px solid var(--color-border);
box-shadow: 0 0 0 2px var(--color-border);
}
[data-style="brutalist"] .global-player-iframe-wrap,
[data-style="brutalist"] .global-player-media-wrap,
[data-style="brutalist"] .global-player .audio-player,
[data-style="brutalist"] .global-player .video-player {
border-radius: 0;
}
[data-style="brutalist"] .global-player .audio-player-btn {
background: transparent;
color: var(--color-text);
}
[data-style="brutalist"] .global-player .audio-player-btn:hover {
background: var(--color-border-subtle);
color: var(--color-text);
}
[data-style="brutalist"] .global-player .audio-player-vol-btn {
color: var(--color-text);
}
[data-style="brutalist"] .global-player .btn--ghost {
color: var(--color-text);
}
/* ── Notification items ──────────────────────────────────────────── */
[data-style="brutalist"] .notification-item {
border-radius: 0;
border: 2px solid var(--color-border);
box-shadow: 2px 2px 0 var(--color-border);
}
[data-style="brutalist"] .notifications-unread-pill {
border-radius: 0;
}
[data-style="brutalist"] .notif-dot {
border-radius: 0;
box-shadow: none;
}
[data-style="brutalist"] .notif-icon {
border-radius: 0;
}
/* ── Notification badge ──────────────────────────────────────────── */
[data-style="brutalist"] .notification-badge {
border-radius: 0;
}
/* ── Markdown code ───────────────────────────────────────────────── */
[data-style="brutalist"] .md code,
[data-style="brutalist"] .md pre {
border-radius: 0;
}
/* ── User result items (search) ──────────────────────────────────── */
[data-style="brutalist"] .user-result-item {
border-radius: 0;
border: 2px solid var(--color-border);
box-shadow: 2px 2px 0 var(--color-border);
transition: box-shadow 0.08s ease, transform 0.08s ease, border-color 0.1s;
}
[data-style="brutalist"] .user-result-item:hover {
border-color: var(--color-accent);
box-shadow: 2px 2px 0 var(--color-accent);
transform: translate(-2px, -2px);
}
/* ── Feed tab bar ────────────────────────────────────────────────── */
[data-style="brutalist"] .feed-tab {
border-radius: 0;
}
/* ── btn-primary ─────────────────────────────────────────────────── */
[data-style="brutalist"] .btn-primary {
border-radius: 0;
border: 2px solid var(--color-border);
box-shadow: 2px 2px 0 var(--color-border);
transition: box-shadow 0.08s ease, transform 0.08s ease;
}
[data-style="brutalist"] .btn-primary:hover:not(:disabled) {
box-shadow: none;
transform: translate(2px, 2px);
}
[data-style="brutalist"] .btn-primary:active {
box-shadow: none;
transform: translate(2px, 2px);
}
/* ── btn-border ──────────────────────────────────────────────────── */
[data-style="brutalist"] .btn-border,
[data-style="brutalist"] .btn-border-danger,
[data-style="brutalist"] .btn-border-success {
border-radius: 0;
box-shadow: 2px 2px 0 var(--color-border);
}
[data-style="brutalist"] .btn-border:hover,
[data-style="brutalist"] .btn-border-danger:hover,
[data-style="brutalist"] .btn-border-success:hover {
box-shadow: none;
transform: translate(2px, 2px);
}
/* ── Comment actions ─────────────────────────────────────────────── */
[data-style="brutalist"] .comment-submit-btn,
[data-style="brutalist"] .comment-action-btn {
border-radius: 0;
}
/* ── Error card ──────────────────────────────────────────────────── */
[data-style="brutalist"] .error-card {
border-radius: 0;
}

162
src/themes/smooth.css Normal file
View File

@@ -0,0 +1,162 @@
/* ══════════════════════════════════════════════════════════════════════
Smooth theme — clean, modern, Saira typeface
Palette: Prussian Blue · Deep Teal · Jasmine · Brick Ember · Blood Red
Applied when data-style is absent or "smooth".
══════════════════════════════════════════════════════════════════════ */
/* ── Dark variant (default) ───────────────────────────────────────── */
:root,
:root[data-style="smooth"] {
font-family: "Saira", sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
line-height: 1.5;
color-scheme: light dark;
/* Text */
--color-text: #eef0f3;
--color-text-secondary: #8aaabb;
--color-text-muted: #506878;
--color-on-accent: #001427; /* Prussian Blue on Jasmine */
/* Surfaces */
--color-bg: #001427; /* Prussian Blue */
--color-surface: #0a1f35;
/* Borders */
--color-border: transparent;
--color-border-subtle: rgba(112, 141, 129, 0.2); /* Deep Teal tint */
/* Accent */
--color-accent: #f4d58d; /* Jasmine */
--color-accent-hover: #d4aa5a;
/* Links */
--color-link: #f4d58d;
--color-link-hover: #d4aa5a;
/* Danger */
--color-danger: #bf0603; /* Brick Ember */
--color-danger-bg: #220404;
--color-danger-hover: #8d0801; /* Blood Red */
/* Success */
--color-success: #708d81; /* Deep Teal */
--color-success-bg: #0d2820;
--color-success-hover: #8aaa9a;
/* Overlays */
--color-overlay: rgba(0, 10, 20, 0.55);
--color-header-user-bg: rgba(244, 213, 141, 0.06);
--color-header-user-bg-hover: rgba(244, 213, 141, 0.12);
/* Misc */
--color-option-bg: #0d2340;
--color-option-border: #001427;
/* Service brand colors (fixed, not theme-dependent) */
--color-youtube: #c00;
--color-youtube-hover: #f00;
--color-bandcamp: #1da0c3;
--color-bandcamp-hover: #25c8f0;
--color-soundcloud: #f50;
--color-soundcloud-hover: #f73;
}
/* ── Light via system preference (auto color-scheme) ──────────────── */
@media (prefers-color-scheme: light) {
:root:not([data-style]),
:root[data-style="smooth"]:not([data-color-scheme]) {
--color-text: #001427; /* Prussian Blue */
--color-text-secondary: #3a5a70;
--color-text-muted: #6a8898;
--color-on-accent: #ffffff;
--color-bg: #f0f4f8;
--color-surface: #ffffff;
--color-border: transparent;
--color-border-subtle: rgba(0, 20, 39, 0.12);
--color-accent: #bf0603; /* Brick Ember — pops on light bg */
--color-accent-hover: #8d0801;
--color-link: #bf0603;
--color-link-hover: #8d0801;
--color-danger: #bf0603;
--color-danger-bg: #fde8e8;
--color-danger-hover: #8d0801;
--color-success: #4a6e60; /* darkened Deep Teal */
--color-success-bg: #e0ede8;
--color-success-hover: #2e5448;
--color-overlay: rgba(0, 10, 20, 0.4);
--color-header-user-bg: rgba(0, 20, 39, 0.07);
--color-header-user-bg-hover: rgba(0, 20, 39, 0.13);
--color-option-bg: #f5f7fa;
--color-option-border: #dde3ea;
}
}
/* ── Manual dark override ─────────────────────────────────────────── */
:root:not([data-style])[data-color-scheme="dark"],
:root[data-style="smooth"][data-color-scheme="dark"] {
color-scheme: dark;
--color-text: #eef0f3;
--color-text-secondary: #8aaabb;
--color-text-muted: #506878;
--color-on-accent: #001427;
--color-bg: #001427;
--color-surface: #0a1f35;
--color-border: transparent;
--color-border-subtle: rgba(112, 141, 129, 0.2);
--color-accent: #f4d58d;
--color-accent-hover: #d4aa5a;
--color-link: #f4d58d;
--color-link-hover: #d4aa5a;
--color-danger: #bf0603;
--color-danger-bg: #220404;
--color-danger-hover: #8d0801;
--color-success: #708d81;
--color-success-bg: #0d2820;
--color-success-hover: #8aaa9a;
--color-overlay: rgba(0, 10, 20, 0.55);
--color-header-user-bg: rgba(244, 213, 141, 0.06);
--color-header-user-bg-hover: rgba(244, 213, 141, 0.12);
--color-option-bg: #0d2340;
--color-option-border: #001427;
}
/* ── Manual light override ────────────────────────────────────────── */
:root:not([data-style])[data-color-scheme="light"],
:root[data-style="smooth"][data-color-scheme="light"] {
color-scheme: light;
--color-text: #001427;
--color-text-secondary: #3a5a70;
--color-text-muted: #6a8898;
--color-on-accent: #ffffff;
--color-bg: #f0f4f8;
--color-surface: #ffffff;
--color-border: transparent;
--color-border-subtle: rgba(0, 20, 39, 0.12);
--color-accent: #bf0603;
--color-accent-hover: #8d0801;
--color-link: #bf0603;
--color-link-hover: #8d0801;
--color-danger: #bf0603;
--color-danger-bg: #fde8e8;
--color-danger-hover: #8d0801;
--color-success: #4a6e60;
--color-success-bg: #e0ede8;
--color-success-hover: #2e5448;
--color-overlay: rgba(0, 10, 20, 0.4);
--color-header-user-bg: rgba(0, 20, 39, 0.07);
--color-header-user-bg-hover: rgba(0, 20, 39, 0.13);
--color-option-bg: #f5f7fa;
--color-option-border: #dde3ea;
}