v3: localization fixes, char counters & limits on all text fields, ux fixes
This commit is contained in:
50
src/App.css
50
src/App.css
@@ -539,7 +539,11 @@
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface) 92%);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 8%,
|
||||
var(--color-surface) 92%
|
||||
);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
@@ -740,7 +744,11 @@
|
||||
min-width: 0;
|
||||
height: 48px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-border) 88%);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 12%,
|
||||
var(--color-border) 88%
|
||||
);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
@@ -2354,7 +2362,6 @@ body.has-player .fab-new {
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
@@ -2370,6 +2377,7 @@ body.has-player .fab-new {
|
||||
.playlist-card-icon {
|
||||
font-size: 1.4rem;
|
||||
opacity: 0.7;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Fill the 48×48 preview box and center content for media buttons */
|
||||
@@ -2380,10 +2388,16 @@ body.has-player .fab-new {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dump-card-preview .rich-content-compact {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dump-card-preview .rich-content-compact-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
@@ -3450,10 +3464,12 @@ body.has-player .fab-new {
|
||||
|
||||
.journal-card--large .journal-card-comment {
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
}
|
||||
|
||||
.journal-card--medium .journal-card-comment {
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
}
|
||||
|
||||
.journal-card-footer {
|
||||
@@ -3791,6 +3807,28 @@ body.has-player .fab-new {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.input-with-count {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-editor-count {
|
||||
display: block;
|
||||
text-align: right;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
.text-editor-count--warn {
|
||||
color: var(--color-warning, #c97a00);
|
||||
}
|
||||
.text-editor-count--danger {
|
||||
color: var(--color-danger, #c0392b);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mention-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
@@ -9,12 +9,16 @@ import { NotificationBell } from "./NotificationBell.tsx";
|
||||
import { UserMenu } from "./UserMenu.tsx";
|
||||
|
||||
export function AppHeader(
|
||||
{ centerSlot, disableNew }: { centerSlot?: ReactNode; disableNew?: boolean },
|
||||
{ centerSlot, disableNew, initialDumpUrl }: {
|
||||
centerSlot?: ReactNode;
|
||||
disableNew?: boolean;
|
||||
initialDumpUrl?: string;
|
||||
},
|
||||
) {
|
||||
const { user } = useAuth();
|
||||
const { wsStatus, wsErrorMessage } = useWS();
|
||||
const navigate = useNavigate();
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(!!initialDumpUrl);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -76,13 +80,18 @@ export function AppHeader(
|
||||
|
||||
{wsStatus === "disconnected" && wsErrorMessage && (
|
||||
<div className="app-header-status" role="alert">
|
||||
<strong><Trans>Live updates unavailable.</Trans></strong>{" "}
|
||||
<strong>
|
||||
<Trans>Live updates unavailable.</Trans>
|
||||
</strong>{" "}
|
||||
{wsErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createModalOpen && (
|
||||
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||
<DumpCreateModal
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
initialUrl={initialDumpUrl}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import type {
|
||||
Comment,
|
||||
CreateCommentRequest,
|
||||
@@ -79,7 +79,10 @@ function CommentNode({
|
||||
|
||||
async function handleReply(e?: React.SubmitEvent) {
|
||||
e?.preventDefault();
|
||||
if (!replyBody.trim() || !token) return;
|
||||
if (
|
||||
!replyBody.trim() || !token ||
|
||||
replyBody.length > VALIDATION.COMMENT_BODY_MAX
|
||||
) return;
|
||||
setSubmitting(true);
|
||||
setReplyError(null);
|
||||
try {
|
||||
@@ -124,7 +127,10 @@ function CommentNode({
|
||||
|
||||
async function handleEditSave(e?: React.SubmitEvent) {
|
||||
e?.preventDefault();
|
||||
if (!editBody.trim() || !token) return;
|
||||
if (
|
||||
!editBody.trim() || !token ||
|
||||
editBody.length > VALIDATION.COMMENT_BODY_MAX
|
||||
) return;
|
||||
setEditSubmitting(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
@@ -244,17 +250,24 @@ function CommentNode({
|
||||
onSubmit={handleEditSave}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
{editError && (
|
||||
<ErrorCard title={t`Failed to save edit`} message={editError} />
|
||||
<ErrorCard
|
||||
title={t`Failed to save edit`}
|
||||
message={editError}
|
||||
/>
|
||||
)}
|
||||
<div className="comment-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="comment-submit-btn"
|
||||
disabled={editSubmitting || !editBody.trim()}
|
||||
disabled={editSubmitting || !editBody.trim() ||
|
||||
editBody.length > VALIDATION.COMMENT_BODY_MAX}
|
||||
>
|
||||
{editSubmitting ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
||||
{editSubmitting
|
||||
? <Trans>Saving…</Trans>
|
||||
: <Trans>Save</Trans>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -329,17 +342,24 @@ function CommentNode({
|
||||
placeholder={t`Write a reply…`}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
{replyError && (
|
||||
<ErrorCard title={t`Failed to post reply`} message={replyError} />
|
||||
<ErrorCard
|
||||
title={t`Failed to post reply`}
|
||||
message={replyError}
|
||||
/>
|
||||
)}
|
||||
<div className="comment-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="comment-submit-btn"
|
||||
disabled={submitting || !replyBody.trim()}
|
||||
disabled={submitting || !replyBody.trim() ||
|
||||
replyBody.length > VALIDATION.COMMENT_BODY_MAX}
|
||||
>
|
||||
{submitting ? <Trans>Posting…</Trans> : <Trans>Post reply</Trans>}
|
||||
{submitting
|
||||
? <Trans>Posting…</Trans>
|
||||
: <Trans>Post reply</Trans>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -400,7 +420,10 @@ export function CommentThread({
|
||||
|
||||
async function handleTopLevelSubmit(e?: React.SubmitEvent) {
|
||||
e?.preventDefault();
|
||||
if (!topLevelBody.trim() || !token) return;
|
||||
if (
|
||||
!topLevelBody.trim() || !token ||
|
||||
topLevelBody.length > VALIDATION.COMMENT_BODY_MAX
|
||||
) return;
|
||||
setSubmitting(true);
|
||||
setTopLevelError(null);
|
||||
try {
|
||||
@@ -456,6 +479,7 @@ export function CommentThread({
|
||||
placeholder={t`Add a comment…`}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
{topLevelError && (
|
||||
<ErrorCard
|
||||
@@ -467,9 +491,12 @@ export function CommentThread({
|
||||
<button
|
||||
type="submit"
|
||||
className="comment-submit-btn"
|
||||
disabled={submitting || !topLevelBody.trim()}
|
||||
disabled={submitting || !topLevelBody.trim() ||
|
||||
topLevelBody.length > VALIDATION.COMMENT_BODY_MAX}
|
||||
>
|
||||
{submitting ? <Trans>Posting…</Trans> : <Trans>Post comment</Trans>}
|
||||
{submitting
|
||||
? <Trans>Posting…</Trans>
|
||||
: <Trans>Post comment</Trans>}
|
||||
</button>
|
||||
{topLevelBody.trim() && (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
|
||||
22
src/components/CountedInput.tsx
Normal file
22
src/components/CountedInput.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type React from "react";
|
||||
import { charCountClass } from "../utils/charCount.ts";
|
||||
|
||||
interface CountedInputProps
|
||||
extends
|
||||
Omit<React.InputHTMLAttributes<HTMLInputElement>, "maxLength" | "value"> {
|
||||
value: string;
|
||||
maxLength: number;
|
||||
}
|
||||
|
||||
export function CountedInput({ value, maxLength, ...rest }: CountedInputProps) {
|
||||
const countClass = charCountClass(value.length, maxLength);
|
||||
|
||||
return (
|
||||
<div className="input-with-count">
|
||||
<input value={value} maxLength={maxLength} {...rest} />
|
||||
<span className={`text-editor-count${countClass}`}>
|
||||
{value.length} / {maxLength}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import type {
|
||||
CreateUrlDumpRequest,
|
||||
Dump,
|
||||
@@ -51,16 +51,20 @@ type UrlPreview =
|
||||
| { status: "done"; richContent: RichContent | null };
|
||||
|
||||
function LocalFilePreview({ file }: { file: File }) {
|
||||
// useRef instead of useMemo+useEffect: StrictMode double-invokes effect
|
||||
// cleanups, which would revoke the blob URL before the video element can use it.
|
||||
const blobRef = useRef<{ file: File; url: string } | null>(null);
|
||||
if (blobRef.current?.file !== file) {
|
||||
if (blobRef.current) URL.revokeObjectURL(blobRef.current.url);
|
||||
blobRef.current = { file, url: URL.createObjectURL(file) };
|
||||
}
|
||||
const src = blobRef.current.url;
|
||||
const mime = file.type;
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
// Blob URL lifecycle requires setState in effect — no synchronous alternative.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSrc(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
if (!src) return null;
|
||||
const mime = file.type;
|
||||
if (mime.startsWith("image/")) {
|
||||
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||
}
|
||||
@@ -75,9 +79,12 @@ function LocalFilePreview({ file }: { file: File }) {
|
||||
|
||||
interface DumpCreateModalProps {
|
||||
onClose: () => void;
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
export function DumpCreateModal(
|
||||
{ onClose, initialUrl = "" }: DumpCreateModalProps,
|
||||
) {
|
||||
const { authFetch } = useAuth();
|
||||
const { injectDump } = useWS();
|
||||
|
||||
@@ -86,7 +93,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
|
||||
// Create phase state
|
||||
const [mode, setMode] = useState<Mode>("url");
|
||||
const [url, setUrl] = useState("");
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [comment, setComment] = useState("");
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
@@ -166,6 +173,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
|
||||
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (comment.length > VALIDATION.DUMP_COMMENT_MAX) return;
|
||||
setSubmitState({ status: "submitting" });
|
||||
|
||||
try {
|
||||
@@ -320,7 +328,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="dc-url"><Trans>URL</Trans></label>
|
||||
<label htmlFor="dc-url">
|
||||
<Trans>URL</Trans>
|
||||
</label>
|
||||
<input
|
||||
id="dc-url"
|
||||
type="url"
|
||||
@@ -345,7 +355,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
/>
|
||||
</div>
|
||||
{urlPreview.status === "loading" && (
|
||||
<p className="preview-loading"><Trans>Fetching preview…</Trans></p>
|
||||
<p className="preview-loading">
|
||||
<Trans>Fetching preview…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{urlPreview.status === "done" &&
|
||||
urlPreview.richContent && (
|
||||
@@ -377,6 +389,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
disabled={submitting}
|
||||
placeholder={t`Tell the community what makes this worth their time...`}
|
||||
rows={3}
|
||||
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -411,7 +424,8 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={submitting}
|
||||
disabled={submitting ||
|
||||
comment.length > VALIDATION.DUMP_COMMENT_MAX}
|
||||
>
|
||||
{submitting
|
||||
? (mode === "url"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { formatBytes } from "../utils/format.ts";
|
||||
|
||||
@@ -136,9 +136,15 @@ export function FileDropZone({
|
||||
</svg>
|
||||
<p className="fdz__hint">{resolvedHint}</p>
|
||||
<p className="fdz__browse">
|
||||
<Trans>or <span className="fdz__browse-link">browse files</span></Trans>
|
||||
<Trans>
|
||||
or <span className="fdz__browse-link">browse files</span>
|
||||
</Trans>
|
||||
</p>
|
||||
{showLimit && <p className="fdz__limit"><Trans>Max 50 MB</Trans></p>}
|
||||
{showLimit && (
|
||||
<p className="fdz__limit">
|
||||
<Trans>Max 50 MB</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -33,9 +33,13 @@ function AudioFilePreview(
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
extractPeaks(fileUrl, NUM_BARS)
|
||||
.then((p) => { if (!cancelled) setPeaks(p); })
|
||||
.then((p) => {
|
||||
if (!cancelled) setPeaks(p);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fileUrl]);
|
||||
|
||||
const handlePlayBtn = () => {
|
||||
@@ -45,7 +49,10 @@ function AudioFilePreview(
|
||||
|
||||
const handleWaveformClick = (e: React.MouseEvent<Element>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
if (isActive) {
|
||||
seekTo(ratio * duration);
|
||||
} else {
|
||||
@@ -58,7 +65,11 @@ function AudioFilePreview(
|
||||
const isPlaying = isActive && playing;
|
||||
|
||||
return (
|
||||
<div className={`audio-file-preview${isActive ? " audio-file-preview--active" : ""}`}>
|
||||
<div
|
||||
className={`audio-file-preview${
|
||||
isActive ? " audio-file-preview--active" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="audio-player-btn"
|
||||
@@ -67,13 +78,21 @@ function AudioFilePreview(
|
||||
>
|
||||
{isPlaying
|
||||
? (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
style={{ padding: "1px" }}
|
||||
>
|
||||
<rect x="5" y="3" width="4" height="18" rx="1" />
|
||||
<rect x="15" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
)
|
||||
: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
style={{ marginLeft: "2px" }}
|
||||
>
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
)}
|
||||
@@ -98,7 +117,9 @@ function AudioFilePreview(
|
||||
y={y}
|
||||
width={BAR_W}
|
||||
height={barH}
|
||||
className={`waveform-bar${played ? " waveform-bar--played" : ""}`}
|
||||
className={`waveform-bar${
|
||||
played ? " waveform-bar--played" : ""
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -130,7 +151,6 @@ export default function FilePreview(
|
||||
const { current, playing, play, togglePlay } = useContext(PlayerContext);
|
||||
const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`;
|
||||
const mime = dump.fileMime ?? "";
|
||||
const isMedia = mime.startsWith("video/") || mime.startsWith("audio/");
|
||||
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
|
||||
|
||||
if (compact) {
|
||||
@@ -150,7 +170,9 @@ export default function FilePreview(
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rich-content-thumbnail-btn${isPlaying ? " is-playing" : ""}`}
|
||||
className={`rich-content-thumbnail-btn${
|
||||
isPlaying ? " is-playing" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -174,14 +196,16 @@ export default function FilePreview(
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rich-content-compact-icon rich-content-thumbnail-btn${isPlaying ? " is-playing" : ""}`}
|
||||
className={`rich-content-thumbnail-btn${
|
||||
isPlaying ? " is-playing" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
|
||||
}}
|
||||
>
|
||||
{mimeIcon(mime)}
|
||||
<span className="rich-content-compact-icon">{mimeIcon(mime)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -202,7 +226,13 @@ export default function FilePreview(
|
||||
<button
|
||||
type="button"
|
||||
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
|
||||
onClick={() => videoActive ? togglePlay() : play({ kind: "file", fileUrl, mimeType: mime, title: dump.title })}
|
||||
onClick={() =>
|
||||
videoActive ? togglePlay() : play({
|
||||
kind: "file",
|
||||
fileUrl,
|
||||
mimeType: mime,
|
||||
title: dump.title,
|
||||
})}
|
||||
>
|
||||
<video
|
||||
src={fileUrl}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useFollows } from "../hooks/useFollows.ts";
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
|
||||
function itemKey(item: { kind: string; embedUrl?: string; fileUrl?: string } | null) {
|
||||
function itemKey(
|
||||
item: { kind: string; embedUrl?: string; fileUrl?: string } | null,
|
||||
) {
|
||||
if (!item) return null;
|
||||
return item.kind === "embed" ? item.embedUrl : item.fileUrl;
|
||||
}
|
||||
@@ -50,13 +52,18 @@ export function GlobalPlayer() {
|
||||
|
||||
const typeClass = current.kind === "embed"
|
||||
? current.type
|
||||
: current.mimeType.startsWith("video/") ? "file-video" : "file-audio";
|
||||
: current.mimeType.startsWith("video/")
|
||||
? "file-video"
|
||||
: "file-audio";
|
||||
|
||||
const title = current.title ?? (current.kind === "embed" ? current.embedUrl : current.fileUrl);
|
||||
const title = current.title ??
|
||||
(current.kind === "embed" ? current.embedUrl : current.fileUrl);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`global-player global-player--${typeClass}${reduced ? " global-player--reduced" : ""}`}
|
||||
className={`global-player global-player--${typeClass}${
|
||||
reduced ? " global-player--reduced" : ""
|
||||
}`}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="global-player-header">
|
||||
|
||||
@@ -108,6 +108,7 @@ export function JournalCard(
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
play({
|
||||
kind: "embed",
|
||||
embedUrl,
|
||||
title: dump.richContent?.title,
|
||||
type: dump.richContent?.type ?? "unknown",
|
||||
|
||||
@@ -61,16 +61,23 @@ function Waveform(
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
extractPeaks(src, NUM_BARS)
|
||||
.then((p) => { if (!cancelled) setPeaks(p); })
|
||||
.then((p) => {
|
||||
if (!cancelled) setPeaks(p);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<Element>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
onSeek(ratio * duration);
|
||||
};
|
||||
|
||||
@@ -128,8 +135,16 @@ interface MediaPlayerProps {
|
||||
}
|
||||
|
||||
export function MediaPlayer(
|
||||
{ src, kind, mime, autoplay, onPlayStateChange, onTimeUpdate, seekRef, toggleRef }:
|
||||
MediaPlayerProps,
|
||||
{
|
||||
src,
|
||||
kind,
|
||||
mime,
|
||||
autoplay,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
seekRef,
|
||||
toggleRef,
|
||||
}: MediaPlayerProps,
|
||||
) {
|
||||
const mediaRef = useRef<HTMLMediaElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
@@ -141,36 +156,50 @@ export function MediaPlayer(
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// ── Callback refs (mutated in render, never trigger effects as deps) ─────────
|
||||
// Updating refs in render is safe: they're only read in event handlers / effects,
|
||||
// never during render. This avoids the stale-closure problem without extra effects.
|
||||
// ── Callback refs ─────────────────────────────────────────────────────────────
|
||||
// Updated via effects (after render) so the linter doesn't flag render-phase ref
|
||||
// mutation. No cleanup → no null window. Negligible stale-window between commit
|
||||
// and effect, acceptable since these are only called from async event handlers.
|
||||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||
onPlayStateChangeRef.current = onPlayStateChange;
|
||||
onTimeUpdateRef.current = onTimeUpdate;
|
||||
|
||||
// Stable function refs — logic updated in render, registered once via a stable lambda.
|
||||
// This avoids the "no-deps effect" anti-pattern that created brief null windows on
|
||||
// every re-render (timeupdate fires 4×/s → ref nulled & re-registered each time).
|
||||
// Sync prop callbacks after every render
|
||||
useEffect(() => {
|
||||
onPlayStateChangeRef.current = onPlayStateChange;
|
||||
onTimeUpdateRef.current = onTimeUpdate;
|
||||
});
|
||||
|
||||
// Stable function refs — updated via effects, indirected by the registration
|
||||
// effect below so external seekRef/toggleRef never see a null window.
|
||||
const seekToFnRef = useRef((_t: number) => {});
|
||||
seekToFnRef.current = (time: number) => {
|
||||
setCurrent(time);
|
||||
mediaRef.current!.currentTime = time;
|
||||
};
|
||||
|
||||
const toggleFnRef = useRef(() => {});
|
||||
toggleFnRef.current = () => {
|
||||
const a = mediaRef.current!;
|
||||
if (playing) {
|
||||
a.pause();
|
||||
setPlaying(false);
|
||||
onPlayStateChangeRef.current?.(false);
|
||||
} else {
|
||||
a.play()
|
||||
.then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); })
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// seekTo: all captured values are stable (setCurrent from useState, mediaRef DOM ref)
|
||||
useEffect(() => {
|
||||
seekToFnRef.current = (time: number) => {
|
||||
setCurrent(time);
|
||||
mediaRef.current!.currentTime = time;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// toggle: captures `playing` — re-synced whenever play state changes
|
||||
useEffect(() => {
|
||||
toggleFnRef.current = () => {
|
||||
const a = mediaRef.current!;
|
||||
if (playing) {
|
||||
a.pause();
|
||||
setPlaying(false);
|
||||
onPlayStateChangeRef.current?.(false);
|
||||
} else {
|
||||
a.play()
|
||||
.then(() => {
|
||||
setPlaying(true);
|
||||
onPlayStateChangeRef.current?.(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [playing]);
|
||||
|
||||
// Stable wrappers used everywhere inside the component
|
||||
const seekTo = (time: number) => seekToFnRef.current(time);
|
||||
@@ -182,10 +211,12 @@ export function MediaPlayer(
|
||||
useEffect(() => {
|
||||
if (!autoplay) return;
|
||||
mediaRef.current?.play()
|
||||
.then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); })
|
||||
.then(() => {
|
||||
setPlaying(true);
|
||||
onPlayStateChangeRef.current?.(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [autoplay]);
|
||||
|
||||
// On unmount: pause and cut callbacks so stale timeupdate/ended events that fire
|
||||
// between React's commit and the listener-removal effect can't reach the provider.
|
||||
@@ -196,7 +227,6 @@ export function MediaPlayer(
|
||||
onPlayStateChangeRef.current = undefined;
|
||||
onTimeUpdateRef.current = undefined;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Register imperative handles into provider refs. seekRef/toggleRef are stable
|
||||
@@ -208,7 +238,6 @@ export function MediaPlayer(
|
||||
if (seekRef) seekRef.current = null;
|
||||
if (toggleRef) toggleRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seekRef, toggleRef]);
|
||||
|
||||
// Media element event listeners
|
||||
@@ -243,9 +272,14 @@ export function MediaPlayer(
|
||||
if (kind !== "video") return;
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
if (playing) {
|
||||
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
|
||||
hideTimer.current = setTimeout(
|
||||
() => setControlsVisible(false),
|
||||
HIDE_DELAY,
|
||||
);
|
||||
}
|
||||
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
|
||||
return () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, [playing, kind]);
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────────
|
||||
@@ -257,11 +291,15 @@ export function MediaPlayer(
|
||||
setControlsVisible(true);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
if (playing) {
|
||||
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
|
||||
hideTimer.current = setTimeout(
|
||||
() => setControlsVisible(false),
|
||||
HIDE_DELAY,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const seek = (e: React.ChangeEvent<HTMLInputElement>) => seekTo(Number(e.target.value));
|
||||
const seek = (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
seekTo(Number(e.target.value));
|
||||
|
||||
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = Number(e.target.value);
|
||||
@@ -286,10 +324,20 @@ export function MediaPlayer(
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
|
||||
const track = kind === "audio"
|
||||
? <Waveform src={src} current={current} duration={duration} onSeek={seekTo} />
|
||||
? (
|
||||
<Waveform
|
||||
src={src}
|
||||
current={current}
|
||||
duration={duration}
|
||||
onSeek={seekTo}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="audio-player-track">
|
||||
<div className="audio-player-fill" style={{ width: `${progress * 100}%` }} />
|
||||
<div
|
||||
className="audio-player-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="audio-player-range"
|
||||
@@ -365,7 +413,9 @@ export function MediaPlayer(
|
||||
if (kind === "video") {
|
||||
return (
|
||||
<div
|
||||
className={`video-player${showingControls ? " video-player--controls-visible" : ""}`}
|
||||
className={`video-player${
|
||||
showingControls ? " video-player--controls-visible" : ""
|
||||
}`}
|
||||
onMouseMove={showControlsTemporarily}
|
||||
onMouseLeave={() => playing && setControlsVisible(false)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { Playlist } from "../model.ts";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||
@@ -12,7 +14,7 @@ interface NewPlaylistFormProps {
|
||||
export function NewPlaylistForm(
|
||||
{
|
||||
onCreated,
|
||||
toggleLabel = "+ New playlist",
|
||||
toggleLabel,
|
||||
toggleClassName = "new-playlist-toggle",
|
||||
}: NewPlaylistFormProps,
|
||||
) {
|
||||
@@ -25,11 +27,11 @@ export function NewPlaylistForm(
|
||||
className={toggleClassName}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{toggleLabel}
|
||||
{toggleLabel ?? <Trans>+ New playlist</Trans>}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<Modal title="New playlist" onClose={() => setOpen(false)}>
|
||||
<Modal title={t`New playlist`} onClose={() => setOpen(false)}>
|
||||
<PlaylistCreateForm
|
||||
onCreated={(playlist) => {
|
||||
onCreated(playlist);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Playlist } from "../model.ts";
|
||||
@@ -68,7 +68,9 @@ export function PlaylistCard(
|
||||
playlist.isPublic ? "" : " playlist-badge--private"
|
||||
}`}
|
||||
>
|
||||
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>}
|
||||
{playlist.isPublic
|
||||
? <Trans>public</Trans>
|
||||
: <Trans>private</Trans>}
|
||||
</span>
|
||||
{playlist.ownerUsername && !isOwner && (
|
||||
<Link
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { CountedInput } from "./CountedInput.tsx";
|
||||
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist, parseAPIResponse } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
@@ -27,7 +28,9 @@ export function PlaylistCreateForm(
|
||||
|
||||
const handleSubmit = async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
if (
|
||||
!title.trim() || description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
|
||||
) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -64,19 +67,21 @@ export function PlaylistCreateForm(
|
||||
|
||||
return (
|
||||
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
|
||||
<input
|
||||
<CountedInput
|
||||
type="text"
|
||||
placeholder={t`Title`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||
/>
|
||||
<TextEditor
|
||||
placeholder={t`Description (optional)`}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
rows={3}
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
/>
|
||||
<div className="visibility-toggle">
|
||||
<button
|
||||
@@ -94,7 +99,9 @@ export function PlaylistCreateForm(
|
||||
<Trans>Private</Trans>
|
||||
</button>
|
||||
</div>
|
||||
{error && <ErrorCard title={t`Failed to create playlist`} message={error} />}
|
||||
{error && (
|
||||
<ErrorCard title={t`Failed to create playlist`} message={error} />
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<div className="form-actions-right">
|
||||
<button
|
||||
@@ -107,7 +114,8 @@ export function PlaylistCreateForm(
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={submitting}
|
||||
disabled={submitting ||
|
||||
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
>
|
||||
{submitting
|
||||
? <Trans>Creating…</Trans>
|
||||
|
||||
@@ -23,9 +23,17 @@ export function PlaylistMembershipPanel({
|
||||
return (
|
||||
<>
|
||||
{loading
|
||||
? <p className="page-loading"><Trans>Loading…</Trans></p>
|
||||
? (
|
||||
<p className="page-loading">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
)
|
||||
: memberships.length === 0 && !showNewForm
|
||||
? <p className="empty-state"><Trans>No playlists yet.</Trans></p>
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>No playlists yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<ul className="playlist-membership-list">
|
||||
{memberships.map((m) => (
|
||||
|
||||
@@ -10,9 +10,50 @@ interface RichContentCardProps {
|
||||
export default function RichContentCard(
|
||||
{ richContent, compact = false }: RichContentCardProps,
|
||||
) {
|
||||
const { play } = useContext(PlayerContext);
|
||||
const { play, current, playing } = useContext(PlayerContext);
|
||||
|
||||
if (compact) {
|
||||
if (richContent.embedUrl) {
|
||||
const isActive = current?.kind === "embed" &&
|
||||
current.embedUrl === richContent.embedUrl;
|
||||
const isPlaying = isActive && playing;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rich-content-thumbnail-btn${
|
||||
isActive ? " is-playing" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play({
|
||||
kind: "embed",
|
||||
embedUrl: richContent.embedUrl!,
|
||||
title: richContent.title,
|
||||
type: richContent.type,
|
||||
});
|
||||
}}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{richContent.thumbnailUrl
|
||||
? (
|
||||
<img
|
||||
src={richContent.thumbnailUrl}
|
||||
alt={richContent.title ?? ""}
|
||||
className="rich-content-compact-thumbnail"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: <span className="rich-content-compact-icon">▶</span>}
|
||||
<span className="rich-content-play-overlay">
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={richContent.url}
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { EmojiPicker } from "frimousse";
|
||||
import { EmojiPicker, type Locale as EmojiLocale } from "frimousse";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { charCountClass } from "../utils/charCount.ts";
|
||||
import { MentionDropdown } from "./MentionDropdown.tsx";
|
||||
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
|
||||
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
|
||||
@@ -29,6 +31,7 @@ interface TextEditorProps {
|
||||
autoResize?: boolean;
|
||||
onSubmit?: () => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
@@ -44,9 +47,11 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
autoResize = false,
|
||||
onSubmit,
|
||||
onKeyDown,
|
||||
maxLength,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { i18n } = useLingui();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const emojiViewportRef = useRef<HTMLDivElement>(null);
|
||||
const emojiSearchRef = useRef<HTMLInputElement>(null);
|
||||
@@ -205,6 +210,15 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
id={id}
|
||||
className={className}
|
||||
/>
|
||||
{maxLength != null && (
|
||||
<span
|
||||
className={`text-editor-count${
|
||||
charCountClass(value.length, maxLength)
|
||||
}`}
|
||||
>
|
||||
{value.length} / {maxLength}
|
||||
</span>
|
||||
)}
|
||||
{mentionOpen && (
|
||||
<MentionDropdown
|
||||
results={mentionResults}
|
||||
@@ -248,6 +262,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
>
|
||||
<EmojiPicker.Root
|
||||
onEmojiSelect={(e) => handleEmojiSelect(e.emoji)}
|
||||
locale={i18n.locale as EmojiLocale}
|
||||
>
|
||||
<div className="emoji-picker-search-row">
|
||||
<EmojiPicker.Search
|
||||
@@ -270,8 +285,12 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
// frimousse's onFocusCapture can detect it and arm arrow-key nav
|
||||
tabIndex={-1}
|
||||
>
|
||||
<EmojiPicker.Loading><Trans>Loading…</Trans></EmojiPicker.Loading>
|
||||
<EmojiPicker.Empty><Trans>No emoji found.</Trans></EmojiPicker.Empty>
|
||||
<EmojiPicker.Loading>
|
||||
<Trans>Loading…</Trans>
|
||||
</EmojiPicker.Loading>
|
||||
<EmojiPicker.Empty>
|
||||
<Trans>No emoji found.</Trans>
|
||||
</EmojiPicker.Empty>
|
||||
<EmojiPicker.List />
|
||||
</EmojiPicker.Viewport>
|
||||
</EmojiPicker.Root>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import type { User } from "../model.ts";
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type RefObject, useCallback, useRef, useState } from "react";
|
||||
|
||||
// Trigger: ':' not preceded by a word character, followed by 1+ word chars
|
||||
const TRIGGER_RE = /(?<![A-Za-z0-9_]):([A-Za-z0-9_+-]{1,})$/;
|
||||
// Trigger: ':' not preceded by a word character, followed by a letter then
|
||||
// optional word chars / '+' / '-'. Requiring a letter first prevents ASCII
|
||||
// emoticons like ':-' or ':(' from opening the picker.
|
||||
const TRIGGER_RE = /(?<![A-Za-z0-9_]):([A-Za-z][A-Za-z0-9_+-]*)$/;
|
||||
|
||||
interface EmojiTriggerState {
|
||||
open: boolean;
|
||||
|
||||
@@ -5,7 +5,9 @@ type Locale = (typeof SUPPORTED)[number];
|
||||
|
||||
function detectLocale(): Locale {
|
||||
const stored = localStorage.getItem("locale");
|
||||
if (stored && (SUPPORTED as readonly string[]).includes(stored)) return stored as Locale;
|
||||
if (stored && (SUPPORTED as readonly string[]).includes(stored)) {
|
||||
return stored as Locale;
|
||||
}
|
||||
return navigator.language.startsWith("fr") ? "fr" : "en";
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -66,7 +66,7 @@ msgstr "← Back to all dumps"
|
||||
|
||||
#: src/pages/UserDumps.tsx:61
|
||||
#: src/pages/UserPlaylists.tsx:352
|
||||
#: src/pages/UserUpvoted.tsx:130
|
||||
#: src/pages/UserUpvoted.tsx:133
|
||||
msgid "← Back to profile"
|
||||
msgstr "← Back to profile"
|
||||
|
||||
@@ -74,7 +74,7 @@ msgstr "← Back to profile"
|
||||
msgid "+ Invite someone"
|
||||
msgstr "+ Invite someone"
|
||||
|
||||
#: src/components/AppHeader.tsx:63
|
||||
#: src/components/AppHeader.tsx:67
|
||||
msgid "+ New"
|
||||
msgstr "+ New"
|
||||
|
||||
@@ -83,6 +83,7 @@ msgstr "+ New"
|
||||
msgid "+ New dump"
|
||||
msgstr "+ New dump"
|
||||
|
||||
#: src/components/NewPlaylistForm.tsx:30
|
||||
#: src/components/PlaylistMembershipPanel.tsx:72
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ New playlist"
|
||||
@@ -146,7 +147,7 @@ msgid "Add email…"
|
||||
msgstr "Add email…"
|
||||
|
||||
#: src/components/AddToPlaylistModal.tsx:64
|
||||
#: src/components/DumpCreateModal.tsx:262
|
||||
#: src/components/DumpCreateModal.tsx:275
|
||||
msgid "Add to playlist"
|
||||
msgstr "Add to playlist"
|
||||
|
||||
@@ -160,7 +161,7 @@ msgid "All {0, plural, one {# dump} other {# dumps}} loaded."
|
||||
msgstr "All {0, plural, one {# dump} other {# dumps}} loaded."
|
||||
|
||||
#. placeholder {0}: votes.length
|
||||
#: src/pages/UserUpvoted.tsx:184
|
||||
#: src/pages/UserUpvoted.tsx:187
|
||||
msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
|
||||
msgstr "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
|
||||
|
||||
@@ -177,7 +178,7 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
|
||||
#: src/components/CommentThread.tsx:353
|
||||
#: src/components/CommentThread.tsx:483
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/DumpCreateModal.tsx:394
|
||||
#: src/components/DumpCreateModal.tsx:408
|
||||
#: src/components/PlaylistCreateForm.tsx:105
|
||||
#: src/pages/DumpEdit.tsx:288
|
||||
#: src/pages/PlaylistDetail.tsx:672
|
||||
@@ -278,7 +279,7 @@ msgstr "Delete this playlist? This cannot be undone."
|
||||
msgid "Description (optional)"
|
||||
msgstr "Description (optional)"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:439
|
||||
#: src/components/DumpCreateModal.tsx:453
|
||||
msgid "Done"
|
||||
msgstr "Done"
|
||||
|
||||
@@ -290,7 +291,7 @@ msgstr "Drop a file here"
|
||||
msgid "Drop a replacement here"
|
||||
msgstr "Drop a replacement here"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:405
|
||||
#: src/components/DumpCreateModal.tsx:419
|
||||
msgid "Dump it"
|
||||
msgstr "Dump it"
|
||||
|
||||
@@ -298,7 +299,7 @@ msgstr "Dump it"
|
||||
#~ msgid "Dump not found"
|
||||
#~ msgstr "Dump not found"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:416
|
||||
#: src/components/DumpCreateModal.tsx:430
|
||||
msgid "Dumped!"
|
||||
msgstr "Dumped!"
|
||||
|
||||
@@ -372,7 +373,7 @@ msgstr "Failed to generate invite"
|
||||
msgid "Failed to load"
|
||||
msgstr "Failed to load"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:300
|
||||
#: src/components/DumpCreateModal.tsx:313
|
||||
msgid "Failed to post"
|
||||
msgstr "Failed to post"
|
||||
|
||||
@@ -400,15 +401,15 @@ msgstr "Failed to save edit"
|
||||
msgid "Failed to update avatar"
|
||||
msgstr "Failed to update avatar"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:333
|
||||
#: src/components/DumpCreateModal.tsx:347
|
||||
msgid "Fetching preview…"
|
||||
msgstr "Fetching preview…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:403
|
||||
#: src/components/DumpCreateModal.tsx:417
|
||||
msgid "Fetching…"
|
||||
msgstr "Fetching…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:293
|
||||
#: src/components/DumpCreateModal.tsx:306
|
||||
#: src/components/FileDropZone.tsx:31
|
||||
msgid "File"
|
||||
msgstr "File"
|
||||
@@ -425,7 +426,7 @@ msgstr "File"
|
||||
#~ msgid "File too large (max 50 MB)"
|
||||
#~ msgstr "File too large (max 50 MB)"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:187
|
||||
#: src/components/DumpCreateModal.tsx:200
|
||||
msgid "File too large (max 50 MB)."
|
||||
msgstr "File too large (max 50 MB)."
|
||||
|
||||
@@ -442,11 +443,11 @@ msgstr "Follow {targetUsername}"
|
||||
msgid "Follow playlist"
|
||||
msgstr "Follow playlist"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:359
|
||||
#: src/pages/index/FollowedFeed.tsx:358
|
||||
msgid "Follow some public playlists to see their dumps here."
|
||||
msgstr "Follow some public playlists to see their dumps here."
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:345
|
||||
#: src/pages/index/FollowedFeed.tsx:344
|
||||
msgid "Follow some users to see their dumps here."
|
||||
msgstr "Follow some users to see their dumps here."
|
||||
|
||||
@@ -469,11 +470,11 @@ msgstr "Following"
|
||||
#~ msgid "Forbidden"
|
||||
#~ msgstr "Forbidden"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:325
|
||||
#: src/pages/index/FollowedFeed.tsx:324
|
||||
msgid "From people"
|
||||
msgstr "From people"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:332
|
||||
#: src/pages/index/FollowedFeed.tsx:331
|
||||
msgid "From playlists"
|
||||
msgstr "From playlists"
|
||||
|
||||
@@ -522,7 +523,7 @@ msgstr "just now"
|
||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
|
||||
#: src/components/AppHeader.tsx:79
|
||||
#: src/components/AppHeader.tsx:83
|
||||
msgid "Live updates unavailable."
|
||||
msgstr "Live updates unavailable."
|
||||
|
||||
@@ -543,7 +544,7 @@ msgstr "Loading dump…"
|
||||
#: src/pages/UserDumps.tsx:111
|
||||
#: src/pages/UserPlaylists.tsx:409
|
||||
#: src/pages/UserPlaylists.tsx:436
|
||||
#: src/pages/UserUpvoted.tsx:180
|
||||
#: src/pages/UserUpvoted.tsx:183
|
||||
msgid "Loading more…"
|
||||
msgstr "Loading more…"
|
||||
|
||||
@@ -565,11 +566,11 @@ msgstr "Loading profile…"
|
||||
#: src/pages/Notifications.tsx:386
|
||||
#: src/pages/UserDumps.tsx:50
|
||||
#: src/pages/UserPlaylists.tsx:341
|
||||
#: src/pages/UserUpvoted.tsx:119
|
||||
#: src/pages/UserUpvoted.tsx:122
|
||||
msgid "Loading…"
|
||||
msgstr "Loading…"
|
||||
|
||||
#: src/components/AppHeader.tsx:70
|
||||
#: src/components/AppHeader.tsx:74
|
||||
#: src/pages/UserLogin.tsx:62
|
||||
#: src/pages/UserLogin.tsx:91
|
||||
msgid "Log in"
|
||||
@@ -600,10 +601,14 @@ msgstr "new"
|
||||
msgid "New"
|
||||
msgstr "New"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:262
|
||||
#: src/components/DumpCreateModal.tsx:275
|
||||
msgid "New dump"
|
||||
msgstr "New dump"
|
||||
|
||||
#: src/components/NewPlaylistForm.tsx:34
|
||||
msgid "New playlist"
|
||||
msgstr "New playlist"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:783
|
||||
msgid "No dumps in this playlist yet."
|
||||
msgstr "No dumps in this playlist yet."
|
||||
@@ -647,8 +652,8 @@ msgstr "No users match \"{q}\"."
|
||||
#: src/pages/Notifications.tsx:327
|
||||
#: src/pages/UserDumps.tsx:92
|
||||
#: src/pages/UserPublicProfile.tsx:930
|
||||
#: src/pages/UserPublicProfile.tsx:1049
|
||||
#: src/pages/UserUpvoted.tsx:151
|
||||
#: src/pages/UserPublicProfile.tsx:1047
|
||||
#: src/pages/UserUpvoted.tsx:154
|
||||
msgid "Nothing here yet."
|
||||
msgstr "Nothing here yet."
|
||||
|
||||
@@ -690,7 +695,7 @@ msgstr "Password (min. {0} characters)"
|
||||
#~ msgid "Playlist not found"
|
||||
#~ msgstr "Playlist not found"
|
||||
|
||||
#: src/components/AppHeader.tsx:46
|
||||
#: src/components/AppHeader.tsx:50
|
||||
#: src/components/UserMenu.tsx:62
|
||||
#: src/pages/Search.tsx:175
|
||||
#: src/pages/UserPlaylists.tsx:366
|
||||
@@ -703,7 +708,7 @@ msgstr "Playlists"
|
||||
msgid "Playlists ({0}{1})"
|
||||
msgstr "Playlists ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:180
|
||||
#: src/components/DumpCreateModal.tsx:193
|
||||
msgid "Please select a file."
|
||||
msgstr "Please select a file."
|
||||
|
||||
@@ -728,7 +733,7 @@ msgstr "Posting…"
|
||||
msgid "private"
|
||||
msgstr "private"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:383
|
||||
#: src/components/DumpCreateModal.tsx:397
|
||||
#: src/components/PlaylistCreateForm.tsx:94
|
||||
#: src/pages/DumpEdit.tsx:274
|
||||
#: src/pages/PlaylistDetail.tsx:737
|
||||
@@ -740,7 +745,7 @@ msgstr "Private"
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:375
|
||||
#: src/components/DumpCreateModal.tsx:389
|
||||
#: src/components/PlaylistCreateForm.tsx:87
|
||||
#: src/pages/DumpEdit.tsx:267
|
||||
#: src/pages/PlaylistDetail.tsx:730
|
||||
@@ -820,7 +825,7 @@ msgstr "Search failed"
|
||||
msgid "Searching…"
|
||||
msgstr "Searching…"
|
||||
|
||||
#: src/components/AppHeader.tsx:61
|
||||
#: src/components/AppHeader.tsx:65
|
||||
msgid "Server unreachable"
|
||||
msgstr "Server unreachable"
|
||||
|
||||
@@ -836,7 +841,7 @@ msgstr "Submit search"
|
||||
msgid "Tell people about yourself…"
|
||||
msgstr "Tell people about yourself…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:363
|
||||
#: src/components/DumpCreateModal.tsx:377
|
||||
#: src/pages/DumpEdit.tsx:256
|
||||
msgid "Tell the community what makes this worth their time..."
|
||||
msgstr "Tell the community what makes this worth their time..."
|
||||
@@ -877,11 +882,11 @@ msgstr "Unfollow playlist"
|
||||
msgid "Upload failed"
|
||||
msgstr "Upload failed"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:404
|
||||
#: src/components/DumpCreateModal.tsx:418
|
||||
msgid "Uploading…"
|
||||
msgstr "Uploading…"
|
||||
|
||||
#: src/pages/UserUpvoted.tsx:147
|
||||
#: src/pages/UserUpvoted.tsx:150
|
||||
msgid "Upvoted"
|
||||
msgstr "Upvoted"
|
||||
|
||||
@@ -891,12 +896,12 @@ msgstr "Upvoted"
|
||||
msgid "Upvoted ({0}{1})"
|
||||
msgstr "Upvoted ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:309
|
||||
#: src/components/DumpCreateModal.tsx:322
|
||||
#: src/pages/DumpEdit.tsx:221
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:164
|
||||
#: src/components/DumpCreateModal.tsx:176
|
||||
msgid "URL is required."
|
||||
msgstr "URL is required."
|
||||
|
||||
@@ -923,15 +928,15 @@ msgstr "Users"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:878
|
||||
#: src/pages/UserPublicProfile.tsx:948
|
||||
#: src/pages/UserPublicProfile.tsx:1076
|
||||
#: src/pages/UserPublicProfile.tsx:1074
|
||||
msgid "View all →"
|
||||
msgstr "View all →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:418
|
||||
#: src/components/DumpCreateModal.tsx:432
|
||||
msgid "View dump →"
|
||||
msgstr "View dump →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:356
|
||||
#: src/components/DumpCreateModal.tsx:370
|
||||
#: src/pages/DumpEdit.tsx:250
|
||||
msgid "Why are you dumping this?"
|
||||
msgstr "Why are you dumping this?"
|
||||
|
||||
@@ -7,6 +7,11 @@ msgstr ""
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: fr\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
|
||||
#: src/components/CommentThread.tsx:170
|
||||
msgid "[deleted]"
|
||||
@@ -61,7 +66,7 @@ msgstr "← Retour à toutes les recos"
|
||||
|
||||
#: src/pages/UserDumps.tsx:61
|
||||
#: src/pages/UserPlaylists.tsx:352
|
||||
#: src/pages/UserUpvoted.tsx:130
|
||||
#: src/pages/UserUpvoted.tsx:133
|
||||
msgid "← Back to profile"
|
||||
msgstr "← Retour au profil"
|
||||
|
||||
@@ -69,7 +74,7 @@ msgstr "← Retour au profil"
|
||||
msgid "+ Invite someone"
|
||||
msgstr "+ Inviter quelqu'un"
|
||||
|
||||
#: src/components/AppHeader.tsx:63
|
||||
#: src/components/AppHeader.tsx:67
|
||||
msgid "+ New"
|
||||
msgstr "+ Nouveau"
|
||||
|
||||
@@ -78,6 +83,7 @@ msgstr "+ Nouveau"
|
||||
msgid "+ New dump"
|
||||
msgstr "+ Nouvelle reco"
|
||||
|
||||
#: src/components/NewPlaylistForm.tsx:30
|
||||
#: src/components/PlaylistMembershipPanel.tsx:72
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ Nouvelle collection"
|
||||
@@ -141,7 +147,7 @@ msgid "Add email…"
|
||||
msgstr "Ajouter un e-mail…"
|
||||
|
||||
#: src/components/AddToPlaylistModal.tsx:64
|
||||
#: src/components/DumpCreateModal.tsx:262
|
||||
#: src/components/DumpCreateModal.tsx:275
|
||||
msgid "Add to playlist"
|
||||
msgstr "Ajouter à la collection"
|
||||
|
||||
@@ -151,7 +157,7 @@ msgid "All {0, plural, one {# dump} other {# dumps}} loaded."
|
||||
msgstr "Toutes les {0, plural, one {# reco} other {# recos}} chargées."
|
||||
|
||||
#. placeholder {0}: votes.length
|
||||
#: src/pages/UserUpvoted.tsx:184
|
||||
#: src/pages/UserUpvoted.tsx:187
|
||||
msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
|
||||
msgstr "Toutes les {0, plural, one {# reco votée} other {# recos votées}} chargées."
|
||||
|
||||
@@ -168,7 +174,7 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
|
||||
#: src/components/CommentThread.tsx:353
|
||||
#: src/components/CommentThread.tsx:483
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/DumpCreateModal.tsx:394
|
||||
#: src/components/DumpCreateModal.tsx:408
|
||||
#: src/components/PlaylistCreateForm.tsx:105
|
||||
#: src/pages/DumpEdit.tsx:288
|
||||
#: src/pages/PlaylistDetail.tsx:672
|
||||
@@ -261,7 +267,7 @@ msgstr "Supprimer cette collection ? Cette action est irréversible."
|
||||
msgid "Description (optional)"
|
||||
msgstr "Description (facultatif)"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:439
|
||||
#: src/components/DumpCreateModal.tsx:453
|
||||
msgid "Done"
|
||||
msgstr "Terminé"
|
||||
|
||||
@@ -273,11 +279,11 @@ msgstr "Déposez un fichier ici"
|
||||
msgid "Drop a replacement here"
|
||||
msgstr "Déposez un fichier de remplacement ici"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:405
|
||||
#: src/components/DumpCreateModal.tsx:419
|
||||
msgid "Dump it"
|
||||
msgstr "Recommander"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:416
|
||||
#: src/components/DumpCreateModal.tsx:430
|
||||
msgid "Dumped!"
|
||||
msgstr "Recommandé !"
|
||||
|
||||
@@ -351,7 +357,7 @@ msgstr "Impossible de générer une invitation"
|
||||
msgid "Failed to load"
|
||||
msgstr "Chargement échoué"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:300
|
||||
#: src/components/DumpCreateModal.tsx:313
|
||||
msgid "Failed to post"
|
||||
msgstr "Publication échouée"
|
||||
|
||||
@@ -379,20 +385,20 @@ msgstr "Impossible d'enregistrer la modification"
|
||||
msgid "Failed to update avatar"
|
||||
msgstr "Impossible de mettre à jour l'avatar"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:333
|
||||
#: src/components/DumpCreateModal.tsx:347
|
||||
msgid "Fetching preview…"
|
||||
msgstr "Récupération de l'aperçu…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:403
|
||||
#: src/components/DumpCreateModal.tsx:417
|
||||
msgid "Fetching…"
|
||||
msgstr "Récupération…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:293
|
||||
#: src/components/DumpCreateModal.tsx:306
|
||||
#: src/components/FileDropZone.tsx:31
|
||||
msgid "File"
|
||||
msgstr "Fichier"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:187
|
||||
#: src/components/DumpCreateModal.tsx:200
|
||||
msgid "File too large (max 50 MB)."
|
||||
msgstr "Fichier trop volumineux (max 50 Mo)."
|
||||
|
||||
@@ -409,11 +415,11 @@ msgstr "Suivre {targetUsername}"
|
||||
msgid "Follow playlist"
|
||||
msgstr "Suivre la collection"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:359
|
||||
#: src/pages/index/FollowedFeed.tsx:358
|
||||
msgid "Follow some public playlists to see their dumps here."
|
||||
msgstr "Suivez des collections publiques pour voir leurs recos ici."
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:345
|
||||
#: src/pages/index/FollowedFeed.tsx:344
|
||||
msgid "Follow some users to see their dumps here."
|
||||
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
||||
|
||||
@@ -432,11 +438,11 @@ msgstr "Suivies ({0}{1})"
|
||||
msgid "Following"
|
||||
msgstr "Abonné"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:325
|
||||
#: src/pages/index/FollowedFeed.tsx:324
|
||||
msgid "From people"
|
||||
msgstr "De personnes"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:332
|
||||
#: src/pages/index/FollowedFeed.tsx:331
|
||||
msgid "From playlists"
|
||||
msgstr "De collections"
|
||||
|
||||
@@ -464,7 +470,7 @@ msgstr "à l'instant"
|
||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||
|
||||
#: src/components/AppHeader.tsx:79
|
||||
#: src/components/AppHeader.tsx:83
|
||||
msgid "Live updates unavailable."
|
||||
msgstr "Mises à jour en direct indisponibles."
|
||||
|
||||
@@ -485,7 +491,7 @@ msgstr "Chargement de la reco…"
|
||||
#: src/pages/UserDumps.tsx:111
|
||||
#: src/pages/UserPlaylists.tsx:409
|
||||
#: src/pages/UserPlaylists.tsx:436
|
||||
#: src/pages/UserUpvoted.tsx:180
|
||||
#: src/pages/UserUpvoted.tsx:183
|
||||
msgid "Loading more…"
|
||||
msgstr "Chargement…"
|
||||
|
||||
@@ -507,11 +513,11 @@ msgstr "Chargement du profil…"
|
||||
#: src/pages/Notifications.tsx:386
|
||||
#: src/pages/UserDumps.tsx:50
|
||||
#: src/pages/UserPlaylists.tsx:341
|
||||
#: src/pages/UserUpvoted.tsx:119
|
||||
#: src/pages/UserUpvoted.tsx:122
|
||||
msgid "Loading…"
|
||||
msgstr "Chargement…"
|
||||
|
||||
#: src/components/AppHeader.tsx:70
|
||||
#: src/components/AppHeader.tsx:74
|
||||
#: src/pages/UserLogin.tsx:62
|
||||
#: src/pages/UserLogin.tsx:91
|
||||
msgid "Log in"
|
||||
@@ -542,10 +548,14 @@ msgstr "nouveau"
|
||||
msgid "New"
|
||||
msgstr "Nouveau"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:262
|
||||
#: src/components/DumpCreateModal.tsx:275
|
||||
msgid "New dump"
|
||||
msgstr "Nouvelle reco"
|
||||
|
||||
#: src/components/NewPlaylistForm.tsx:34
|
||||
msgid "New playlist"
|
||||
msgstr "Nouvelle collection"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:783
|
||||
msgid "No dumps in this playlist yet."
|
||||
msgstr "Aucune reco dans cette collection pour l'instant."
|
||||
@@ -585,8 +595,8 @@ msgstr "Aucun utilisateur ne correspond à « {q} »."
|
||||
#: src/pages/Notifications.tsx:327
|
||||
#: src/pages/UserDumps.tsx:92
|
||||
#: src/pages/UserPublicProfile.tsx:930
|
||||
#: src/pages/UserPublicProfile.tsx:1049
|
||||
#: src/pages/UserUpvoted.tsx:151
|
||||
#: src/pages/UserPublicProfile.tsx:1047
|
||||
#: src/pages/UserUpvoted.tsx:154
|
||||
msgid "Nothing here yet."
|
||||
msgstr "Rien ici pour l'instant."
|
||||
|
||||
@@ -616,7 +626,7 @@ msgstr "Mot de passe"
|
||||
msgid "Password (min. {0} characters)"
|
||||
msgstr "Mot de passe (min. {0} caractères)"
|
||||
|
||||
#: src/components/AppHeader.tsx:46
|
||||
#: src/components/AppHeader.tsx:50
|
||||
#: src/components/UserMenu.tsx:62
|
||||
#: src/pages/Search.tsx:175
|
||||
#: src/pages/UserPlaylists.tsx:366
|
||||
@@ -629,7 +639,7 @@ msgstr "Collections"
|
||||
msgid "Playlists ({0}{1})"
|
||||
msgstr "Collections ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:180
|
||||
#: src/components/DumpCreateModal.tsx:193
|
||||
msgid "Please select a file."
|
||||
msgstr "Veuillez sélectionner un fichier."
|
||||
|
||||
@@ -654,7 +664,7 @@ msgstr "Publication…"
|
||||
msgid "private"
|
||||
msgstr "privé"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:383
|
||||
#: src/components/DumpCreateModal.tsx:397
|
||||
#: src/components/PlaylistCreateForm.tsx:94
|
||||
#: src/pages/DumpEdit.tsx:274
|
||||
#: src/pages/PlaylistDetail.tsx:737
|
||||
@@ -666,7 +676,7 @@ msgstr "Privé"
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:375
|
||||
#: src/components/DumpCreateModal.tsx:389
|
||||
#: src/components/PlaylistCreateForm.tsx:87
|
||||
#: src/pages/DumpEdit.tsx:267
|
||||
#: src/pages/PlaylistDetail.tsx:730
|
||||
@@ -746,7 +756,7 @@ msgstr "Recherche échouée"
|
||||
msgid "Searching…"
|
||||
msgstr "Recherche…"
|
||||
|
||||
#: src/components/AppHeader.tsx:61
|
||||
#: src/components/AppHeader.tsx:65
|
||||
msgid "Server unreachable"
|
||||
msgstr "Serveur inaccessible"
|
||||
|
||||
@@ -762,7 +772,7 @@ msgstr "Lancer la recherche"
|
||||
msgid "Tell people about yourself…"
|
||||
msgstr "Parlez de vous…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:363
|
||||
#: src/components/DumpCreateModal.tsx:377
|
||||
#: src/pages/DumpEdit.tsx:256
|
||||
msgid "Tell the community what makes this worth their time..."
|
||||
msgstr "Dites à la communauté pourquoi ça vaut le coup…"
|
||||
@@ -799,11 +809,11 @@ msgstr "Ne plus suivre la collection"
|
||||
msgid "Upload failed"
|
||||
msgstr "Envoi échoué"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:404
|
||||
#: src/components/DumpCreateModal.tsx:418
|
||||
msgid "Uploading…"
|
||||
msgstr "Envoi…"
|
||||
|
||||
#: src/pages/UserUpvoted.tsx:147
|
||||
#: src/pages/UserUpvoted.tsx:150
|
||||
msgid "Upvoted"
|
||||
msgstr "Voté"
|
||||
|
||||
@@ -813,12 +823,12 @@ msgstr "Voté"
|
||||
msgid "Upvoted ({0}{1})"
|
||||
msgstr "Votés ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:309
|
||||
#: src/components/DumpCreateModal.tsx:322
|
||||
#: src/pages/DumpEdit.tsx:221
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:164
|
||||
#: src/components/DumpCreateModal.tsx:176
|
||||
msgid "URL is required."
|
||||
msgstr "L'URL est obligatoire."
|
||||
|
||||
@@ -837,15 +847,15 @@ msgstr "Utilisateurs"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:878
|
||||
#: src/pages/UserPublicProfile.tsx:948
|
||||
#: src/pages/UserPublicProfile.tsx:1076
|
||||
#: src/pages/UserPublicProfile.tsx:1074
|
||||
msgid "View all →"
|
||||
msgstr "Tout voir →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:418
|
||||
#: src/components/DumpCreateModal.tsx:432
|
||||
msgid "View dump →"
|
||||
msgstr "Voir la reco →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:356
|
||||
#: src/components/DumpCreateModal.tsx:370
|
||||
#: src/pages/DumpEdit.tsx:250
|
||||
msgid "Why are you dumping this?"
|
||||
msgstr "Pourquoi recommandez-vous ça ?"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { dumpUrl } from "../utils/urls.ts";
|
||||
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
|
||||
@@ -190,7 +190,9 @@ export function Dump() {
|
||||
if (dumpState.status === "loading") {
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading"><Trans>Loading dump…</Trans></p>
|
||||
<p className="page-loading">
|
||||
<Trans>Loading dump…</Trans>
|
||||
</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -315,7 +317,9 @@ export function Dump() {
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/"><Trans>← Back to all dumps</Trans></Link>
|
||||
<Link to="/">
|
||||
<Trans>← Back to all dumps</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
|
||||
import { deserializeDump, parseAPIResponse } from "../model.ts";
|
||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||
@@ -65,7 +65,9 @@ export function DumpEdit() {
|
||||
}, [selectedDump, token]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (state.status !== "loaded") return;
|
||||
if (
|
||||
state.status !== "loaded" || comment.length > VALIDATION.DUMP_COMMENT_MAX
|
||||
) return;
|
||||
|
||||
let res: Response;
|
||||
|
||||
@@ -140,7 +142,9 @@ export function DumpEdit() {
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading"><Trans>Loading dump…</Trans></p>
|
||||
<p className="page-loading">
|
||||
<Trans>Loading dump…</Trans>
|
||||
</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -177,7 +181,9 @@ export function DumpEdit() {
|
||||
<PageShell>
|
||||
<div className="form-page form-page--two-col">
|
||||
<div className="form-page-header">
|
||||
<p className="form-page-eyebrow"><Trans>Editing</Trans></p>
|
||||
<p className="form-page-eyebrow">
|
||||
<Trans>Editing</Trans>
|
||||
</p>
|
||||
<h1 className="form-page-title">{dump.title}</h1>
|
||||
</div>
|
||||
|
||||
@@ -203,7 +209,9 @@ export function DumpEdit() {
|
||||
onClick={handleRefreshMetadata}
|
||||
disabled={refreshing}
|
||||
>
|
||||
{refreshing ? <Trans>Refreshing…</Trans> : <Trans>Refresh metadata</Trans>}
|
||||
{refreshing
|
||||
? <Trans>Refreshing…</Trans>
|
||||
: <Trans>Refresh metadata</Trans>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -218,7 +226,9 @@ export function DumpEdit() {
|
||||
{dump.kind === "url"
|
||||
? (
|
||||
<div className="form-group">
|
||||
<label htmlFor="url"><Trans>URL</Trans></label>
|
||||
<label htmlFor="url">
|
||||
<Trans>URL</Trans>
|
||||
</label>
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
@@ -255,6 +265,7 @@ export function DumpEdit() {
|
||||
onChange={setComment}
|
||||
placeholder={t`Tell the community what makes this worth their time...`}
|
||||
rows={3}
|
||||
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -287,7 +298,11 @@ export function DumpEdit() {
|
||||
<Link to={dumpUrl(dump)} className="form-cancel">
|
||||
<Trans>Cancel</Trans>
|
||||
</Link>
|
||||
<button type="submit" className="btn-primary">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={comment.length > VALIDATION.DUMP_COMMENT_MAX}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -83,9 +83,21 @@ export function Index() {
|
||||
);
|
||||
const mainFetchDone = useRef(false);
|
||||
|
||||
const rawTab = new URLSearchParams(location.search).get("tab") ?? "hot";
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const rawTab = searchParams.get("tab") ?? "hot";
|
||||
const tab: FeedTab = VALID_TABS.has(rawTab) ? rawTab as FeedTab : "hot";
|
||||
|
||||
// Web Share Target: Android share sheet navigates to /?share_url=...
|
||||
const shareUrl = searchParams.get("share_url") ??
|
||||
searchParams.get("share_text") ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!shareUrl) return;
|
||||
// Clean share params from the URL so a refresh doesn't re-open the modal
|
||||
const clean = tab !== "hot" ? `?tab=${tab}` : "";
|
||||
globalThis.history.replaceState({}, "", location.pathname + clean);
|
||||
}, [shareUrl, tab, location.pathname]);
|
||||
|
||||
// ── Main feed fetch ──
|
||||
|
||||
useEffect(() => {
|
||||
@@ -241,6 +253,7 @@ export function Index() {
|
||||
</div>
|
||||
}
|
||||
disableNew={dumpsState.status === "error"}
|
||||
initialDumpUrl={shareUrl || undefined}
|
||||
/>
|
||||
|
||||
<div className="index-below-header">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
|
||||
@@ -315,7 +315,9 @@ export function Notifications() {
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && (
|
||||
<p className="page-loading"><Trans>Loading…</Trans></p>
|
||||
<p className="page-loading">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
<ErrorCard title={t`Failed to load`} message={state.error} />
|
||||
@@ -324,7 +326,9 @@ export function Notifications() {
|
||||
{state.status === "loaded" && state.items.length === 0 && (
|
||||
<div className="notifications-empty">
|
||||
<span className="notifications-empty-icon">🔕</span>
|
||||
<p><Trans>Nothing here yet.</Trans></p>
|
||||
<p>
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
<p className="notifications-empty-hint">
|
||||
<Trans>
|
||||
You'll be notified when someone follows your playlists, upvotes
|
||||
@@ -338,7 +342,11 @@ export function Notifications() {
|
||||
groupByDate(state.items).map(({ label, items }) => (
|
||||
<section key={label} className="notif-group">
|
||||
<h2 className="notif-group-label">
|
||||
{label === "Today" ? t`Today` : label === "Yesterday" ? t`Yesterday` : t`Earlier`}
|
||||
{label === "Today"
|
||||
? t`Today`
|
||||
: label === "Yesterday"
|
||||
? t`Yesterday`
|
||||
: t`Earlier`}
|
||||
</h2>
|
||||
<ul className="notification-list">
|
||||
{items.map((n) => (
|
||||
@@ -383,7 +391,9 @@ export function Notifications() {
|
||||
onClick={loadMore}
|
||||
disabled={state.loadingMore}
|
||||
>
|
||||
{state.loadingMore ? <Trans>Loading…</Trans> : <Trans>Load more</Trans>}
|
||||
{state.loadingMore
|
||||
? <Trans>Loading…</Trans>
|
||||
: <Trans>Load more</Trans>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { CountedInput } from "../components/CountedInput.tsx";
|
||||
import type {
|
||||
PlaylistWithDumps,
|
||||
RawDump,
|
||||
@@ -527,7 +528,10 @@ export function PlaylistDetail() {
|
||||
};
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!playlistId || state.status !== "loaded") return;
|
||||
if (
|
||||
!playlistId || state.status !== "loaded" ||
|
||||
editDescription.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
|
||||
) return;
|
||||
setEditSaving(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
@@ -587,7 +591,9 @@ export function PlaylistDetail() {
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading"><Trans>Loading playlist…</Trans></p>
|
||||
<p className="page-loading">
|
||||
<Trans>Loading playlist…</Trans>
|
||||
</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -649,17 +655,19 @@ export function PlaylistDetail() {
|
||||
{editOpen
|
||||
? (
|
||||
<div className="playlist-detail-title-row">
|
||||
<input
|
||||
type="text"
|
||||
<CountedInput
|
||||
className="playlist-edit-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
autoFocus
|
||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={editSaving}
|
||||
disabled={editSaving ||
|
||||
editDescription.length >
|
||||
VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
onClick={handleEditSave}
|
||||
>
|
||||
{editSaving ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
||||
@@ -710,6 +718,7 @@ export function PlaylistDetail() {
|
||||
placeholder={t`Description (optional)`}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
/>
|
||||
)
|
||||
: playlist.description && (
|
||||
@@ -745,7 +754,9 @@ export function PlaylistDetail() {
|
||||
playlist.isPublic ? "" : " playlist-badge--private"
|
||||
}`}
|
||||
>
|
||||
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>}
|
||||
{playlist.isPublic
|
||||
? <Trans>public</Trans>
|
||||
: <Trans>private</Trans>}
|
||||
</span>
|
||||
{playlist.ownerUsername && (
|
||||
<Link
|
||||
@@ -765,7 +776,9 @@ export function PlaylistDetail() {
|
||||
text={t`Edited ${playlist.updatedAt.toLocaleString()}`}
|
||||
>
|
||||
<span className="playlist-edited-label">
|
||||
<Trans>edited {relativeTime(playlist.updatedAt)}</Trans>
|
||||
<Trans>
|
||||
edited {relativeTime(playlist.updatedAt)}
|
||||
</Trans>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -780,13 +793,15 @@ export function PlaylistDetail() {
|
||||
</div>
|
||||
|
||||
{visibleDumps.length === 0
|
||||
? <p className="empty-state"><Trans>No dumps in this playlist yet.</Trans></p>
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>No dumps in this playlist yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className="playlist-dump-list"
|
||||
onDragOver={isOwner
|
||||
? (e) => e.preventDefault()
|
||||
: undefined}
|
||||
onDragOver={isOwner ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
{visibleDumps.map((dump) => {
|
||||
const isActive = activeDumpIds.has(dump.id);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AppHeader } from "../components/AppHeader.tsx";
|
||||
import { SearchBar } from "../components/SearchBar.tsx";
|
||||
@@ -203,11 +203,15 @@ export function Search() {
|
||||
)}
|
||||
|
||||
{state.status === "idle" && (
|
||||
<p className="search-page-empty"><Trans>Enter a query to search.</Trans></p>
|
||||
<p className="search-page-empty">
|
||||
<Trans>Enter a query to search.</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === "loading" && (
|
||||
<p className="search-page-empty"><Trans>Searching…</Trans></p>
|
||||
<p className="search-page-empty">
|
||||
<Trans>Searching…</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === "error" && (
|
||||
@@ -236,10 +240,14 @@ export function Search() {
|
||||
)}
|
||||
<div ref={sentinelRef} />
|
||||
{state.dumps.loadingMore && (
|
||||
<p className="feed-loading-more"><Trans>Loading more…</Trans></p>
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!state.dumps.hasMore && state.dumps.items.length > 0 && (
|
||||
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -270,7 +278,11 @@ export function Search() {
|
||||
|
||||
{state.status === "loaded" && tab === "playlists" && (
|
||||
state.playlists.length === 0
|
||||
? <p className="search-page-empty">{t`No playlists match "${q}".`}</p>
|
||||
? (
|
||||
<p className="search-page-empty">
|
||||
{t`No playlists match "${q}".`}
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{state.playlists.map((p) => (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans, Plural } from "@lingui/react/macro";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { Link, useParams } from "react-router";
|
||||
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
@@ -47,7 +47,9 @@ export function UserDumps() {
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading"><Trans>Loading…</Trans></p>
|
||||
<p className="page-loading">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +91,11 @@ export function UserDumps() {
|
||||
)}
|
||||
|
||||
{dumps.length === 0
|
||||
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{dumps.map((dump) => (
|
||||
@@ -108,10 +114,18 @@ export function UserDumps() {
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more"><Trans>Loading more…</Trans></p>}
|
||||
{loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!hasMore && dumps.length > 0 && (
|
||||
<p className="index-status">
|
||||
<Trans>All <Plural value={dumps.length} one="# dump" other="# dumps" /> loaded.</Trans>
|
||||
<Trans>
|
||||
All <Plural value={dumps.length} one="# dump" other="# dumps" />
|
||||
{" "}
|
||||
loaded.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</PageShell>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { SubmitEvent } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
@@ -59,7 +59,9 @@ export function UserLogin() {
|
||||
return (
|
||||
<PageShell centered>
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-card-title"><Trans>Log in</Trans></h1>
|
||||
<h1 className="auth-card-title">
|
||||
<Trans>Log in</Trans>
|
||||
</h1>
|
||||
|
||||
{state.status === "error" && (
|
||||
<ErrorCard title={t`Login failed`} message={state.error} />
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
@@ -338,7 +338,9 @@ export function UserPlaylists() {
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading"><Trans>Loading…</Trans></p>
|
||||
<p className="page-loading">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -384,12 +386,17 @@ export function UserPlaylists() {
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">
|
||||
<Trans>
|
||||
Created ({created.items.length}{created.hasMore ? "+" : ""})
|
||||
Created ({created.items.length}
|
||||
{created.hasMore ? "+" : ""})
|
||||
</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
{created.items.length === 0
|
||||
? <p className="empty-state"><Trans>No playlists yet.</Trans></p>
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>No playlists yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{created.items.map((p) => (
|
||||
@@ -406,7 +413,9 @@ export function UserPlaylists() {
|
||||
)}
|
||||
<div ref={createdSentinelRef} />
|
||||
{created.loadingMore && (
|
||||
<p className="feed-loading-more"><Trans>Loading more…</Trans></p>
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -414,7 +423,8 @@ export function UserPlaylists() {
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">
|
||||
<Trans>
|
||||
Followed ({followed.items.length}{followed.hasMore ? "+" : ""})
|
||||
Followed ({followed.items.length}
|
||||
{followed.hasMore ? "+" : ""})
|
||||
</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
@@ -433,7 +443,9 @@ export function UserPlaylists() {
|
||||
)}
|
||||
<div ref={followedSentinelRef} />
|
||||
{followed.loadingMore && (
|
||||
<p className="feed-loading-more"><Trans>Loading more…</Trans></p>
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
import { API_URL, DEFAULT_PAGE_SIZE, VALIDATION } from "../config/api.ts";
|
||||
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
|
||||
import {
|
||||
deserializeAuthResponse,
|
||||
@@ -89,7 +89,9 @@ function InviteButton() {
|
||||
<button type="button" className="invite-btn" onClick={generate}>
|
||||
<Trans>+ Invite someone</Trans>
|
||||
</button>
|
||||
{error && <ErrorCard title={t`Failed to generate invite`} message={error} />}
|
||||
{error && (
|
||||
<ErrorCard title={t`Failed to generate invite`} message={error} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -550,7 +552,10 @@ export function UserPublicProfile() {
|
||||
};
|
||||
|
||||
const handleDescSave = async () => {
|
||||
if (state.status !== "loaded") return;
|
||||
if (
|
||||
state.status !== "loaded" ||
|
||||
descDraft.length > VALIDATION.USER_DESCRIPTION_MAX
|
||||
) return;
|
||||
setDescSaving(true);
|
||||
setDescError(null);
|
||||
try {
|
||||
@@ -587,7 +592,9 @@ export function UserPublicProfile() {
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading"><Trans>Loading profile…</Trans></p>
|
||||
<p className="page-loading">
|
||||
<Trans>Loading profile…</Trans>
|
||||
</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -689,7 +696,9 @@ export function UserPublicProfile() {
|
||||
className="profile-email-btn profile-email-btn--save"
|
||||
disabled={emailSaving || !emailDraft.trim()}
|
||||
>
|
||||
{emailSaving ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
||||
{emailSaving
|
||||
? <Trans>Saving…</Trans>
|
||||
: <Trans>Save</Trans>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -723,7 +732,10 @@ export function UserPublicProfile() {
|
||||
)
|
||||
)}
|
||||
{avatarError && (
|
||||
<ErrorCard title={t`Failed to update avatar`} message={avatarError} />
|
||||
<ErrorCard
|
||||
title={t`Failed to update avatar`}
|
||||
message={avatarError}
|
||||
/>
|
||||
)}
|
||||
{!isOwnProfile && (
|
||||
<FollowUserButton
|
||||
@@ -754,13 +766,15 @@ export function UserPublicProfile() {
|
||||
onSubmit={handleDescSave}
|
||||
placeholder={t`Tell people about yourself…`}
|
||||
autoResize
|
||||
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
|
||||
/>
|
||||
<div className="profile-description-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={handleDescSave}
|
||||
disabled={descSaving}
|
||||
disabled={descSaving ||
|
||||
descDraft.length > VALIDATION.USER_DESCRIPTION_MAX}
|
||||
>
|
||||
{descSaving ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
||||
</button>
|
||||
@@ -842,7 +856,10 @@ export function UserPublicProfile() {
|
||||
<section className="profile-section" id="playlists">
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">
|
||||
<Trans>Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})</Trans>
|
||||
<Trans>
|
||||
Playlists ({playlists.items.length}
|
||||
{playlists.hasMore ? "+" : ""})
|
||||
</Trans>
|
||||
</h2>
|
||||
{isOwnProfile && (
|
||||
<NewPlaylistForm
|
||||
@@ -862,7 +879,11 @@ export function UserPublicProfile() {
|
||||
)}
|
||||
</div>
|
||||
{playlists.items.length === 0
|
||||
? <p className="empty-state"><Trans>No playlists yet.</Trans></p>
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>No playlists yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{playlists.items.map((p) => (
|
||||
@@ -927,7 +948,11 @@ function DumpList(
|
||||
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||
)}
|
||||
{dumps.length === 0
|
||||
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{dumps.map((dump) => (
|
||||
@@ -945,7 +970,9 @@ function DumpList(
|
||||
</ul>
|
||||
)}
|
||||
{dumps.length > 0 && (
|
||||
<Link to={viewAllHref} className="profile-view-all"><Trans>View all →</Trans></Link>
|
||||
<Link to={viewAllHref} className="profile-view-all">
|
||||
<Trans>View all →</Trans>
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
@@ -998,9 +1025,7 @@ function UpvotedDumpList(
|
||||
useEffect(() => {
|
||||
if (!profileUserId || !isOwnProfile) return;
|
||||
if (prevMyVotesRef.current === null) {
|
||||
// setVotedIds must fire here alongside prevMyVotesRef mutation; render-phase
|
||||
// isn't possible because startFading/cancelFading (below) are also setState
|
||||
// calls that cannot be invoked during render.
|
||||
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setVotedIds(new Set(wsMyVotes));
|
||||
prevMyVotesRef.current = new Set(wsMyVotes);
|
||||
@@ -1021,8 +1046,8 @@ function UpvotedDumpList(
|
||||
const { dumpId, voterId, action } = lastVoteEvent;
|
||||
if (voterId !== profileUserId) return;
|
||||
if (action === "remove") {
|
||||
// setVotedIds + startFading must be coordinated in the same effect body
|
||||
// to guarantee a single render — render-phase can't call startFading (setState).
|
||||
// setVotedIds and startFading must fire together to avoid a render with
|
||||
// stale votedIds between the two updates.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setVotedIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -1046,7 +1071,11 @@ function UpvotedDumpList(
|
||||
<h2 className="profile-section-title">{title}</h2>
|
||||
</div>
|
||||
{visibleDumps.length === 0
|
||||
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{visibleDumps.map((dump) => {
|
||||
@@ -1073,7 +1102,9 @@ function UpvotedDumpList(
|
||||
</ul>
|
||||
)}
|
||||
{visibleDumps.length > 0 && (
|
||||
<Link to={viewAllHref} className="profile-view-all"><Trans>View all →</Trans></Link>
|
||||
<Link to={viewAllHref} className="profile-view-all">
|
||||
<Trans>View all →</Trans>
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SubmitEvent } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
@@ -91,7 +91,9 @@ export function UserRegister() {
|
||||
if (tokenState.status === "checking") {
|
||||
return (
|
||||
<PageShell centered>
|
||||
<p className="page-loading"><Trans>Checking invite…</Trans></p>
|
||||
<p className="page-loading">
|
||||
<Trans>Checking invite…</Trans>
|
||||
</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +114,9 @@ export function UserRegister() {
|
||||
return (
|
||||
<PageShell centered>
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-card-title"><Trans>Register</Trans></h1>
|
||||
<h1 className="auth-card-title">
|
||||
<Trans>Register</Trans>
|
||||
</h1>
|
||||
|
||||
{formState.status === "error" && (
|
||||
<ErrorCard title={t`Registration failed`} message={formState.error} />
|
||||
@@ -126,6 +130,7 @@ export function UserRegister() {
|
||||
required
|
||||
pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`}
|
||||
title={t`${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`}
|
||||
maxLength={VALIDATION.USERNAME_MAX}
|
||||
disabled={formState.status === "submitting"}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -157,7 +162,9 @@ export function UserRegister() {
|
||||
</form>
|
||||
|
||||
<p className="auth-card-footer">
|
||||
<Trans>Already have an account? <Link to="/login">Log in</Link></Trans>
|
||||
<Trans>
|
||||
Already have an account? <Link to="/login">Log in</Link>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
@@ -69,6 +69,7 @@ export function UserUpvoted() {
|
||||
useEffect(() => {
|
||||
if (!profileUserId || me?.id !== profileUserId) return;
|
||||
if (prevMyVotesRef.current === null) {
|
||||
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setVotedIds(new Set(myVotes));
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
@@ -87,6 +88,8 @@ export function UserUpvoted() {
|
||||
if (voterId !== profileUserId) return;
|
||||
|
||||
if (action === "remove") {
|
||||
// setVotedIds and startFading must fire together to avoid a render with
|
||||
// stale votedIds between the two updates.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setVotedIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -116,7 +119,9 @@ export function UserUpvoted() {
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading"><Trans>Loading…</Trans></p>
|
||||
<p className="page-loading">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -148,7 +153,11 @@ export function UserUpvoted() {
|
||||
/>
|
||||
|
||||
{visibleDumps.length === 0
|
||||
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{visibleDumps.map((dump) => {
|
||||
@@ -177,11 +186,21 @@ export function UserUpvoted() {
|
||||
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && (
|
||||
<p className="feed-loading-more"><Trans>Loading more…</Trans></p>
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!hasMore && visibleDumps.length > 0 && (
|
||||
<p className="index-status">
|
||||
<Trans>All <Plural value={votes.length} one="# upvoted dump" other="# upvoted dumps" /> loaded.</Trans>
|
||||
<Trans>
|
||||
All{" "}
|
||||
<Plural
|
||||
value={votes.length}
|
||||
one="# upvoted dump"
|
||||
other="# upvoted dumps"
|
||||
/>{" "}
|
||||
loaded.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</PageShell>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DumpCard } from "../../components/DumpCard.tsx";
|
||||
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
||||
@@ -71,7 +71,11 @@ function FollowedSubFeed({
|
||||
const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <p className="index-status"><Trans>Loading…</Trans></p>;
|
||||
return (
|
||||
<p className="index-status">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return <ErrorCard title={t`Failed to load`} message={state.error} />;
|
||||
@@ -100,7 +104,11 @@ function FollowedSubFeed({
|
||||
))}
|
||||
</ul>
|
||||
<div ref={sentinelRef} />
|
||||
{state.loadingMore && <p className="feed-loading-more"><Trans>Loading more…</Trans></p>}
|
||||
{state.loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -219,8 +227,7 @@ export function FollowedFeed({
|
||||
})
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
}, [token, usersState.status, playlistsState.status]);
|
||||
|
||||
// Scroll save
|
||||
useScrollSave(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DumpCard } from "../../components/DumpCard.tsx";
|
||||
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
||||
@@ -26,10 +26,20 @@ export function HotFeed(
|
||||
[dumps],
|
||||
);
|
||||
|
||||
if (loading) return <p className="index-status"><Trans>Loading…</Trans></p>;
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="index-status">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
|
||||
if (sorted.length === 0) {
|
||||
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>;
|
||||
return (
|
||||
<p className="index-status">
|
||||
<Trans>No dumps yet. Be the first!</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -49,9 +59,15 @@ export function HotFeed(
|
||||
))}
|
||||
</ul>
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more"><Trans>Loading more…</Trans></p>}
|
||||
{loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!hasMore && sorted.length > 0 && (
|
||||
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
||||
import {
|
||||
@@ -38,10 +38,20 @@ export function JournalFeed(
|
||||
});
|
||||
}, [dumps]);
|
||||
|
||||
if (loading) return <p className="index-status"><Trans>Loading…</Trans></p>;
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="index-status">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
|
||||
if (tiered.length === 0) {
|
||||
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>;
|
||||
return (
|
||||
<p className="index-status">
|
||||
<Trans>No dumps yet. Be the first!</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -62,9 +72,15 @@ export function JournalFeed(
|
||||
))}
|
||||
</ul>
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more"><Trans>Loading more…</Trans></p>}
|
||||
{loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!hasMore && tiered.length > 0 && (
|
||||
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DumpCard } from "../../components/DumpCard.tsx";
|
||||
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
||||
@@ -26,10 +26,20 @@ export function NewFeed(
|
||||
[dumps],
|
||||
);
|
||||
|
||||
if (loading) return <p className="index-status"><Trans>Loading…</Trans></p>;
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="index-status">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
|
||||
if (sorted.length === 0) {
|
||||
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>;
|
||||
return (
|
||||
<p className="index-status">
|
||||
<Trans>No dumps yet. Be the first!</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -49,9 +59,15 @@ export function NewFeed(
|
||||
))}
|
||||
</ul>
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more"><Trans>Loading more…</Trans></p>}
|
||||
{loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!hasMore && sorted.length > 0 && (
|
||||
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
5
src/utils/charCount.ts
Normal file
5
src/utils/charCount.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function charCountClass(len: number, max: number): string {
|
||||
if (len >= max) return " text-editor-count--danger";
|
||||
if (len >= max * 0.85) return " text-editor-count--warn";
|
||||
return "";
|
||||
}
|
||||
Reference in New Issue
Block a user