v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* Convert a caught fetch error into a human-readable message.
|
||||
* TypeError means the request never left the client (no network / CORS / etc.).
|
||||
*/
|
||||
export function friendlyFetchError(err: unknown): string {
|
||||
if (err instanceof TypeError) {
|
||||
return "Could not connect to the server. Check your connection and try again.";
|
||||
}
|
||||
return err instanceof Error ? err.message : "An unexpected error occurred.";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PublicUser, Role } from "../model.ts";
|
||||
import type { PublicUser, Role } from "../../model.ts";
|
||||
|
||||
// Mirror of api/lib/permissions.ts. Keep the two in sync.
|
||||
export type Permission =
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type AuthResponse, deserializeAuthResponse } from "../model.ts";
|
||||
import { type AuthResponse, deserializeAuthResponse } from "../../model.ts";
|
||||
|
||||
// The session is shared by every tab through this one localStorage entry.
|
||||
export const AUTH_STORAGE_KEY = "authResponse";
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChatReplyTarget } from "../contexts/WSContext.ts";
|
||||
import type { ChatReplyTarget } from "../../contexts/ws/WSContext.ts";
|
||||
|
||||
// The draft and reply target outlive the chat — closing it, or reloading the
|
||||
// tab, keeps a half-written message.
|
||||
26
src/utils/chat/message.ts
Normal file
26
src/utils/chat/message.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { markdownToPlainText } from "./text.ts";
|
||||
import { isEmojiOnly } from "../editor/text.ts";
|
||||
|
||||
// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts.
|
||||
export const MAX_CHAT_LENGTH = 2000;
|
||||
// The counter only shows once a message is getting near the limit.
|
||||
export const COUNT_FROM = 1800;
|
||||
|
||||
// What the server holds a message to: its length once trimmed.
|
||||
export function sendable(draft: string): boolean {
|
||||
const body = draft.trim();
|
||||
return body.length > 0 && body.length <= MAX_CHAT_LENGTH;
|
||||
}
|
||||
|
||||
// A message of nothing but a few emoji is shown large, as chats do.
|
||||
export function messageTextClass(body: string): string {
|
||||
return isEmojiOnly(body)
|
||||
? "chat-message-text chat-message-text--jumbo"
|
||||
: "chat-message-text";
|
||||
}
|
||||
|
||||
// One line of plain text, for reply previews.
|
||||
export function snippet(body: string): string {
|
||||
return markdownToPlainText(body, t`image`);
|
||||
}
|
||||
39
src/utils/chat/time.ts
Normal file
39
src/utils/chat/time.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Timestamps in the chat: day separators and the grouped-message gutter.
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { i18n } from "../../i18n.ts";
|
||||
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
// Human label for a day separator: "Today"/"Yesterday" for the two most recent
|
||||
// days, otherwise a localized full date.
|
||||
export function dayLabel(date: Date): string {
|
||||
const now = new Date();
|
||||
if (isSameDay(date, now)) return t`Today`;
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (isSameDay(date, yesterday)) return t`Yesterday`;
|
||||
return date.toLocaleDateString(i18n.locale, {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: now.getFullYear() === date.getFullYear() ? undefined : "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// Hours and minutes only, for the narrow gutter beside a grouped message — the
|
||||
// day period ("PM") is dropped to fit; its tooltip has the full timestamp.
|
||||
export function clockTime(date: Date): string {
|
||||
return new Intl.DateTimeFormat(i18n.locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).formatToParts(date)
|
||||
.filter((part) => part.type !== "dayPeriod")
|
||||
.map((part) => part.value)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
13
src/utils/editor/skinTone.ts
Normal file
13
src/utils/editor/skinTone.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// The emoji picker's skin tone, remembered across pickers and reloads.
|
||||
|
||||
import type { SkinTone } from "frimousse";
|
||||
|
||||
export const SKIN_TONE_KEY = "emoji-skin-tone";
|
||||
|
||||
export function readSkinTone(): SkinTone {
|
||||
try {
|
||||
return (localStorage.getItem(SKIN_TONE_KEY) as SkinTone | null) ?? "none";
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dump } from "../model.ts";
|
||||
import type { Dump } from "../../model.ts";
|
||||
|
||||
export function hotScore(dump: Dump): number {
|
||||
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dump } from "../model.ts";
|
||||
import type { Dump } from "../../model.ts";
|
||||
import { hotScore } from "./hotScore.ts";
|
||||
|
||||
/**
|
||||
@@ -1,5 +1,5 @@
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { PlayerItem } from "../contexts/PlayerContext.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type { PlayerItem } from "../../contexts/player/PlayerContext.ts";
|
||||
|
||||
/** Mirrors the `Tralbum` shape returned by GET /api/bandcamp/tracks. */
|
||||
interface BandcampTrack {
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PlayerItem } from "../contexts/PlayerContext.ts";
|
||||
import type { PlayerItem } from "../../contexts/player/PlayerContext.ts";
|
||||
import { resolveBandcampQueue } from "./bandcamp.ts";
|
||||
|
||||
/**
|
||||
33
src/utils/player/volume.ts
Normal file
33
src/utils/player/volume.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Each queue advance remounts MediaPlayer (the element is keyed on the source),
|
||||
// which would otherwise snap the volume back to 100% on every track. Hold it
|
||||
// outside the component and mirror it to localStorage so it also survives a
|
||||
// reload.
|
||||
|
||||
const VOLUME_KEY = "player-volume";
|
||||
|
||||
let lastVolume = 1;
|
||||
let lastMuted = false;
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(VOLUME_KEY) ?? "null");
|
||||
if (stored && typeof stored.volume === "number") {
|
||||
lastVolume = Math.min(Math.max(stored.volume, 0), 1);
|
||||
lastMuted = stored.muted === true;
|
||||
}
|
||||
} catch {
|
||||
// Malformed or unavailable storage — the defaults above stand.
|
||||
}
|
||||
|
||||
export function rememberVolume(volume: number, muted: boolean) {
|
||||
lastVolume = volume;
|
||||
lastMuted = muted;
|
||||
try {
|
||||
localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
|
||||
} catch {
|
||||
// Private mode or blocked storage — volume just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
/** The volume and mute state the last player left behind. */
|
||||
export function storedVolume(): { volume: number; muted: boolean } {
|
||||
return { volume: lastVolume, muted: lastMuted };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
|
||||
/** Mirrors one entry of GET /api/youtube/playlist. */
|
||||
export interface YouTubeTrack {
|
||||
24
src/utils/shared/api.ts
Normal file
24
src/utils/shared/api.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { parseAPIResponse } from "../../model.ts";
|
||||
|
||||
/**
|
||||
* Convert a caught fetch error into a human-readable message.
|
||||
* TypeError means the request never left the client (no network / CORS / etc.).
|
||||
*/
|
||||
export function friendlyFetchError(err: unknown): string {
|
||||
if (err instanceof TypeError) {
|
||||
return "Could not connect to the server. Check your connection and try again.";
|
||||
}
|
||||
return err instanceof Error ? err.message : "An unexpected error occurred.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a fetch `Response` through the standard API envelope and returns the
|
||||
* payload, throwing the server's message on `success: false`. A thrown error
|
||||
* here (or a network `TypeError` from the `fetch` itself) is caught by
|
||||
* `useApiForm`'s `submit` and rendered as the form's root error.
|
||||
*/
|
||||
export async function expectOk<T>(res: Response): Promise<T> {
|
||||
const body = parseAPIResponse<T>(await res.json());
|
||||
if (!body.success) throw new Error(body.error.message);
|
||||
return body.data;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
|
||||
/** Our own API origin — `API_URL` is relative ("") when we're same-origin. */
|
||||
function apiOrigin(): string {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { i18n } from "../i18n.ts";
|
||||
import { i18n } from "../../i18n.ts";
|
||||
|
||||
export function relativeTime(date: Date): string {
|
||||
const rtf = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
|
||||
@@ -1,4 +1,4 @@
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
|
||||
type VersionedDump = {
|
||||
id: string;
|
||||
Reference in New Issue
Block a user