v3: added multiple stylesheets, improved user profiles
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
168
src/App.css
168
src/App.css
@@ -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;
|
||||
}
|
||||
|
||||
27
src/App.tsx
27
src/App.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
18
src/contexts/ThemeContext.ts
Normal file
18
src/contexts/ThemeContext.ts
Normal 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: () => {},
|
||||
});
|
||||
52
src/contexts/ThemeProvider.tsx
Normal file
52
src/contexts/ThemeProvider.tsx
Normal 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
6
src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
import { ThemeContext } from "../contexts/ThemeContext.ts";
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
@@ -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
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
@@ -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();
|
||||
|
||||
|
||||
18
src/model.ts
18
src/model.ts
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
479
src/themes/brutalist.css
Normal 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
162
src/themes/smooth.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user