v3: added index default tab selection setting, fixed notification pages not loading, reduced invite token length, global player state survives page reloads, many visual fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s
This commit is contained in:
@@ -3,9 +3,7 @@ import { jwtVerify, SignJWT } from "@panva/jose";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AuthPayload,
|
type AuthPayload,
|
||||||
InvitePayload,
|
|
||||||
isAuthPayload,
|
isAuthPayload,
|
||||||
isInvitePayload,
|
|
||||||
isPasswordResetPayload,
|
isPasswordResetPayload,
|
||||||
type PasswordResetPayload,
|
type PasswordResetPayload,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
@@ -18,28 +16,6 @@ if (!JWT_SECRET) {
|
|||||||
}
|
}
|
||||||
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
|
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
|
||||||
|
|
||||||
// ── Invite tokens ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function createInviteToken(inviterId: string): Promise<string> {
|
|
||||||
return await new SignJWT({ purpose: "invite", inviterId })
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setJti(crypto.randomUUID())
|
|
||||||
.setExpirationTime("7d")
|
|
||||||
.sign(JWT_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyInviteToken(
|
|
||||||
token: string,
|
|
||||||
): Promise<InvitePayload | null> {
|
|
||||||
try {
|
|
||||||
const { payload } = await jwtVerify(token, JWT_KEY);
|
|
||||||
if (!isInvitePayload(payload)) return null;
|
|
||||||
return payload as InvitePayload;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Password reset tokens ─────────────────────────────────────────────────────
|
// ── Password reset tokens ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function createPasswordResetToken(
|
export async function createPasswordResetToken(
|
||||||
|
|||||||
@@ -165,19 +165,6 @@ export function isAuthPayload(obj: unknown): obj is AuthPayload {
|
|||||||
"exp" in obj && typeof obj.exp === "number";
|
"exp" in obj && typeof obj.exp === "number";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvitePayload {
|
|
||||||
purpose: "invite";
|
|
||||||
inviterId: string;
|
|
||||||
exp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isInvitePayload(obj: unknown): obj is InvitePayload {
|
|
||||||
return !!obj && typeof obj === "object" &&
|
|
||||||
"purpose" in obj && (obj as Record<string, unknown>).purpose === "invite" &&
|
|
||||||
"inviterId" in obj &&
|
|
||||||
typeof (obj as Record<string, unknown>).inviterId === "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordResetPayload {
|
export interface PasswordResetPayload {
|
||||||
purpose: "password-reset";
|
purpose: "password-reset";
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ import { createInvite, validateInvite } from "../services/invite-service.ts";
|
|||||||
const router = new Router({ prefix: "/api/invites" });
|
const router = new Router({ prefix: "/api/invites" });
|
||||||
|
|
||||||
// Create a new invite link (any authenticated user)
|
// Create a new invite link (any authenticated user)
|
||||||
router.post("/", authMiddleware, async (ctx: AuthContext) => {
|
router.post("/", authMiddleware, (ctx: AuthContext) => {
|
||||||
if (!ctx.state.user) {
|
if (!ctx.state.user) {
|
||||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||||
}
|
}
|
||||||
const token = await createInvite(ctx.state.user.userId);
|
const token = createInvite(ctx.state.user.userId);
|
||||||
ctx.response.status = 201;
|
ctx.response.status = 201;
|
||||||
ctx.response.body = { success: true, data: { token } };
|
ctx.response.body = { success: true, data: { token } };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate an invite token (used by the register page before showing the form)
|
// Validate an invite token (used by the register page before showing the form)
|
||||||
router.get("/:token", async (ctx) => {
|
router.get("/:token", (ctx) => {
|
||||||
try {
|
try {
|
||||||
await validateInvite(ctx.params.token);
|
validateInvite(ctx.params.token);
|
||||||
ctx.response.body = { success: true, data: null };
|
ctx.response.body = { success: true, data: null };
|
||||||
} catch {
|
} catch {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ router.post("/register", async (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate invite — throws 404/409 if bad
|
// Validate invite — throws 404/409 if bad
|
||||||
const inviterId = await validateInvite(body.inviteToken);
|
const inviterId = validateInvite(body.inviteToken);
|
||||||
|
|
||||||
const user = await createUser(body, inviterId);
|
const user = await createUser(body, inviterId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
import { db, isInviteRow } from "../model/db.ts";
|
import { db, isInviteRow } from "../model/db.ts";
|
||||||
import { createInviteToken, verifyInviteToken } from "../lib/jwt.ts";
|
import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts";
|
||||||
|
|
||||||
export async function createInvite(inviterId: string): Promise<string> {
|
/** Short, opaque, URL-safe invite token (128 bits of entropy → 22 chars). */
|
||||||
const token = await createInviteToken(inviterId);
|
function generateInviteToken(): string {
|
||||||
|
return randomBytes(16).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInvite(inviterId: string): string {
|
||||||
|
const token = generateInviteToken();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO invites (token, inviter_id, created_at) VALUES (?, ?, ?);`,
|
`INSERT INTO invites (token, inviter_id, created_at) VALUES (?, ?, ?);`,
|
||||||
).run(token, inviterId, new Date().toISOString());
|
).run(token, inviterId, new Date().toISOString());
|
||||||
@@ -11,25 +17,25 @@ export async function createInvite(inviterId: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies the JWT signature + expiry and checks the token exists and has not
|
* Looks the token up, checks it has not been used and has not expired (same
|
||||||
* been used. Returns the inviterId on success; throws APIException otherwise.
|
* 7-day window as the startup cleanup). Returns the inviterId on success;
|
||||||
|
* throws APIException otherwise.
|
||||||
|
*
|
||||||
|
* Tokens are opaque random strings stored as the row's primary key. Invites
|
||||||
|
* created before this scheme (full JWTs) are still stored verbatim in the
|
||||||
|
* token column, so they continue to validate by direct lookup.
|
||||||
*/
|
*/
|
||||||
export async function validateInvite(token: string): Promise<string> {
|
export function validateInvite(token: string): string {
|
||||||
const payload = await verifyInviteToken(token);
|
|
||||||
if (!payload) {
|
|
||||||
throw new APIException(
|
|
||||||
APIErrorCode.NOT_FOUND,
|
|
||||||
404,
|
|
||||||
"Invalid or expired invite",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = db.prepare(
|
const row = db.prepare(
|
||||||
`SELECT token, inviter_id, used_at, created_at FROM invites WHERE token = ?;`,
|
`SELECT token, inviter_id, used_at, created_at FROM invites WHERE token = ?;`,
|
||||||
).get(token);
|
).get(token);
|
||||||
|
|
||||||
if (!row || !isInviteRow(row)) {
|
if (!row || !isInviteRow(row)) {
|
||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Invite not found");
|
throw new APIException(
|
||||||
|
APIErrorCode.NOT_FOUND,
|
||||||
|
404,
|
||||||
|
"Invalid or expired invite",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.used_at !== null) {
|
if (row.used_at !== null) {
|
||||||
@@ -40,7 +46,17 @@ export async function validateInvite(token: string): Promise<string> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload.inviterId;
|
const expiresAt = new Date(row.created_at).getTime() +
|
||||||
|
UNUSED_INVITES_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
if (Date.now() > expiresAt) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.NOT_FOUND,
|
||||||
|
404,
|
||||||
|
"Invalid or expired invite",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row.inviter_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
2
deno.lock
generated
2
deno.lock
generated
@@ -35,6 +35,7 @@
|
|||||||
"npm:@vitejs/plugin-react-swc@^4.3.1": "4.3.1_vite@8.0.16__@types+node@26.0.0__jiti@2.7.0_@types+node@26.0.0_jiti@2.7.0",
|
"npm:@vitejs/plugin-react-swc@^4.3.1": "4.3.1_vite@8.0.16__@types+node@26.0.0__jiti@2.7.0_@types+node@26.0.0_jiti@2.7.0",
|
||||||
"npm:eslint-plugin-react-hooks@^7.1.1": "7.1.1_eslint@10.5.0__jiti@2.7.0_jiti@2.7.0",
|
"npm:eslint-plugin-react-hooks@^7.1.1": "7.1.1_eslint@10.5.0__jiti@2.7.0_jiti@2.7.0",
|
||||||
"npm:eslint-plugin-react-refresh@~0.5.3": "0.5.3_eslint@10.5.0__jiti@2.7.0_jiti@2.7.0",
|
"npm:eslint-plugin-react-refresh@~0.5.3": "0.5.3_eslint@10.5.0__jiti@2.7.0_jiti@2.7.0",
|
||||||
|
"npm:eslint@*": "10.5.0_jiti@2.7.0",
|
||||||
"npm:eslint@^10.5.0": "10.5.0_jiti@2.7.0",
|
"npm:eslint@^10.5.0": "10.5.0_jiti@2.7.0",
|
||||||
"npm:frimousse@0.3": "0.3.0_react@19.2.7_typescript@6.0.3",
|
"npm:frimousse@0.3": "0.3.0_react@19.2.7_typescript@6.0.3",
|
||||||
"npm:globals@^17.6.0": "17.6.0",
|
"npm:globals@^17.6.0": "17.6.0",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"npm:react@^19.2.7": "19.2.7",
|
"npm:react@^19.2.7": "19.2.7",
|
||||||
"npm:remark-gfm@^4.0.1": "4.0.1",
|
"npm:remark-gfm@^4.0.1": "4.0.1",
|
||||||
"npm:typescript-eslint@^8.61.1": "8.61.1_eslint@10.5.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0",
|
"npm:typescript-eslint@^8.61.1": "8.61.1_eslint@10.5.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0",
|
||||||
|
"npm:typescript@*": "6.0.3",
|
||||||
"npm:typescript@~6.0.3": "6.0.3",
|
"npm:typescript@~6.0.3": "6.0.3",
|
||||||
"npm:vite@*": "8.0.16_@types+node@26.0.0_jiti@2.7.0",
|
"npm:vite@*": "8.0.16_@types+node@26.0.0_jiti@2.7.0",
|
||||||
"npm:vite@^8.0.16": "8.0.16_@types+node@26.0.0_jiti@2.7.0"
|
"npm:vite@^8.0.16": "8.0.16_@types+node@26.0.0_jiti@2.7.0"
|
||||||
|
|||||||
120
src/App.css
120
src/App.css
@@ -207,6 +207,26 @@
|
|||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On the narrowest viewports the fixed-width action column squeezes the title
|
||||||
|
into a sliver. Stack the block: title takes the full width on top, the
|
||||||
|
vote/playlist actions sit in a row beneath it. */
|
||||||
|
@media (max-width: 512px) {
|
||||||
|
.dump-header-block {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-header-left {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-header-right {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dump-comment {
|
.dump-comment {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
line-height: 1.72;
|
line-height: 1.72;
|
||||||
@@ -1051,6 +1071,90 @@
|
|||||||
body.has-player {
|
body.has-player {
|
||||||
padding-bottom: var(--player-height, 0px);
|
padding-bottom: var(--player-height, 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Floating "new dump" button ───────────────────────────────────────
|
||||||
|
Revealed once the header scrolls out of view. Bottom-right by default, but
|
||||||
|
moves to the top-right while the global player is mounted (it owns the
|
||||||
|
bottom of the screen, full-width on mobile). Sits below the player and
|
||||||
|
modal backdrop (both z-index 1000). */
|
||||||
|
.dump-fab {
|
||||||
|
position: fixed;
|
||||||
|
--fab-size: 3.5rem;
|
||||||
|
/* Sit just outside the right edge of the centered content lane — in the
|
||||||
|
margin beside it — rather than over the content or pinned to the viewport
|
||||||
|
edge. The lane width is `--fab-lane` (860px for most feeds; the journal
|
||||||
|
grid is wider, see below). Falls back to a 1.25rem viewport gutter once
|
||||||
|
that margin is too narrow to hold the button. */
|
||||||
|
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) - 1rem));
|
||||||
|
/* Anchored to the bottom-right by default, but expressed via `top` (rather
|
||||||
|
than `bottom`) so the jump to the top-right when a player mounts can
|
||||||
|
animate as a slide. `dvh` tracks the visible viewport on mobile; `vh` is
|
||||||
|
the fallback for browsers without it. */
|
||||||
|
top: calc(100vh - var(--fab-size) - 1.25rem - env(safe-area-inset-bottom, 0px));
|
||||||
|
top: calc(100dvh - var(--fab-size) - 1.25rem - env(safe-area-inset-bottom, 0px));
|
||||||
|
z-index: 900;
|
||||||
|
width: var(--fab-size);
|
||||||
|
height: var(--fab-size);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
/* Circle by default; a theme can square it off by setting --fab-radius: 0. */
|
||||||
|
border-radius: var(--fab-radius, 50%);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 16px color-mix(in srgb, var(--color-accent) 45%, transparent),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
/* Hidden state: faded out, nudged down, non-interactive. */
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(1rem) scale(0.9);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease, background 0.15s ease,
|
||||||
|
top 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-fab-icon {
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-fab--visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-fab:hover {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
transform: translateY(-2px) scale(1);
|
||||||
|
box-shadow: 0 6px 20px color-mix(in srgb, var(--color-accent) 55%, transparent),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-fab:active {
|
||||||
|
transform: translateY(0) scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The journal tab lays out its masonry grid in a wider lane than the other
|
||||||
|
feeds, so the button must track that wider edge to stay aligned with it. */
|
||||||
|
body:has(.journal-grid) {
|
||||||
|
--fab-lane: 1180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The player occupies the bottom of the screen (full-width on mobile), so when
|
||||||
|
one is mounted slide the button up to the top-right corner to stay clear. */
|
||||||
|
body.has-player .dump-fab {
|
||||||
|
top: calc(1.25rem + env(safe-area-inset-top, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reserve room at the end of the scroll so the button never hides the last
|
||||||
|
content when scrolled all the way down. */
|
||||||
|
body.has-fab .page-content {
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.rich-content-thumbnail-btn {
|
.rich-content-thumbnail-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -1674,10 +1778,6 @@ body.has-player {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border-subtle);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.3rem 0.5rem 0.3rem 0.75rem;
|
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
}
|
}
|
||||||
.invite-url {
|
.invite-url {
|
||||||
@@ -1685,11 +1785,18 @@ body.has-player {
|
|||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
min-width: 0;
|
||||||
text-overflow: ellipsis;
|
overflow-x: auto;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
}
|
}
|
||||||
|
/* Always-visible copy target. Sits first so it's the obvious tap. */
|
||||||
.invite-copy-btn {
|
.invite-copy-btn {
|
||||||
|
order: -1;
|
||||||
|
flex-shrink: 0;
|
||||||
padding: 0.2rem 0.65rem;
|
padding: 0.2rem 0.65rem;
|
||||||
border: 1px solid var(--color-border-subtle);
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -1698,7 +1805,6 @@ body.has-player {
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.12s;
|
transition: background 0.12s;
|
||||||
}
|
}
|
||||||
.invite-copy-btn:hover {
|
.invite-copy-btn:hover {
|
||||||
|
|||||||
124
src/App.tsx
124
src/App.tsx
@@ -3,6 +3,8 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router";
|
|||||||
|
|
||||||
import { RestrictedGuest } from "./pages/RestrictedGuest.tsx";
|
import { RestrictedGuest } from "./pages/RestrictedGuest.tsx";
|
||||||
import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx";
|
import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx";
|
||||||
|
import { useAuth } from "./hooks/useAuth.ts";
|
||||||
|
import { useDefaultFeedTab } from "./hooks/useDefaultFeedTab.ts";
|
||||||
|
|
||||||
import { AuthProvider } from "./contexts/AuthProvider.tsx";
|
import { AuthProvider } from "./contexts/AuthProvider.tsx";
|
||||||
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
|
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
|
||||||
@@ -10,6 +12,7 @@ import { WSProvider } from "./contexts/WSProvider.tsx";
|
|||||||
import { FollowProvider } from "./contexts/FollowProvider.tsx";
|
import { FollowProvider } from "./contexts/FollowProvider.tsx";
|
||||||
import { ThemeProvider } from "./contexts/ThemeProvider.tsx";
|
import { ThemeProvider } from "./contexts/ThemeProvider.tsx";
|
||||||
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
||||||
|
import { DumpFab } from "./components/DumpFab.tsx";
|
||||||
|
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
@@ -66,63 +69,74 @@ const NotFound = lazy(() =>
|
|||||||
import("./pages/NotFound.tsx").then((m) => ({ default: m.NotFound }))
|
import("./pages/NotFound.tsx").then((m) => ({ default: m.NotFound }))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function IndexRedirect() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [preferredTab] = useDefaultFeedTab();
|
||||||
|
// "followed" requires a session, so fall back to "hot" for guests.
|
||||||
|
const tab = preferredTab === "followed" && !user ? "hot" : preferredTab;
|
||||||
|
return <Navigate to={`/~/${tab}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<>
|
||||||
<Routes>
|
<Suspense>
|
||||||
<Route path="/" element={<Navigate to="/~/hot" replace />} />
|
<Routes>
|
||||||
<Route path="/~/:feedTab" element={<Index />} />
|
<Route path="/" element={<IndexRedirect />} />
|
||||||
<Route path="/dumps/:selectedDump" element={<Dump />} />
|
<Route path="/~/:feedTab" element={<Index />} />
|
||||||
<Route
|
<Route path="/dumps/:selectedDump" element={<Dump />} />
|
||||||
path="/dumps/:selectedDump/edit"
|
<Route
|
||||||
element={
|
path="/dumps/:selectedDump/edit"
|
||||||
<RestrictedLoggedIn>
|
element={
|
||||||
<DumpEdit />
|
<RestrictedLoggedIn>
|
||||||
</RestrictedLoggedIn>
|
<DumpEdit />
|
||||||
}
|
</RestrictedLoggedIn>
|
||||||
/>
|
}
|
||||||
<Route
|
/>
|
||||||
path="/register"
|
<Route
|
||||||
element={
|
path="/register"
|
||||||
<RestrictedGuest>
|
element={
|
||||||
<UserRegister />
|
<RestrictedGuest>
|
||||||
</RestrictedGuest>
|
<UserRegister />
|
||||||
}
|
</RestrictedGuest>
|
||||||
/>
|
}
|
||||||
<Route
|
/>
|
||||||
path="/login"
|
<Route
|
||||||
element={
|
path="/login"
|
||||||
<RestrictedGuest>
|
element={
|
||||||
<UserLogin />
|
<RestrictedGuest>
|
||||||
</RestrictedGuest>
|
<UserLogin />
|
||||||
}
|
</RestrictedGuest>
|
||||||
/>
|
}
|
||||||
<Route path="/users/:username" element={<UserPublicProfile />} />
|
/>
|
||||||
<Route
|
<Route path="/users/:username" element={<UserPublicProfile />} />
|
||||||
path="/users/:username/~/:profileTab"
|
<Route
|
||||||
element={<UserPublicProfile />}
|
path="/users/:username/~/:profileTab"
|
||||||
/>
|
element={<UserPublicProfile />}
|
||||||
<Route path="/users/:username/dumps" element={<UserDumps />} />
|
/>
|
||||||
<Route path="/users/:username/upvoted" element={<UserUpvoted />} />
|
<Route path="/users/:username/dumps" element={<UserDumps />} />
|
||||||
<Route
|
<Route path="/users/:username/upvoted" element={<UserUpvoted />} />
|
||||||
path="/users/:username/playlists"
|
<Route
|
||||||
element={<UserPlaylists />}
|
path="/users/:username/playlists"
|
||||||
/>
|
element={<UserPlaylists />}
|
||||||
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
|
/>
|
||||||
<Route path="/search" element={<Search />} />
|
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
|
||||||
<Route path="/search/~/:searchTab" element={<Search />} />
|
<Route path="/search" element={<Search />} />
|
||||||
<Route path="/reset-password" element={<ResetPassword />} />
|
<Route path="/search/~/:searchTab" element={<Search />} />
|
||||||
<Route
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
path="/notifications"
|
<Route
|
||||||
element={
|
path="/notifications"
|
||||||
<RestrictedLoggedIn>
|
element={
|
||||||
<Notifications />
|
<RestrictedLoggedIn>
|
||||||
</RestrictedLoggedIn>
|
<Notifications />
|
||||||
}
|
</RestrictedLoggedIn>
|
||||||
/>
|
}
|
||||||
<Route path="*" element={<NotFound />} />
|
/>
|
||||||
</Routes>
|
<Route path="*" element={<NotFound />} />
|
||||||
</Suspense>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
<DumpFab />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
93
src/components/DumpFab.tsx
Normal file
93
src/components/DumpFab.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
|
||||||
|
const DumpCreateModal = lazy(() =>
|
||||||
|
import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal }))
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floating "new dump" button shown once the header has scrolled out of view.
|
||||||
|
* Sits in the bottom-right corner and lifts above the global player (which is
|
||||||
|
* full-width on mobile) so the two never overlap. Only rendered for signed-in
|
||||||
|
* users — dump creation requires an account.
|
||||||
|
*
|
||||||
|
* Mounted once at the app root (not per-page) since some pages render their own
|
||||||
|
* header instead of going through PageShell. Visibility is derived from a live
|
||||||
|
* read of the current page's `.app-header`, so it survives client-side
|
||||||
|
* navigation and lazily-mounted route headers.
|
||||||
|
*/
|
||||||
|
export function DumpFab() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
const [headerHidden, setHeaderHidden] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Reserve clearance at the end of the scroll so the floating button never
|
||||||
|
// hides the last content when scrolled all the way down.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
document.body.classList.add("has-fab");
|
||||||
|
return () => document.body.classList.remove("has-fab");
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Reveal the button once the header's bottom edge scrolls above the viewport.
|
||||||
|
// Re-reading the header on every tick keeps this correct across navigation
|
||||||
|
// and lazily-mounted pages, where the header element is replaced.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const update = () => {
|
||||||
|
const header = document.querySelector(".app-header");
|
||||||
|
setHeaderHidden(!!header && header.getBoundingClientRect().bottom <= 0);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
globalThis.addEventListener("scroll", update, { passive: true });
|
||||||
|
globalThis.addEventListener("resize", update);
|
||||||
|
// Body height changes when a lazy route finishes mounting — re-check then,
|
||||||
|
// since no scroll/resize event fires in that case.
|
||||||
|
const ro = new ResizeObserver(update);
|
||||||
|
ro.observe(document.body);
|
||||||
|
return () => {
|
||||||
|
globalThis.removeEventListener("scroll", update);
|
||||||
|
globalThis.removeEventListener("resize", update);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, [user, location.pathname]);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dump-fab${headerHidden ? " dump-fab--visible" : ""}`}
|
||||||
|
aria-label={t`New dump`}
|
||||||
|
title={t`New dump`}
|
||||||
|
aria-hidden={!headerHidden}
|
||||||
|
tabIndex={headerHidden ? 0 : -1}
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
>
|
||||||
|
{/* Symmetric SVG plus — centers exactly via flex, regardless of the
|
||||||
|
theme's font metrics (a text "+" sits slightly off the box center). */}
|
||||||
|
<svg
|
||||||
|
className="dump-fab-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{modalOpen && (
|
||||||
|
<Suspense>
|
||||||
|
<DumpCreateModal onClose={() => setModalOpen(false)} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,11 +2,16 @@ import { Trans } from "@lingui/react/macro";
|
|||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
|
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
|
||||||
import { useTabParam } from "../hooks/useTabParam.ts";
|
import { useTabParam } from "../hooks/useTabParam.ts";
|
||||||
|
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
|
||||||
import { TabBar } from "./TabBar.tsx";
|
import { TabBar } from "./TabBar.tsx";
|
||||||
|
|
||||||
export function FeedTabBar() {
|
export function FeedTabBar() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [tab, setTab] = useTabParam<FeedTab>(FEED_TABS, "hot");
|
const [preferredTab] = useDefaultFeedTab();
|
||||||
|
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
||||||
|
? "hot"
|
||||||
|
: preferredTab;
|
||||||
|
const [tab, setTab] = useTabParam<FeedTab>(FEED_TABS, defaultTab);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: "hot" as FeedTab, label: <Trans>Hot</Trans> },
|
{ key: "hot" as FeedTab, label: <Trans>Hot</Trans> },
|
||||||
|
|||||||
@@ -10,8 +10,16 @@ function itemKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GlobalPlayer() {
|
export function GlobalPlayer() {
|
||||||
const { current, stop, seekRef, toggleRef, onPlayStateChange, onTimeUpdate } =
|
const {
|
||||||
useContext(PlayerContext);
|
current,
|
||||||
|
startTime,
|
||||||
|
autoplay,
|
||||||
|
stop,
|
||||||
|
seekRef,
|
||||||
|
toggleRef,
|
||||||
|
onPlayStateChange,
|
||||||
|
onTimeUpdate,
|
||||||
|
} = useContext(PlayerContext);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [reduced, setReduced] = useState(false);
|
const [reduced, setReduced] = useState(false);
|
||||||
const [prevKey, setPrevKey] = useState(itemKey(current));
|
const [prevKey, setPrevKey] = useState(itemKey(current));
|
||||||
@@ -98,7 +106,8 @@ export function GlobalPlayer() {
|
|||||||
src={current.fileUrl}
|
src={current.fileUrl}
|
||||||
kind={current.mimeType.startsWith("video/") ? "video" : "audio"}
|
kind={current.mimeType.startsWith("video/") ? "video" : "audio"}
|
||||||
mime={current.mimeType}
|
mime={current.mimeType}
|
||||||
autoplay
|
autoplay={autoplay}
|
||||||
|
startTime={startTime}
|
||||||
onPlayStateChange={onPlayStateChange}
|
onPlayStateChange={onPlayStateChange}
|
||||||
onTimeUpdate={onTimeUpdate}
|
onTimeUpdate={onTimeUpdate}
|
||||||
seekRef={seekRef}
|
seekRef={seekRef}
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ interface MediaPlayerProps {
|
|||||||
mime?: string;
|
mime?: string;
|
||||||
poster?: string;
|
poster?: string;
|
||||||
autoplay?: boolean;
|
autoplay?: boolean;
|
||||||
|
startTime?: number;
|
||||||
onPlayStateChange?: (playing: boolean) => void;
|
onPlayStateChange?: (playing: boolean) => void;
|
||||||
onTimeUpdate?: (time: number, duration: number) => void;
|
onTimeUpdate?: (time: number, duration: number) => void;
|
||||||
seekRef?: { current: ((t: number) => void) | null };
|
seekRef?: { current: ((t: number) => void) | null };
|
||||||
@@ -142,6 +143,7 @@ export function MediaPlayer(
|
|||||||
mime,
|
mime,
|
||||||
poster,
|
poster,
|
||||||
autoplay,
|
autoplay,
|
||||||
|
startTime,
|
||||||
onPlayStateChange,
|
onPlayStateChange,
|
||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
seekRef,
|
seekRef,
|
||||||
@@ -209,6 +211,21 @@ export function MediaPlayer(
|
|||||||
|
|
||||||
// ── Effects ──────────────────────────────────────────────────────────────────
|
// ── Effects ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Resume to a restored offset once metadata (and thus seekability) is available.
|
||||||
|
// Runs only on mount for the initial offset — later seeks go through seekTo.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!startTime) return;
|
||||||
|
const a = mediaRef.current!;
|
||||||
|
const apply = () => {
|
||||||
|
a.currentTime = startTime;
|
||||||
|
setCurrent(startTime);
|
||||||
|
};
|
||||||
|
if (a.readyState >= 1) apply();
|
||||||
|
else a.addEventListener("loadedmetadata", apply, { once: true });
|
||||||
|
return () => a.removeEventListener("loadedmetadata", apply);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Autoplay on mount (e.g. triggered by play() in PlayerContext)
|
// Autoplay on mount (e.g. triggered by play() in PlayerContext)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoplay) return;
|
if (!autoplay) return;
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export interface PlayerContextValue {
|
|||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
|
||||||
|
// Initial seek offset for the active item (non-zero only for an item restored
|
||||||
|
// from a previous session) and whether the active MediaPlayer should autoplay.
|
||||||
|
// A restored item appears paused; a fresh play() autoplays on the user gesture.
|
||||||
|
startTime: number;
|
||||||
|
autoplay: boolean;
|
||||||
|
|
||||||
// Control — callable by any consumer
|
// Control — callable by any consumer
|
||||||
play(item: PlayerItem): void;
|
play(item: PlayerItem): void;
|
||||||
stop(): void;
|
stop(): void;
|
||||||
@@ -32,6 +38,8 @@ export const PlayerContext = createContext<PlayerContextValue>({
|
|||||||
playing: false,
|
playing: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
|
startTime: 0,
|
||||||
|
autoplay: false,
|
||||||
play: () => {},
|
play: () => {},
|
||||||
stop: () => {},
|
stop: () => {},
|
||||||
seekTo: () => {},
|
seekTo: () => {},
|
||||||
|
|||||||
@@ -1,11 +1,39 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts";
|
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "player";
|
||||||
|
|
||||||
|
// Snapshot persisted across reloads: which item was playing and how far in.
|
||||||
|
interface StoredSession {
|
||||||
|
item: PlayerItem;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSession(): StoredSession | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<StoredSession>;
|
||||||
|
if (!parsed.item || (parsed.item.kind !== "embed" && parsed.item.kind !== "file")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { item: parsed.item as PlayerItem, time: Number(parsed.time) || 0 };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [current, setCurrent] = useState<PlayerItem | null>(null);
|
const restored = useRef(readSession()).current;
|
||||||
|
|
||||||
|
const [current, setCurrent] = useState<PlayerItem | null>(restored?.item ?? null);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(restored?.time ?? 0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
|
// Resume offset for the active item — only the restored item carries one.
|
||||||
|
const [startTime, setStartTime] = useState(restored?.time ?? 0);
|
||||||
|
// Restored items start paused (no user gesture); fresh play() autoplays.
|
||||||
|
const [autoplay, setAutoplay] = useState(false);
|
||||||
|
|
||||||
// GlobalPlayer registers the active MediaPlayer's imperative handles here
|
// GlobalPlayer registers the active MediaPlayer's imperative handles here
|
||||||
const seekRef = useRef<((t: number) => void) | null>(null);
|
const seekRef = useRef<((t: number) => void) | null>(null);
|
||||||
@@ -20,6 +48,8 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setCurrent(item);
|
setCurrent(item);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
|
setStartTime(0);
|
||||||
|
setAutoplay(true);
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -28,6 +58,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
|
setStartTime(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const seekTo = useCallback((t: number) => {
|
const seekTo = useCallback((t: number) => {
|
||||||
@@ -50,11 +81,45 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setDuration(d);
|
setDuration(d);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── Persistence ────────────────────────────────────────────────────────────────
|
||||||
|
// Write the item to storage as soon as it changes (survives crashes), then refresh
|
||||||
|
// the playback position on pagehide — which also fires on a normal reload — so the
|
||||||
|
// resume offset is accurate without thrashing localStorage on every timeupdate.
|
||||||
|
useEffect(() => {
|
||||||
|
if (current) {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({ item: current, time: currentTime } satisfies StoredSession),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
// currentTime intentionally omitted from deps — see pagehide writer below.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [current]);
|
||||||
|
|
||||||
|
const sessionRef = useRef<StoredSession | null>(null);
|
||||||
|
sessionRef.current = current ? { item: current, time: currentTime } : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const persist = () => {
|
||||||
|
if (sessionRef.current) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessionRef.current));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
globalThis.addEventListener("pagehide", persist);
|
||||||
|
return () => globalThis.removeEventListener("pagehide", persist);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(() => ({
|
const value = useMemo(() => ({
|
||||||
current,
|
current,
|
||||||
playing,
|
playing,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
|
startTime,
|
||||||
|
autoplay,
|
||||||
play,
|
play,
|
||||||
stop,
|
stop,
|
||||||
seekTo,
|
seekTo,
|
||||||
@@ -68,6 +133,8 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
playing,
|
playing,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
|
startTime,
|
||||||
|
autoplay,
|
||||||
play,
|
play,
|
||||||
stop,
|
stop,
|
||||||
seekTo,
|
seekTo,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext } from "react";
|
import { useCallback, useContext } from "react";
|
||||||
|
|
||||||
import { AuthContext } from "../contexts/AuthContext.ts";
|
import { AuthContext } from "../contexts/AuthContext.ts";
|
||||||
|
|
||||||
@@ -16,17 +16,20 @@ function isTokenExpired(token: string): boolean {
|
|||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const { authResponse, setAuthResponse } = useContext(AuthContext);
|
const { authResponse, setAuthResponse } = useContext(AuthContext);
|
||||||
|
|
||||||
const login = (response: AuthResponse) => {
|
const login = useCallback((response: AuthResponse) => {
|
||||||
setAuthResponse(response);
|
setAuthResponse(response);
|
||||||
localStorage.setItem("authResponse", JSON.stringify(response));
|
localStorage.setItem("authResponse", JSON.stringify(response));
|
||||||
};
|
}, [setAuthResponse]);
|
||||||
|
|
||||||
const logout = () => {
|
const logout = useCallback(() => {
|
||||||
setAuthResponse(null);
|
setAuthResponse(null);
|
||||||
localStorage.removeItem("authResponse");
|
localStorage.removeItem("authResponse");
|
||||||
};
|
}, [setAuthResponse]);
|
||||||
|
|
||||||
const authFetch = async (input: RequestInfo, init: RequestInit = {}) => {
|
const authFetch = useCallback(async (
|
||||||
|
input: RequestInfo,
|
||||||
|
init: RequestInit = {},
|
||||||
|
) => {
|
||||||
const token = authResponse?.token;
|
const token = authResponse?.token;
|
||||||
|
|
||||||
if (token && isTokenExpired(token)) {
|
if (token && isTokenExpired(token)) {
|
||||||
@@ -54,7 +57,7 @@ export const useAuth = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
};
|
}, [authResponse?.token, logout]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: authResponse?.user ?? null,
|
user: authResponse?.user ?? null,
|
||||||
|
|||||||
28
src/hooks/useDefaultFeedTab.ts
Normal file
28
src/hooks/useDefaultFeedTab.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "default-feed-tab";
|
||||||
|
|
||||||
|
function readStored(): FeedTab {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored && (FEED_TABS as readonly string[]).includes(stored)
|
||||||
|
? stored as FeedTab
|
||||||
|
: "hot";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User preference for which feed tab the index opens on, persisted to
|
||||||
|
* localStorage (defaults to "hot"). Returns the stored tab and a setter that
|
||||||
|
* persists the choice. Consumers that only need to honour the preference (e.g.
|
||||||
|
* the index default) can ignore the setter.
|
||||||
|
*/
|
||||||
|
export function useDefaultFeedTab(): [FeedTab, (tab: FeedTab) => void] {
|
||||||
|
const [tab, setTabState] = useState<FeedTab>(readStored);
|
||||||
|
|
||||||
|
const setTab = useCallback((t: FeedTab) => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, t);
|
||||||
|
setTabState(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [tab, setTab];
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -43,7 +43,7 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
|||||||
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:560
|
#: src/pages/PlaylistDetail.tsx:560
|
||||||
#: src/pages/UserPublicProfile.tsx:717
|
#: src/pages/UserPublicProfile.tsx:719
|
||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
msgstr "← Back"
|
msgstr "← Back"
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ msgstr "← Back to all dumps"
|
|||||||
msgid "← Back to profile"
|
msgid "← Back to profile"
|
||||||
msgstr "← Back to profile"
|
msgstr "← Back to profile"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:110
|
#: src/pages/UserPublicProfile.tsx:111
|
||||||
msgid "+ Invite someone"
|
msgid "+ Invite someone"
|
||||||
msgstr "+ Invite someone"
|
msgstr "+ Invite someone"
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ msgstr "a comment"
|
|||||||
msgid "a post"
|
msgid "a post"
|
||||||
msgstr "a post"
|
msgstr "a post"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1093
|
#: src/pages/UserPublicProfile.tsx:1095
|
||||||
msgid "Account"
|
msgid "Account"
|
||||||
msgstr "Account"
|
msgstr "Account"
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ msgstr "Account"
|
|||||||
msgid "Add a comment…"
|
msgid "Add a comment…"
|
||||||
msgstr "Add a comment…"
|
msgstr "Add a comment…"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:792
|
#: src/pages/UserPublicProfile.tsx:794
|
||||||
msgid "Add email…"
|
msgid "Add email…"
|
||||||
msgstr "Add email…"
|
msgstr "Add email…"
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ msgstr "Add to playlist"
|
|||||||
msgid "Already have an account? <0>Log in</0>"
|
msgid "Already have an account? <0>Log in</0>"
|
||||||
msgstr "Already have an account? <0>Log in</0>"
|
msgstr "Already have an account? <0>Log in</0>"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1112
|
#: src/pages/UserPublicProfile.tsx:1114
|
||||||
msgid "Appearance"
|
msgid "Appearance"
|
||||||
msgstr "Appearance"
|
msgstr "Appearance"
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ msgstr "Appearance"
|
|||||||
msgid "At least {0} characters"
|
msgid "At least {0} characters"
|
||||||
msgstr "At least {0} characters"
|
msgstr "At least {0} characters"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1160
|
#: src/pages/UserPublicProfile.tsx:1162
|
||||||
msgid "Auto"
|
msgid "Auto"
|
||||||
msgstr "Auto"
|
msgstr "Auto"
|
||||||
|
|
||||||
@@ -177,8 +177,8 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
|
|||||||
#: src/pages/Dump.tsx:357
|
#: src/pages/Dump.tsx:357
|
||||||
#: src/pages/DumpEdit.tsx:433
|
#: src/pages/DumpEdit.tsx:433
|
||||||
#: src/pages/PlaylistDetail.tsx:908
|
#: src/pages/PlaylistDetail.tsx:908
|
||||||
#: src/pages/UserPublicProfile.tsx:1496
|
#: src/pages/UserPublicProfile.tsx:1540
|
||||||
#: src/pages/UserPublicProfile.tsx:1566
|
#: src/pages/UserPublicProfile.tsx:1610
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Cancel"
|
msgstr "Cancel"
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ msgstr "Cancel removal"
|
|||||||
msgid "Cancel search"
|
msgid "Cancel search"
|
||||||
msgstr "Cancel search"
|
msgstr "Cancel search"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:744
|
#: src/pages/UserPublicProfile.tsx:746
|
||||||
msgid "Change avatar"
|
msgid "Change avatar"
|
||||||
msgstr "Change avatar"
|
msgstr "Change avatar"
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ msgstr "Change avatar"
|
|||||||
msgid "Change password"
|
msgid "Change password"
|
||||||
msgstr "Change password"
|
msgstr "Change password"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1105
|
#: src/pages/UserPublicProfile.tsx:1107
|
||||||
msgid "Change password…"
|
msgid "Change password…"
|
||||||
msgstr "Change password…"
|
msgstr "Change password…"
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ msgstr "Checking invite…"
|
|||||||
msgid "Close"
|
msgid "Close"
|
||||||
msgstr "Close"
|
msgstr "Close"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1152
|
#: src/pages/UserPublicProfile.tsx:1154
|
||||||
msgid "Color scheme"
|
msgid "Color scheme"
|
||||||
msgstr "Color scheme"
|
msgstr "Color scheme"
|
||||||
|
|
||||||
@@ -221,11 +221,11 @@ msgstr "Color scheme"
|
|||||||
msgid "Confirm new password"
|
msgid "Confirm new password"
|
||||||
msgstr "Confirm new password"
|
msgstr "Confirm new password"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:101
|
#: src/pages/UserPublicProfile.tsx:102
|
||||||
msgid "Copied!"
|
msgid "Copied!"
|
||||||
msgstr "Copied!"
|
msgstr "Copied!"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:101
|
#: src/pages/UserPublicProfile.tsx:102
|
||||||
msgid "Copy"
|
msgid "Copy"
|
||||||
msgstr "Copy"
|
msgstr "Copy"
|
||||||
|
|
||||||
@@ -263,10 +263,14 @@ msgstr "Creating…"
|
|||||||
msgid "Current password"
|
msgid "Current password"
|
||||||
msgstr "Current password"
|
msgstr "Current password"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1174
|
#: src/pages/UserPublicProfile.tsx:1176
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
msgstr "Dark"
|
msgstr "Dark"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1189
|
||||||
|
msgid "Default tab"
|
||||||
|
msgstr "Default tab"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:374
|
#: src/components/CommentThread.tsx:374
|
||||||
#: src/components/CommentThread.tsx:380
|
#: src/components/CommentThread.tsx:380
|
||||||
#: src/components/ConfirmModal.tsx:16
|
#: src/components/ConfirmModal.tsx:16
|
||||||
@@ -329,13 +333,13 @@ msgstr "Dumped!"
|
|||||||
|
|
||||||
#: src/pages/Search.tsx:172
|
#: src/pages/Search.tsx:172
|
||||||
#: src/pages/UserDumps.tsx:107
|
#: src/pages/UserDumps.tsx:107
|
||||||
#: src/pages/UserPublicProfile.tsx:865
|
#: src/pages/UserPublicProfile.tsx:867
|
||||||
msgid "Dumps"
|
msgid "Dumps"
|
||||||
msgstr "Dumps"
|
msgstr "Dumps"
|
||||||
|
|
||||||
#. placeholder {0}: dumps.items.length
|
#. placeholder {0}: dumps.items.length
|
||||||
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
||||||
#: src/pages/UserPublicProfile.tsx:882
|
#: src/pages/UserPublicProfile.tsx:884
|
||||||
msgid "Dumps ({0}{1})"
|
msgid "Dumps ({0}{1})"
|
||||||
msgstr "Dumps ({0}{1})"
|
msgstr "Dumps ({0}{1})"
|
||||||
|
|
||||||
@@ -387,9 +391,9 @@ msgstr "Enter a query to search."
|
|||||||
msgid "Failed to create playlist"
|
msgid "Failed to create playlist"
|
||||||
msgstr "Failed to create playlist"
|
msgstr "Failed to create playlist"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:82
|
#: src/pages/UserPublicProfile.tsx:83
|
||||||
#: src/pages/UserPublicProfile.tsx:85
|
#: src/pages/UserPublicProfile.tsx:86
|
||||||
#: src/pages/UserPublicProfile.tsx:113
|
#: src/pages/UserPublicProfile.tsx:114
|
||||||
msgid "Failed to generate invite"
|
msgid "Failed to generate invite"
|
||||||
msgstr "Failed to generate invite"
|
msgstr "Failed to generate invite"
|
||||||
|
|
||||||
@@ -398,9 +402,9 @@ msgstr "Failed to generate invite"
|
|||||||
#: src/pages/index/JournalFeed.tsx:33
|
#: src/pages/index/JournalFeed.tsx:33
|
||||||
#: src/pages/index/NewFeed.tsx:36
|
#: src/pages/index/NewFeed.tsx:36
|
||||||
#: src/pages/Notifications.tsx:367
|
#: src/pages/Notifications.tsx:367
|
||||||
#: src/pages/UserPublicProfile.tsx:984
|
#: src/pages/UserPublicProfile.tsx:986
|
||||||
#: src/pages/UserPublicProfile.tsx:1026
|
#: src/pages/UserPublicProfile.tsx:1028
|
||||||
#: src/pages/UserPublicProfile.tsx:1071
|
#: src/pages/UserPublicProfile.tsx:1073
|
||||||
msgid "Failed to load"
|
msgid "Failed to load"
|
||||||
msgstr "Failed to load"
|
msgstr "Failed to load"
|
||||||
|
|
||||||
@@ -417,8 +421,8 @@ msgid "Failed to post reply"
|
|||||||
msgstr "Failed to post reply"
|
msgstr "Failed to post reply"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:896
|
#: src/pages/PlaylistDetail.tsx:896
|
||||||
#: src/pages/UserPublicProfile.tsx:1499
|
#: src/pages/UserPublicProfile.tsx:1543
|
||||||
#: src/pages/UserPublicProfile.tsx:1568
|
#: src/pages/UserPublicProfile.tsx:1612
|
||||||
msgid "Failed to save"
|
msgid "Failed to save"
|
||||||
msgstr "Failed to save"
|
msgstr "Failed to save"
|
||||||
|
|
||||||
@@ -426,10 +430,14 @@ msgstr "Failed to save"
|
|||||||
msgid "Failed to save edit"
|
msgid "Failed to save edit"
|
||||||
msgstr "Failed to save edit"
|
msgstr "Failed to save edit"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:801
|
#: src/pages/UserPublicProfile.tsx:803
|
||||||
msgid "Failed to update avatar"
|
msgid "Failed to update avatar"
|
||||||
msgstr "Failed to update avatar"
|
msgstr "Failed to update avatar"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1184
|
||||||
|
msgid "Feeds"
|
||||||
|
msgstr "Feeds"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:360
|
#: src/components/DumpCreateModal.tsx:360
|
||||||
msgid "Fetching preview…"
|
msgid "Fetching preview…"
|
||||||
msgstr "Fetching preview…"
|
msgstr "Fetching preview…"
|
||||||
@@ -468,8 +476,9 @@ msgstr "Follow some public playlists to see their dumps here."
|
|||||||
msgid "Follow some users to see their dumps here."
|
msgid "Follow some users to see their dumps here."
|
||||||
msgstr "Follow some users to see their dumps here."
|
msgstr "Follow some users to see their dumps here."
|
||||||
|
|
||||||
#: src/components/FeedTabBar.tsx:16
|
#: src/components/FeedTabBar.tsx:21
|
||||||
#: src/pages/UserPublicProfile.tsx:867
|
#: src/pages/UserPublicProfile.tsx:869
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1218
|
||||||
msgid "Followed"
|
msgid "Followed"
|
||||||
msgstr "Followed"
|
msgstr "Followed"
|
||||||
|
|
||||||
@@ -479,13 +488,13 @@ msgstr "Followed"
|
|||||||
msgid "Followed ({0}{1})"
|
msgid "Followed ({0}{1})"
|
||||||
msgstr "Followed ({0}{1})"
|
msgstr "Followed ({0}{1})"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1015
|
#: src/pages/UserPublicProfile.tsx:1017
|
||||||
msgid "Followed playlists"
|
msgid "Followed playlists"
|
||||||
msgstr "Followed playlists"
|
msgstr "Followed playlists"
|
||||||
|
|
||||||
#: src/components/FollowButton.tsx:37
|
#: src/components/FollowButton.tsx:37
|
||||||
#: src/components/FollowButton.tsx:64
|
#: src/components/FollowButton.tsx:64
|
||||||
#: src/pages/UserPublicProfile.tsx:973
|
#: src/pages/UserPublicProfile.tsx:975
|
||||||
msgid "Following"
|
msgid "Following"
|
||||||
msgstr "Following"
|
msgstr "Following"
|
||||||
|
|
||||||
@@ -509,7 +518,8 @@ msgstr "Go home"
|
|||||||
msgid "Go to login"
|
msgid "Go to login"
|
||||||
msgstr "Go to login"
|
msgstr "Go to login"
|
||||||
|
|
||||||
#: src/components/FeedTabBar.tsx:12
|
#: src/components/FeedTabBar.tsx:17
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1197
|
||||||
msgid "Hot"
|
msgid "Hot"
|
||||||
msgstr "Hot"
|
msgstr "Hot"
|
||||||
|
|
||||||
@@ -525,20 +535,21 @@ msgstr "Invalid invite"
|
|||||||
msgid "Invalid link"
|
msgid "Invalid link"
|
||||||
msgstr "Invalid link"
|
msgstr "Invalid link"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:763
|
#: src/pages/UserPublicProfile.tsx:765
|
||||||
msgid "invited by"
|
msgid "invited by"
|
||||||
msgstr "invited by"
|
msgstr "invited by"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:868
|
#: src/pages/UserPublicProfile.tsx:870
|
||||||
#: src/pages/UserPublicProfile.tsx:1060
|
#: src/pages/UserPublicProfile.tsx:1062
|
||||||
msgid "Invitees"
|
msgid "Invitees"
|
||||||
msgstr "Invitees"
|
msgstr "Invitees"
|
||||||
|
|
||||||
#: src/components/FeedTabBar.tsx:14
|
#: src/components/FeedTabBar.tsx:19
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1211
|
||||||
msgid "Journal"
|
msgid "Journal"
|
||||||
msgstr "Journal"
|
msgstr "Journal"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1167
|
#: src/pages/UserPublicProfile.tsx:1169
|
||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Light"
|
msgstr "Light"
|
||||||
|
|
||||||
@@ -580,7 +591,7 @@ msgstr "Loading more…"
|
|||||||
msgid "Loading playlist…"
|
msgid "Loading playlist…"
|
||||||
msgstr "Loading playlist…"
|
msgstr "Loading playlist…"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:700
|
#: src/pages/UserPublicProfile.tsx:702
|
||||||
msgid "Loading profile…"
|
msgid "Loading profile…"
|
||||||
msgstr "Loading profile…"
|
msgstr "Loading profile…"
|
||||||
|
|
||||||
@@ -596,9 +607,9 @@ msgstr "Loading profile…"
|
|||||||
#: src/pages/Notifications.tsx:439
|
#: src/pages/Notifications.tsx:439
|
||||||
#: src/pages/UserDumps.tsx:51
|
#: src/pages/UserDumps.tsx:51
|
||||||
#: src/pages/UserPlaylists.tsx:342
|
#: src/pages/UserPlaylists.tsx:342
|
||||||
#: src/pages/UserPublicProfile.tsx:978
|
#: src/pages/UserPublicProfile.tsx:980
|
||||||
#: src/pages/UserPublicProfile.tsx:1020
|
#: src/pages/UserPublicProfile.tsx:1022
|
||||||
#: src/pages/UserPublicProfile.tsx:1065
|
#: src/pages/UserPublicProfile.tsx:1067
|
||||||
#: src/pages/UserUpvoted.tsx:122
|
#: src/pages/UserUpvoted.tsx:122
|
||||||
msgid "Loading…"
|
msgid "Loading…"
|
||||||
msgstr "Loading…"
|
msgstr "Loading…"
|
||||||
@@ -617,8 +628,8 @@ msgstr "Log in to like"
|
|||||||
msgid "Log in to vote"
|
msgid "Log in to vote"
|
||||||
msgstr "Log in to vote"
|
msgstr "Log in to vote"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:721
|
#: src/pages/UserPublicProfile.tsx:723
|
||||||
#: src/pages/UserPublicProfile.tsx:815
|
#: src/pages/UserPublicProfile.tsx:817
|
||||||
msgid "Log out"
|
msgid "Log out"
|
||||||
msgstr "Log out"
|
msgstr "Log out"
|
||||||
|
|
||||||
@@ -638,13 +649,16 @@ msgstr "Max 50 MB"
|
|||||||
msgid "new"
|
msgid "new"
|
||||||
msgstr "new"
|
msgstr "new"
|
||||||
|
|
||||||
#: src/components/FeedTabBar.tsx:13
|
#: src/components/FeedTabBar.tsx:18
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1204
|
||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "New"
|
msgstr "New"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:292
|
#: src/components/DumpCreateModal.tsx:292
|
||||||
|
#: src/components/DumpFab.tsx:65
|
||||||
|
#: src/components/DumpFab.tsx:66
|
||||||
#: src/pages/UserDumps.tsx:115
|
#: src/pages/UserDumps.tsx:115
|
||||||
#: src/pages/UserPublicProfile.tsx:1223
|
#: src/pages/UserPublicProfile.tsx:1267
|
||||||
msgid "New dump"
|
msgid "New dump"
|
||||||
msgstr "New dump"
|
msgstr "New dump"
|
||||||
|
|
||||||
@@ -677,11 +691,11 @@ msgid "No emoji found."
|
|||||||
msgstr "No emoji found."
|
msgstr "No emoji found."
|
||||||
|
|
||||||
#: src/pages/UserPlaylists.tsx:439
|
#: src/pages/UserPlaylists.tsx:439
|
||||||
#: src/pages/UserPublicProfile.tsx:1033
|
#: src/pages/UserPublicProfile.tsx:1035
|
||||||
msgid "No followed playlists yet."
|
msgid "No followed playlists yet."
|
||||||
msgstr "No followed playlists yet."
|
msgstr "No followed playlists yet."
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1078
|
#: src/pages/UserPublicProfile.tsx:1080
|
||||||
msgid "No invitees yet."
|
msgid "No invitees yet."
|
||||||
msgstr "No invitees yet."
|
msgstr "No invitees yet."
|
||||||
|
|
||||||
@@ -695,7 +709,7 @@ msgstr "No playlists match \"{q}\"."
|
|||||||
|
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:34
|
#: src/components/PlaylistMembershipPanel.tsx:34
|
||||||
#: src/pages/UserPlaylists.tsx:397
|
#: src/pages/UserPlaylists.tsx:397
|
||||||
#: src/pages/UserPublicProfile.tsx:944
|
#: src/pages/UserPublicProfile.tsx:946
|
||||||
msgid "No playlists yet."
|
msgid "No playlists yet."
|
||||||
msgstr "No playlists yet."
|
msgstr "No playlists yet."
|
||||||
|
|
||||||
@@ -703,14 +717,14 @@ msgstr "No playlists yet."
|
|||||||
msgid "No users match \"{q}\"."
|
msgid "No users match \"{q}\"."
|
||||||
msgstr "No users match \"{q}\"."
|
msgstr "No users match \"{q}\"."
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:991
|
#: src/pages/UserPublicProfile.tsx:993
|
||||||
msgid "Not following anyone yet."
|
msgid "Not following anyone yet."
|
||||||
msgstr "Not following anyone yet."
|
msgstr "Not following anyone yet."
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:374
|
#: src/pages/Notifications.tsx:374
|
||||||
#: src/pages/UserDumps.tsx:125
|
#: src/pages/UserDumps.tsx:125
|
||||||
#: src/pages/UserPublicProfile.tsx:1234
|
#: src/pages/UserPublicProfile.tsx:1278
|
||||||
#: src/pages/UserPublicProfile.tsx:1356
|
#: src/pages/UserPublicProfile.tsx:1400
|
||||||
#: src/pages/UserUpvoted.tsx:194
|
#: src/pages/UserUpvoted.tsx:194
|
||||||
msgid "Nothing here yet."
|
msgid "Nothing here yet."
|
||||||
msgstr "Nothing here yet."
|
msgstr "Nothing here yet."
|
||||||
@@ -733,7 +747,7 @@ msgid "or <0>browse files</0>"
|
|||||||
msgstr "or <0>browse files</0>"
|
msgstr "or <0>browse files</0>"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:72
|
#: src/pages/UserLogin.tsx:72
|
||||||
#: src/pages/UserPublicProfile.tsx:1098
|
#: src/pages/UserPublicProfile.tsx:1100
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Password"
|
msgstr "Password"
|
||||||
|
|
||||||
@@ -763,13 +777,13 @@ msgstr "Playlist title"
|
|||||||
#: src/components/UserMenu.tsx:62
|
#: src/components/UserMenu.tsx:62
|
||||||
#: src/pages/Search.tsx:175
|
#: src/pages/Search.tsx:175
|
||||||
#: src/pages/UserPlaylists.tsx:368
|
#: src/pages/UserPlaylists.tsx:368
|
||||||
#: src/pages/UserPublicProfile.tsx:866
|
#: src/pages/UserPublicProfile.tsx:868
|
||||||
msgid "Playlists"
|
msgid "Playlists"
|
||||||
msgstr "Playlists"
|
msgstr "Playlists"
|
||||||
|
|
||||||
#. placeholder {0}: playlists.items.length
|
#. placeholder {0}: playlists.items.length
|
||||||
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
||||||
#: src/pages/UserPublicProfile.tsx:913
|
#: src/pages/UserPublicProfile.tsx:915
|
||||||
msgid "Playlists ({0}{1})"
|
msgid "Playlists ({0}{1})"
|
||||||
msgstr "Playlists ({0}{1})"
|
msgstr "Playlists ({0}{1})"
|
||||||
|
|
||||||
@@ -885,8 +899,8 @@ msgstr "Retry"
|
|||||||
#: src/pages/Dump.tsx:349
|
#: src/pages/Dump.tsx:349
|
||||||
#: src/pages/DumpEdit.tsx:436
|
#: src/pages/DumpEdit.tsx:436
|
||||||
#: src/pages/PlaylistDetail.tsx:915
|
#: src/pages/PlaylistDetail.tsx:915
|
||||||
#: src/pages/UserPublicProfile.tsx:1488
|
#: src/pages/UserPublicProfile.tsx:1532
|
||||||
#: src/pages/UserPublicProfile.tsx:1558
|
#: src/pages/UserPublicProfile.tsx:1602
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Save"
|
msgstr "Save"
|
||||||
|
|
||||||
@@ -895,8 +909,8 @@ msgstr "Save"
|
|||||||
#: src/pages/Dump.tsx:348
|
#: src/pages/Dump.tsx:348
|
||||||
#: src/pages/PlaylistDetail.tsx:911
|
#: src/pages/PlaylistDetail.tsx:911
|
||||||
#: src/pages/ResetPassword.tsx:124
|
#: src/pages/ResetPassword.tsx:124
|
||||||
#: src/pages/UserPublicProfile.tsx:1485
|
#: src/pages/UserPublicProfile.tsx:1529
|
||||||
#: src/pages/UserPublicProfile.tsx:1555
|
#: src/pages/UserPublicProfile.tsx:1599
|
||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Saving…"
|
msgstr "Saving…"
|
||||||
|
|
||||||
@@ -934,7 +948,7 @@ msgstr "Server unreachable"
|
|||||||
msgid "Set new password"
|
msgid "Set new password"
|
||||||
msgstr "Set new password"
|
msgstr "Set new password"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:870
|
#: src/pages/UserPublicProfile.tsx:872
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr "Settings"
|
msgstr "Settings"
|
||||||
|
|
||||||
@@ -943,7 +957,7 @@ msgstr "Settings"
|
|||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Something went wrong"
|
msgstr "Something went wrong"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1117
|
#: src/pages/UserPublicProfile.tsx:1119
|
||||||
msgid "Style"
|
msgid "Style"
|
||||||
msgstr "Style"
|
msgstr "Style"
|
||||||
|
|
||||||
@@ -997,7 +1011,7 @@ msgstr "Unfollow {targetUsername}"
|
|||||||
msgid "Unfollow playlist"
|
msgid "Unfollow playlist"
|
||||||
msgstr "Unfollow playlist"
|
msgstr "Unfollow playlist"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:671
|
#: src/pages/UserPublicProfile.tsx:673
|
||||||
msgid "Upload failed"
|
msgid "Upload failed"
|
||||||
msgstr "Upload failed"
|
msgstr "Upload failed"
|
||||||
|
|
||||||
@@ -1015,7 +1029,7 @@ msgstr "Upvoted"
|
|||||||
|
|
||||||
#. placeholder {0}: votes.items.length
|
#. placeholder {0}: votes.items.length
|
||||||
#. placeholder {1}: votes.hasMore ? "+" : ""
|
#. placeholder {1}: votes.hasMore ? "+" : ""
|
||||||
#: src/pages/UserPublicProfile.tsx:893
|
#: src/pages/UserPublicProfile.tsx:895
|
||||||
msgid "Upvoted ({0}{1})"
|
msgid "Upvoted ({0}{1})"
|
||||||
msgstr "Upvoted ({0}{1})"
|
msgstr "Upvoted ({0}{1})"
|
||||||
|
|
||||||
@@ -1041,11 +1055,11 @@ msgstr "Username"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Users"
|
msgstr "Users"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:963
|
#: src/pages/UserPublicProfile.tsx:965
|
||||||
#: src/pages/UserPublicProfile.tsx:1006
|
#: src/pages/UserPublicProfile.tsx:1008
|
||||||
#: src/pages/UserPublicProfile.tsx:1048
|
#: src/pages/UserPublicProfile.tsx:1050
|
||||||
#: src/pages/UserPublicProfile.tsx:1255
|
#: src/pages/UserPublicProfile.tsx:1299
|
||||||
#: src/pages/UserPublicProfile.tsx:1386
|
#: src/pages/UserPublicProfile.tsx:1430
|
||||||
msgid "View all →"
|
msgid "View all →"
|
||||||
msgstr "View all →"
|
msgstr "View all →"
|
||||||
|
|
||||||
@@ -1058,8 +1072,8 @@ msgstr "View dump →"
|
|||||||
msgid "What makes it worth it?"
|
msgid "What makes it worth it?"
|
||||||
msgstr "What makes it worth it?"
|
msgstr "What makes it worth it?"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:850
|
#: src/pages/UserPublicProfile.tsx:852
|
||||||
#: src/pages/UserPublicProfile.tsx:1547
|
#: src/pages/UserPublicProfile.tsx:1591
|
||||||
msgid "Who am I?"
|
msgid "Who am I?"
|
||||||
msgstr "Who am I?"
|
msgstr "Who am I?"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -43,7 +43,7 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
|||||||
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:560
|
#: src/pages/PlaylistDetail.tsx:560
|
||||||
#: src/pages/UserPublicProfile.tsx:717
|
#: src/pages/UserPublicProfile.tsx:719
|
||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
msgstr "← Retour"
|
msgstr "← Retour"
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ msgstr "← Retour à toutes les recos"
|
|||||||
msgid "← Back to profile"
|
msgid "← Back to profile"
|
||||||
msgstr "← Retour au profil"
|
msgstr "← Retour au profil"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:110
|
#: src/pages/UserPublicProfile.tsx:111
|
||||||
msgid "+ Invite someone"
|
msgid "+ Invite someone"
|
||||||
msgstr "+ Inviter quelqu'un"
|
msgstr "+ Inviter quelqu'un"
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ msgstr "un commentaire"
|
|||||||
msgid "a post"
|
msgid "a post"
|
||||||
msgstr "une publication"
|
msgstr "une publication"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1093
|
#: src/pages/UserPublicProfile.tsx:1095
|
||||||
msgid "Account"
|
msgid "Account"
|
||||||
msgstr "Compte"
|
msgstr "Compte"
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ msgstr "Compte"
|
|||||||
msgid "Add a comment…"
|
msgid "Add a comment…"
|
||||||
msgstr "Ajouter un commentaire…"
|
msgstr "Ajouter un commentaire…"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:792
|
#: src/pages/UserPublicProfile.tsx:794
|
||||||
msgid "Add email…"
|
msgid "Add email…"
|
||||||
msgstr "Ajouter un e-mail…"
|
msgstr "Ajouter un e-mail…"
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ msgstr "Ajouter à la collection"
|
|||||||
msgid "Already have an account? <0>Log in</0>"
|
msgid "Already have an account? <0>Log in</0>"
|
||||||
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
|
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1112
|
#: src/pages/UserPublicProfile.tsx:1114
|
||||||
msgid "Appearance"
|
msgid "Appearance"
|
||||||
msgstr "Apparence"
|
msgstr "Apparence"
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ msgstr "Apparence"
|
|||||||
msgid "At least {0} characters"
|
msgid "At least {0} characters"
|
||||||
msgstr "Au moins {0} caractères"
|
msgstr "Au moins {0} caractères"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1160
|
#: src/pages/UserPublicProfile.tsx:1162
|
||||||
msgid "Auto"
|
msgid "Auto"
|
||||||
msgstr "Auto"
|
msgstr "Auto"
|
||||||
|
|
||||||
@@ -177,8 +177,8 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
|
|||||||
#: src/pages/Dump.tsx:357
|
#: src/pages/Dump.tsx:357
|
||||||
#: src/pages/DumpEdit.tsx:433
|
#: src/pages/DumpEdit.tsx:433
|
||||||
#: src/pages/PlaylistDetail.tsx:908
|
#: src/pages/PlaylistDetail.tsx:908
|
||||||
#: src/pages/UserPublicProfile.tsx:1496
|
#: src/pages/UserPublicProfile.tsx:1540
|
||||||
#: src/pages/UserPublicProfile.tsx:1566
|
#: src/pages/UserPublicProfile.tsx:1610
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Annuler"
|
msgstr "Annuler"
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ msgstr "Annuler la suppression"
|
|||||||
msgid "Cancel search"
|
msgid "Cancel search"
|
||||||
msgstr "Annuler la recherche"
|
msgstr "Annuler la recherche"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:744
|
#: src/pages/UserPublicProfile.tsx:746
|
||||||
msgid "Change avatar"
|
msgid "Change avatar"
|
||||||
msgstr "Changer l'avatar"
|
msgstr "Changer l'avatar"
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ msgstr "Changer l'avatar"
|
|||||||
msgid "Change password"
|
msgid "Change password"
|
||||||
msgstr "Changer le mot de passe"
|
msgstr "Changer le mot de passe"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1105
|
#: src/pages/UserPublicProfile.tsx:1107
|
||||||
msgid "Change password…"
|
msgid "Change password…"
|
||||||
msgstr "Changer le mot de passe…"
|
msgstr "Changer le mot de passe…"
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ msgstr "Vérification de l'invitation…"
|
|||||||
msgid "Close"
|
msgid "Close"
|
||||||
msgstr "Fermer"
|
msgstr "Fermer"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1152
|
#: src/pages/UserPublicProfile.tsx:1154
|
||||||
msgid "Color scheme"
|
msgid "Color scheme"
|
||||||
msgstr "Thème de couleur"
|
msgstr "Thème de couleur"
|
||||||
|
|
||||||
@@ -221,11 +221,11 @@ msgstr "Thème de couleur"
|
|||||||
msgid "Confirm new password"
|
msgid "Confirm new password"
|
||||||
msgstr "Confirmer le nouveau mot de passe"
|
msgstr "Confirmer le nouveau mot de passe"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:101
|
#: src/pages/UserPublicProfile.tsx:102
|
||||||
msgid "Copied!"
|
msgid "Copied!"
|
||||||
msgstr "Copié !"
|
msgstr "Copié !"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:101
|
#: src/pages/UserPublicProfile.tsx:102
|
||||||
msgid "Copy"
|
msgid "Copy"
|
||||||
msgstr "Copier"
|
msgstr "Copier"
|
||||||
|
|
||||||
@@ -263,10 +263,14 @@ msgstr "Création…"
|
|||||||
msgid "Current password"
|
msgid "Current password"
|
||||||
msgstr "Mot de passe actuel"
|
msgstr "Mot de passe actuel"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1174
|
#: src/pages/UserPublicProfile.tsx:1176
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
msgstr "Sombre"
|
msgstr "Sombre"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1189
|
||||||
|
msgid "Default tab"
|
||||||
|
msgstr "Onglet par défaut"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:374
|
#: src/components/CommentThread.tsx:374
|
||||||
#: src/components/CommentThread.tsx:380
|
#: src/components/CommentThread.tsx:380
|
||||||
#: src/components/ConfirmModal.tsx:16
|
#: src/components/ConfirmModal.tsx:16
|
||||||
@@ -329,13 +333,13 @@ msgstr "Recommandé !"
|
|||||||
|
|
||||||
#: src/pages/Search.tsx:172
|
#: src/pages/Search.tsx:172
|
||||||
#: src/pages/UserDumps.tsx:107
|
#: src/pages/UserDumps.tsx:107
|
||||||
#: src/pages/UserPublicProfile.tsx:865
|
#: src/pages/UserPublicProfile.tsx:867
|
||||||
msgid "Dumps"
|
msgid "Dumps"
|
||||||
msgstr "Recos"
|
msgstr "Recos"
|
||||||
|
|
||||||
#. placeholder {0}: dumps.items.length
|
#. placeholder {0}: dumps.items.length
|
||||||
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
#. placeholder {1}: dumps.hasMore ? "+" : ""
|
||||||
#: src/pages/UserPublicProfile.tsx:882
|
#: src/pages/UserPublicProfile.tsx:884
|
||||||
msgid "Dumps ({0}{1})"
|
msgid "Dumps ({0}{1})"
|
||||||
msgstr "Recos ({0}{1})"
|
msgstr "Recos ({0}{1})"
|
||||||
|
|
||||||
@@ -387,9 +391,9 @@ msgstr "Saisissez une recherche."
|
|||||||
msgid "Failed to create playlist"
|
msgid "Failed to create playlist"
|
||||||
msgstr "Impossible de créer la collection"
|
msgstr "Impossible de créer la collection"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:82
|
#: src/pages/UserPublicProfile.tsx:83
|
||||||
#: src/pages/UserPublicProfile.tsx:85
|
#: src/pages/UserPublicProfile.tsx:86
|
||||||
#: src/pages/UserPublicProfile.tsx:113
|
#: src/pages/UserPublicProfile.tsx:114
|
||||||
msgid "Failed to generate invite"
|
msgid "Failed to generate invite"
|
||||||
msgstr "Impossible de générer une invitation"
|
msgstr "Impossible de générer une invitation"
|
||||||
|
|
||||||
@@ -398,9 +402,9 @@ msgstr "Impossible de générer une invitation"
|
|||||||
#: src/pages/index/JournalFeed.tsx:33
|
#: src/pages/index/JournalFeed.tsx:33
|
||||||
#: src/pages/index/NewFeed.tsx:36
|
#: src/pages/index/NewFeed.tsx:36
|
||||||
#: src/pages/Notifications.tsx:367
|
#: src/pages/Notifications.tsx:367
|
||||||
#: src/pages/UserPublicProfile.tsx:984
|
#: src/pages/UserPublicProfile.tsx:986
|
||||||
#: src/pages/UserPublicProfile.tsx:1026
|
#: src/pages/UserPublicProfile.tsx:1028
|
||||||
#: src/pages/UserPublicProfile.tsx:1071
|
#: src/pages/UserPublicProfile.tsx:1073
|
||||||
msgid "Failed to load"
|
msgid "Failed to load"
|
||||||
msgstr "Chargement échoué"
|
msgstr "Chargement échoué"
|
||||||
|
|
||||||
@@ -417,8 +421,8 @@ msgid "Failed to post reply"
|
|||||||
msgstr "Impossible de publier la réponse"
|
msgstr "Impossible de publier la réponse"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:896
|
#: src/pages/PlaylistDetail.tsx:896
|
||||||
#: src/pages/UserPublicProfile.tsx:1499
|
#: src/pages/UserPublicProfile.tsx:1543
|
||||||
#: src/pages/UserPublicProfile.tsx:1568
|
#: src/pages/UserPublicProfile.tsx:1612
|
||||||
msgid "Failed to save"
|
msgid "Failed to save"
|
||||||
msgstr "Enregistrement échoué"
|
msgstr "Enregistrement échoué"
|
||||||
|
|
||||||
@@ -426,10 +430,14 @@ msgstr "Enregistrement échoué"
|
|||||||
msgid "Failed to save edit"
|
msgid "Failed to save edit"
|
||||||
msgstr "Impossible d'enregistrer la modification"
|
msgstr "Impossible d'enregistrer la modification"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:801
|
#: src/pages/UserPublicProfile.tsx:803
|
||||||
msgid "Failed to update avatar"
|
msgid "Failed to update avatar"
|
||||||
msgstr "Impossible de mettre à jour l'avatar"
|
msgstr "Impossible de mettre à jour l'avatar"
|
||||||
|
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1184
|
||||||
|
msgid "Feeds"
|
||||||
|
msgstr "Flux"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:360
|
#: src/components/DumpCreateModal.tsx:360
|
||||||
msgid "Fetching preview…"
|
msgid "Fetching preview…"
|
||||||
msgstr "Récupération de l'aperçu…"
|
msgstr "Récupération de l'aperçu…"
|
||||||
@@ -468,8 +476,9 @@ msgstr "Suivez des collections publiques pour voir leurs recos ici."
|
|||||||
msgid "Follow some users to see their dumps here."
|
msgid "Follow some users to see their dumps here."
|
||||||
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
||||||
|
|
||||||
#: src/components/FeedTabBar.tsx:16
|
#: src/components/FeedTabBar.tsx:21
|
||||||
#: src/pages/UserPublicProfile.tsx:867
|
#: src/pages/UserPublicProfile.tsx:869
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1218
|
||||||
msgid "Followed"
|
msgid "Followed"
|
||||||
msgstr "Suivi"
|
msgstr "Suivi"
|
||||||
|
|
||||||
@@ -479,13 +488,13 @@ msgstr "Suivi"
|
|||||||
msgid "Followed ({0}{1})"
|
msgid "Followed ({0}{1})"
|
||||||
msgstr "Suivies ({0}{1})"
|
msgstr "Suivies ({0}{1})"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1015
|
#: src/pages/UserPublicProfile.tsx:1017
|
||||||
msgid "Followed playlists"
|
msgid "Followed playlists"
|
||||||
msgstr "Collections suivies"
|
msgstr "Collections suivies"
|
||||||
|
|
||||||
#: src/components/FollowButton.tsx:37
|
#: src/components/FollowButton.tsx:37
|
||||||
#: src/components/FollowButton.tsx:64
|
#: src/components/FollowButton.tsx:64
|
||||||
#: src/pages/UserPublicProfile.tsx:973
|
#: src/pages/UserPublicProfile.tsx:975
|
||||||
msgid "Following"
|
msgid "Following"
|
||||||
msgstr "Abonné"
|
msgstr "Abonné"
|
||||||
|
|
||||||
@@ -509,7 +518,8 @@ msgstr "Accueil"
|
|||||||
msgid "Go to login"
|
msgid "Go to login"
|
||||||
msgstr "Aller à la connexion"
|
msgstr "Aller à la connexion"
|
||||||
|
|
||||||
#: src/components/FeedTabBar.tsx:12
|
#: src/components/FeedTabBar.tsx:17
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1197
|
||||||
msgid "Hot"
|
msgid "Hot"
|
||||||
msgstr "Tendances"
|
msgstr "Tendances"
|
||||||
|
|
||||||
@@ -525,20 +535,21 @@ msgstr "Invitation invalide"
|
|||||||
msgid "Invalid link"
|
msgid "Invalid link"
|
||||||
msgstr "Lien invalide"
|
msgstr "Lien invalide"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:763
|
#: src/pages/UserPublicProfile.tsx:765
|
||||||
msgid "invited by"
|
msgid "invited by"
|
||||||
msgstr "invité par"
|
msgstr "invité par"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:868
|
#: src/pages/UserPublicProfile.tsx:870
|
||||||
#: src/pages/UserPublicProfile.tsx:1060
|
#: src/pages/UserPublicProfile.tsx:1062
|
||||||
msgid "Invitees"
|
msgid "Invitees"
|
||||||
msgstr "Invités"
|
msgstr "Invités"
|
||||||
|
|
||||||
#: src/components/FeedTabBar.tsx:14
|
#: src/components/FeedTabBar.tsx:19
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1211
|
||||||
msgid "Journal"
|
msgid "Journal"
|
||||||
msgstr "Journal"
|
msgstr "Journal"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1167
|
#: src/pages/UserPublicProfile.tsx:1169
|
||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Clair"
|
msgstr "Clair"
|
||||||
|
|
||||||
@@ -580,7 +591,7 @@ msgstr "Chargement…"
|
|||||||
msgid "Loading playlist…"
|
msgid "Loading playlist…"
|
||||||
msgstr "Chargement de la collection…"
|
msgstr "Chargement de la collection…"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:700
|
#: src/pages/UserPublicProfile.tsx:702
|
||||||
msgid "Loading profile…"
|
msgid "Loading profile…"
|
||||||
msgstr "Chargement du profil…"
|
msgstr "Chargement du profil…"
|
||||||
|
|
||||||
@@ -596,9 +607,9 @@ msgstr "Chargement du profil…"
|
|||||||
#: src/pages/Notifications.tsx:439
|
#: src/pages/Notifications.tsx:439
|
||||||
#: src/pages/UserDumps.tsx:51
|
#: src/pages/UserDumps.tsx:51
|
||||||
#: src/pages/UserPlaylists.tsx:342
|
#: src/pages/UserPlaylists.tsx:342
|
||||||
#: src/pages/UserPublicProfile.tsx:978
|
#: src/pages/UserPublicProfile.tsx:980
|
||||||
#: src/pages/UserPublicProfile.tsx:1020
|
#: src/pages/UserPublicProfile.tsx:1022
|
||||||
#: src/pages/UserPublicProfile.tsx:1065
|
#: src/pages/UserPublicProfile.tsx:1067
|
||||||
#: src/pages/UserUpvoted.tsx:122
|
#: src/pages/UserUpvoted.tsx:122
|
||||||
msgid "Loading…"
|
msgid "Loading…"
|
||||||
msgstr "Chargement…"
|
msgstr "Chargement…"
|
||||||
@@ -617,8 +628,8 @@ msgstr "Se connecter pour aimer"
|
|||||||
msgid "Log in to vote"
|
msgid "Log in to vote"
|
||||||
msgstr "Se connecter pour voter"
|
msgstr "Se connecter pour voter"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:721
|
#: src/pages/UserPublicProfile.tsx:723
|
||||||
#: src/pages/UserPublicProfile.tsx:815
|
#: src/pages/UserPublicProfile.tsx:817
|
||||||
msgid "Log out"
|
msgid "Log out"
|
||||||
msgstr "Se déconnecter"
|
msgstr "Se déconnecter"
|
||||||
|
|
||||||
@@ -638,13 +649,16 @@ msgstr "Max 50 Mo"
|
|||||||
msgid "new"
|
msgid "new"
|
||||||
msgstr "nouveau"
|
msgstr "nouveau"
|
||||||
|
|
||||||
#: src/components/FeedTabBar.tsx:13
|
#: src/components/FeedTabBar.tsx:18
|
||||||
|
#: src/pages/UserPublicProfile.tsx:1204
|
||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "Nouveau"
|
msgstr "Nouveau"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:292
|
#: src/components/DumpCreateModal.tsx:292
|
||||||
|
#: src/components/DumpFab.tsx:65
|
||||||
|
#: src/components/DumpFab.tsx:66
|
||||||
#: src/pages/UserDumps.tsx:115
|
#: src/pages/UserDumps.tsx:115
|
||||||
#: src/pages/UserPublicProfile.tsx:1223
|
#: src/pages/UserPublicProfile.tsx:1267
|
||||||
msgid "New dump"
|
msgid "New dump"
|
||||||
msgstr "Nouvelle reco"
|
msgstr "Nouvelle reco"
|
||||||
|
|
||||||
@@ -677,11 +691,11 @@ msgid "No emoji found."
|
|||||||
msgstr "Aucun emoji trouvé."
|
msgstr "Aucun emoji trouvé."
|
||||||
|
|
||||||
#: src/pages/UserPlaylists.tsx:439
|
#: src/pages/UserPlaylists.tsx:439
|
||||||
#: src/pages/UserPublicProfile.tsx:1033
|
#: src/pages/UserPublicProfile.tsx:1035
|
||||||
msgid "No followed playlists yet."
|
msgid "No followed playlists yet."
|
||||||
msgstr "Pas encore de collections suivies."
|
msgstr "Pas encore de collections suivies."
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1078
|
#: src/pages/UserPublicProfile.tsx:1080
|
||||||
msgid "No invitees yet."
|
msgid "No invitees yet."
|
||||||
msgstr "Aucun invité pour le moment."
|
msgstr "Aucun invité pour le moment."
|
||||||
|
|
||||||
@@ -695,7 +709,7 @@ msgstr "Aucune collection ne correspond à « {q} »."
|
|||||||
|
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:34
|
#: src/components/PlaylistMembershipPanel.tsx:34
|
||||||
#: src/pages/UserPlaylists.tsx:397
|
#: src/pages/UserPlaylists.tsx:397
|
||||||
#: src/pages/UserPublicProfile.tsx:944
|
#: src/pages/UserPublicProfile.tsx:946
|
||||||
msgid "No playlists yet."
|
msgid "No playlists yet."
|
||||||
msgstr "Pas encore de collections."
|
msgstr "Pas encore de collections."
|
||||||
|
|
||||||
@@ -703,14 +717,14 @@ msgstr "Pas encore de collections."
|
|||||||
msgid "No users match \"{q}\"."
|
msgid "No users match \"{q}\"."
|
||||||
msgstr "Aucun utilisateur ne correspond à « {q} »."
|
msgstr "Aucun utilisateur ne correspond à « {q} »."
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:991
|
#: src/pages/UserPublicProfile.tsx:993
|
||||||
msgid "Not following anyone yet."
|
msgid "Not following anyone yet."
|
||||||
msgstr "Aucun abonnement pour le moment."
|
msgstr "Aucun abonnement pour le moment."
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:374
|
#: src/pages/Notifications.tsx:374
|
||||||
#: src/pages/UserDumps.tsx:125
|
#: src/pages/UserDumps.tsx:125
|
||||||
#: src/pages/UserPublicProfile.tsx:1234
|
#: src/pages/UserPublicProfile.tsx:1278
|
||||||
#: src/pages/UserPublicProfile.tsx:1356
|
#: src/pages/UserPublicProfile.tsx:1400
|
||||||
#: src/pages/UserUpvoted.tsx:194
|
#: src/pages/UserUpvoted.tsx:194
|
||||||
msgid "Nothing here yet."
|
msgid "Nothing here yet."
|
||||||
msgstr "Rien ici pour l'instant."
|
msgstr "Rien ici pour l'instant."
|
||||||
@@ -733,7 +747,7 @@ msgid "or <0>browse files</0>"
|
|||||||
msgstr "ou <0>parcourir les fichiers</0>"
|
msgstr "ou <0>parcourir les fichiers</0>"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:72
|
#: src/pages/UserLogin.tsx:72
|
||||||
#: src/pages/UserPublicProfile.tsx:1098
|
#: src/pages/UserPublicProfile.tsx:1100
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Mot de passe"
|
msgstr "Mot de passe"
|
||||||
|
|
||||||
@@ -763,13 +777,13 @@ msgstr "Titre de la collection"
|
|||||||
#: src/components/UserMenu.tsx:62
|
#: src/components/UserMenu.tsx:62
|
||||||
#: src/pages/Search.tsx:175
|
#: src/pages/Search.tsx:175
|
||||||
#: src/pages/UserPlaylists.tsx:368
|
#: src/pages/UserPlaylists.tsx:368
|
||||||
#: src/pages/UserPublicProfile.tsx:866
|
#: src/pages/UserPublicProfile.tsx:868
|
||||||
msgid "Playlists"
|
msgid "Playlists"
|
||||||
msgstr "Collections"
|
msgstr "Collections"
|
||||||
|
|
||||||
#. placeholder {0}: playlists.items.length
|
#. placeholder {0}: playlists.items.length
|
||||||
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
#. placeholder {1}: playlists.hasMore ? "+" : ""
|
||||||
#: src/pages/UserPublicProfile.tsx:913
|
#: src/pages/UserPublicProfile.tsx:915
|
||||||
msgid "Playlists ({0}{1})"
|
msgid "Playlists ({0}{1})"
|
||||||
msgstr "Collections ({0}{1})"
|
msgstr "Collections ({0}{1})"
|
||||||
|
|
||||||
@@ -885,8 +899,8 @@ msgstr "Réessayer"
|
|||||||
#: src/pages/Dump.tsx:349
|
#: src/pages/Dump.tsx:349
|
||||||
#: src/pages/DumpEdit.tsx:436
|
#: src/pages/DumpEdit.tsx:436
|
||||||
#: src/pages/PlaylistDetail.tsx:915
|
#: src/pages/PlaylistDetail.tsx:915
|
||||||
#: src/pages/UserPublicProfile.tsx:1488
|
#: src/pages/UserPublicProfile.tsx:1532
|
||||||
#: src/pages/UserPublicProfile.tsx:1558
|
#: src/pages/UserPublicProfile.tsx:1602
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Enregistrer"
|
msgstr "Enregistrer"
|
||||||
|
|
||||||
@@ -895,8 +909,8 @@ msgstr "Enregistrer"
|
|||||||
#: src/pages/Dump.tsx:348
|
#: src/pages/Dump.tsx:348
|
||||||
#: src/pages/PlaylistDetail.tsx:911
|
#: src/pages/PlaylistDetail.tsx:911
|
||||||
#: src/pages/ResetPassword.tsx:124
|
#: src/pages/ResetPassword.tsx:124
|
||||||
#: src/pages/UserPublicProfile.tsx:1485
|
#: src/pages/UserPublicProfile.tsx:1529
|
||||||
#: src/pages/UserPublicProfile.tsx:1555
|
#: src/pages/UserPublicProfile.tsx:1599
|
||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Enregistrement…"
|
msgstr "Enregistrement…"
|
||||||
|
|
||||||
@@ -934,7 +948,7 @@ msgstr "Serveur inaccessible"
|
|||||||
msgid "Set new password"
|
msgid "Set new password"
|
||||||
msgstr "Définir un nouveau mot de passe"
|
msgstr "Définir un nouveau mot de passe"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:870
|
#: src/pages/UserPublicProfile.tsx:872
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr "Paramètres"
|
msgstr "Paramètres"
|
||||||
|
|
||||||
@@ -943,7 +957,7 @@ msgstr "Paramètres"
|
|||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Une erreur est survenue"
|
msgstr "Une erreur est survenue"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1117
|
#: src/pages/UserPublicProfile.tsx:1119
|
||||||
msgid "Style"
|
msgid "Style"
|
||||||
msgstr "Style"
|
msgstr "Style"
|
||||||
|
|
||||||
@@ -997,7 +1011,7 @@ msgstr "Ne plus suivre {targetUsername}"
|
|||||||
msgid "Unfollow playlist"
|
msgid "Unfollow playlist"
|
||||||
msgstr "Ne plus suivre la collection"
|
msgstr "Ne plus suivre la collection"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:671
|
#: src/pages/UserPublicProfile.tsx:673
|
||||||
msgid "Upload failed"
|
msgid "Upload failed"
|
||||||
msgstr "Envoi échoué"
|
msgstr "Envoi échoué"
|
||||||
|
|
||||||
@@ -1015,7 +1029,7 @@ msgstr "Voté"
|
|||||||
|
|
||||||
#. placeholder {0}: votes.items.length
|
#. placeholder {0}: votes.items.length
|
||||||
#. placeholder {1}: votes.hasMore ? "+" : ""
|
#. placeholder {1}: votes.hasMore ? "+" : ""
|
||||||
#: src/pages/UserPublicProfile.tsx:893
|
#: src/pages/UserPublicProfile.tsx:895
|
||||||
msgid "Upvoted ({0}{1})"
|
msgid "Upvoted ({0}{1})"
|
||||||
msgstr "Votés ({0}{1})"
|
msgstr "Votés ({0}{1})"
|
||||||
|
|
||||||
@@ -1041,11 +1055,11 @@ msgstr "Nom d'utilisateur"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Utilisateurs"
|
msgstr "Utilisateurs"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:963
|
#: src/pages/UserPublicProfile.tsx:965
|
||||||
#: src/pages/UserPublicProfile.tsx:1006
|
#: src/pages/UserPublicProfile.tsx:1008
|
||||||
#: src/pages/UserPublicProfile.tsx:1048
|
#: src/pages/UserPublicProfile.tsx:1050
|
||||||
#: src/pages/UserPublicProfile.tsx:1255
|
#: src/pages/UserPublicProfile.tsx:1299
|
||||||
#: src/pages/UserPublicProfile.tsx:1386
|
#: src/pages/UserPublicProfile.tsx:1430
|
||||||
msgid "View all →"
|
msgid "View all →"
|
||||||
msgstr "Tout voir →"
|
msgstr "Tout voir →"
|
||||||
|
|
||||||
@@ -1058,8 +1072,8 @@ msgstr "Voir la reco →"
|
|||||||
msgid "What makes it worth it?"
|
msgid "What makes it worth it?"
|
||||||
msgstr "Pourquoi on en voudrait ?"
|
msgstr "Pourquoi on en voudrait ?"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:850
|
#: src/pages/UserPublicProfile.tsx:852
|
||||||
#: src/pages/UserPublicProfile.tsx:1547
|
#: src/pages/UserPublicProfile.tsx:1591
|
||||||
msgid "Who am I?"
|
msgid "Who am I?"
|
||||||
msgstr "Qui suis-je ?"
|
msgstr "Qui suis-je ?"
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { PresenceRow } from "../components/PresenceRow.tsx";
|
|||||||
import { FeedTabBar } from "../components/FeedTabBar.tsx";
|
import { FeedTabBar } from "../components/FeedTabBar.tsx";
|
||||||
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
|
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
|
||||||
import { useTabParam } from "../hooks/useTabParam.ts";
|
import { useTabParam } from "../hooks/useTabParam.ts";
|
||||||
|
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
|
||||||
|
|
||||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||||
|
|
||||||
@@ -83,7 +84,13 @@ export function Index() {
|
|||||||
);
|
);
|
||||||
const mainFetchDone = useRef(false);
|
const mainFetchDone = useRef(false);
|
||||||
|
|
||||||
const [tab] = useTabParam<FeedTab>(FEED_TABS, "hot");
|
// The preferred default tab decides which feed `/` opens on. "followed"
|
||||||
|
// requires a session, so fall back to "hot" for guests.
|
||||||
|
const [preferredTab] = useDefaultFeedTab();
|
||||||
|
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
||||||
|
? "hot"
|
||||||
|
: preferredTab;
|
||||||
|
const [tab] = useTabParam<FeedTab>(FEED_TABS, defaultTab);
|
||||||
|
|
||||||
// Web Share Target: Android share sheet navigates to /?share_url=...
|
// Web Share Target: Android share sheet navigates to /?share_url=...
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { PageShell } from "../components/PageShell.tsx";
|
|||||||
import { PageError } from "../components/PageError.tsx";
|
import { PageError } from "../components/PageError.tsx";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useTheme } from "../hooks/useTheme.ts";
|
import { useTheme } from "../hooks/useTheme.ts";
|
||||||
|
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||||
import { useFading } from "../hooks/useFading.ts";
|
import { useFading } from "../hooks/useFading.ts";
|
||||||
@@ -178,6 +179,7 @@ export function UserPublicProfile() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user: me, authFetch, login, logout, token } = useAuth();
|
const { user: me, authFetch, login, logout, token } = useAuth();
|
||||||
const { style, colorScheme, setStyle, setColorScheme } = useTheme();
|
const { style, colorScheme, setStyle, setColorScheme } = useTheme();
|
||||||
|
const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab();
|
||||||
const {
|
const {
|
||||||
voteCounts,
|
voteCounts,
|
||||||
myVotes,
|
myVotes,
|
||||||
@@ -1177,6 +1179,48 @@ export function UserPublicProfile() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section className="profile-section">
|
||||||
|
<h2 className="profile-section-title">
|
||||||
|
<Trans>Feeds</Trans>
|
||||||
|
</h2>
|
||||||
|
<div className="profile-appearance-grid">
|
||||||
|
<div className="profile-appearance-row">
|
||||||
|
<span className="profile-appearance-label">
|
||||||
|
<Trans>Default tab</Trans>
|
||||||
|
</span>
|
||||||
|
<div className="visibility-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={defaultFeedTab === "hot" ? "active" : ""}
|
||||||
|
onClick={() => setDefaultFeedTab("hot")}
|
||||||
|
>
|
||||||
|
<Trans>Hot</Trans>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={defaultFeedTab === "new" ? "active" : ""}
|
||||||
|
onClick={() => setDefaultFeedTab("new")}
|
||||||
|
>
|
||||||
|
<Trans>New</Trans>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={defaultFeedTab === "journal" ? "active" : ""}
|
||||||
|
onClick={() => setDefaultFeedTab("journal")}
|
||||||
|
>
|
||||||
|
<Trans>Journal</Trans>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={defaultFeedTab === "followed" ? "active" : ""}
|
||||||
|
onClick={() => setDefaultFeedTab("followed")}
|
||||||
|
>
|
||||||
|
<Trans>Followed</Trans>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -149,7 +149,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Buttons — square corners, modest hard shadow ────────────────── */
|
/* ── Buttons — square corners, modest hard shadow ────────────────── */
|
||||||
[data-style="brutalist"] button {
|
/* `.dump-fab` is exempt from the generic button chrome — it keeps its own
|
||||||
|
shape, shadow and animations — but this hard-edged theme squares it off. */
|
||||||
|
[data-style="brutalist"] .dump-fab {
|
||||||
|
--fab-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="brutalist"] button:not(.dump-fab) {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: 2px solid var(--color-border);
|
border: 2px solid var(--color-border);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -157,7 +163,7 @@
|
|||||||
transition: box-shadow 0.08s ease, transform 0.08s ease, border-color 0.1s;
|
transition: box-shadow 0.08s ease, transform 0.08s ease, border-color 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-style="brutalist"] button:hover:not(:disabled) {
|
[data-style="brutalist"] button:not(.dump-fab):hover:not(:disabled) {
|
||||||
border-color: var(--color-border);
|
border-color: var(--color-border);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transform: translate(2px, 2px);
|
transform: translate(2px, 2px);
|
||||||
|
|||||||
@@ -252,7 +252,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Buttons — chunky Win95 outset bevel, press = inset ──────────── */
|
/* ── Buttons — chunky Win95 outset bevel, press = inset ──────────── */
|
||||||
[data-style="geocities"] button {
|
/* `.dump-fab` is exempt from the generic button chrome — it keeps its own
|
||||||
|
shape, glow and animations — but this squared-off retro theme squares it. */
|
||||||
|
[data-style="geocities"] .dump-fab {
|
||||||
|
--fab-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="geocities"] button:not(.dump-fab) {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: 3px outset var(--color-border);
|
border: 3px outset var(--color-border);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -282,6 +288,14 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The shared .vote-btn declares `background: transparent`, which leaves the
|
||||||
|
chunky outset bevel see-through against the card. Give the resting state a
|
||||||
|
solid raised-panel fill; the voted (--active) state keeps its accent
|
||||||
|
background, so scope this to non-active buttons. */
|
||||||
|
[data-style="geocities"] .vote-btn:not(.vote-btn--active) {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
/* Ghost / icon-only buttons stay flat */
|
/* Ghost / icon-only buttons stay flat */
|
||||||
[data-style="geocities"] .modal-close-btn,
|
[data-style="geocities"] .modal-close-btn,
|
||||||
[data-style="geocities"] .playlist-remove-btn,
|
[data-style="geocities"] .playlist-remove-btn,
|
||||||
@@ -326,6 +340,10 @@
|
|||||||
box-shadow: 0 0 9px color-mix(in srgb, var(--color-border) 38%, transparent);
|
box-shadow: 0 0 9px color-mix(in srgb, var(--color-border) 38%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-style="geocities"] .dump-card-comment {
|
||||||
|
padding-bottom: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Comments nest deeply, so the full frame piled up. Keep the squared retro
|
/* Comments nest deeply, so the full frame piled up. Keep the squared retro
|
||||||
edge but lighten it: thin flat border, just a whisper of the same halo. */
|
edge but lighten it: thin flat border, just a whisper of the same halo. */
|
||||||
[data-style="geocities"] .comment-node-inner,
|
[data-style="geocities"] .comment-node-inner,
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Sharp, rectilinear corners throughout — newsprint has no rounding. */
|
/* Sharp, rectilinear corners throughout — newsprint has no rounding. */
|
||||||
[data-style="nyt"] button,
|
[data-style="nyt"] button:not(.dump-fab),
|
||||||
[data-style="nyt"] .dump-card,
|
[data-style="nyt"] .dump-card,
|
||||||
[data-style="nyt"] .playlist-card,
|
[data-style="nyt"] .playlist-card,
|
||||||
[data-style="nyt"] .journal-card,
|
[data-style="nyt"] .journal-card,
|
||||||
@@ -182,6 +182,12 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* This theme squares everything off — opt the FAB into a square too (it keeps
|
||||||
|
its animations; only the corners change). */
|
||||||
|
[data-style="nyt"] .dump-fab {
|
||||||
|
--fab-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Headlines — display serif ───────────────────────────────────── */
|
/* ── Headlines — display serif ───────────────────────────────────── */
|
||||||
[data-style="nyt"] h1,
|
[data-style="nyt"] h1,
|
||||||
[data-style="nyt"] .app-header-brand,
|
[data-style="nyt"] .app-header-brand,
|
||||||
@@ -364,8 +370,9 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page-coloured strip over the grid's content-left edge, hiding the first
|
/* Page-coloured strips over the grid's content-left and content-top edges,
|
||||||
column's left rule (left value tracks the grid's padding-left). */
|
hiding the first column's left rule and the first row's top rule (offsets
|
||||||
|
track the grid's padding-left / padding-top). */
|
||||||
[data-style="nyt"] .journal-grid::before {
|
[data-style="nyt"] .journal-grid::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -377,10 +384,24 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
[data-style="nyt"] .journal-grid::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 1.25rem;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@media (max-width: 460px) {
|
@media (max-width: 460px) {
|
||||||
[data-style="nyt"] .journal-grid::before {
|
[data-style="nyt"] .journal-grid::before {
|
||||||
left: 1rem;
|
left: 1rem;
|
||||||
}
|
}
|
||||||
|
[data-style="nyt"] .journal-grid::after {
|
||||||
|
top: 0.85rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
[data-style="nyt"] .journal-card:hover {
|
[data-style="nyt"] .journal-card:hover {
|
||||||
border-color: var(--color-border);
|
border-color: var(--color-border);
|
||||||
|
|||||||
Reference in New Issue
Block a user