Compare commits
2 Commits
5fd1d364da
...
bf28e49c34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf28e49c34 | ||
|
|
85d0703077 |
187
src/App.css
187
src/App.css
@@ -65,6 +65,62 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
/* Headings inside a comment or a chat message are emphasis within prose, not
|
||||||
|
page titles: the browser's 2em h1 dwarfed everything around it. */
|
||||||
|
.md h1 {
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
.md h2 {
|
||||||
|
font-size: 1.18em;
|
||||||
|
}
|
||||||
|
.md h3 {
|
||||||
|
font-size: 1.06em;
|
||||||
|
}
|
||||||
|
.md h4,
|
||||||
|
.md h5,
|
||||||
|
.md h6 {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
.md > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.md hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin: 0.85em 0;
|
||||||
|
}
|
||||||
|
.md del {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
/* GFM task lists: the checkbox stands in for the bullet. */
|
||||||
|
.md .contains-task-list {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0.2em;
|
||||||
|
}
|
||||||
|
.md .task-list-item input {
|
||||||
|
margin: 0 0.4em 0 0;
|
||||||
|
vertical-align: -0.1em;
|
||||||
|
}
|
||||||
|
.md-table-wrap {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
margin: 0.6em 0;
|
||||||
|
}
|
||||||
|
.md table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
.md th,
|
||||||
|
.md td {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 0.3em 0.6em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.md th {
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.md img {
|
.md img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -83,6 +139,19 @@
|
|||||||
.md--inline li {
|
.md--inline li {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.md--inline h1,
|
||||||
|
.md--inline h2,
|
||||||
|
.md--inline h3,
|
||||||
|
.md--inline h4,
|
||||||
|
.md--inline h5,
|
||||||
|
.md--inline h6 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.md--inline hr,
|
||||||
|
.md--inline .md-table-wrap {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.md--inline ul,
|
.md--inline ul,
|
||||||
.md--inline ol {
|
.md--inline ol {
|
||||||
padding-left: 1.2em;
|
padding-left: 1.2em;
|
||||||
@@ -3155,6 +3224,11 @@ body.has-player .chat-fab {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.dump-edit-panel--details .text-editor-field {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
.dump-edit-panel--details .mention-textarea-wrap textarea {
|
.dump-edit-panel--details .mention-textarea-wrap textarea {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
@@ -4522,6 +4596,15 @@ body.has-player .chat-fab {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A message that's only an emoji or three, shown large as chats do. */
|
||||||
|
.chat-message-text--jumbo {
|
||||||
|
font-size: 2.1em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.chat-message-text--jumbo.md {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-message-edited {
|
.chat-message-edited {
|
||||||
font-size: var(--text-2xs);
|
font-size: var(--text-2xs);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
@@ -4617,16 +4700,21 @@ body.has-player .chat-fab {
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The counter (only shown near the limit) sits just above the input's right
|
/* The counter (only shown near the limit) and the upload notice sit just
|
||||||
edge, in the gap over the composer: inside the textarea, the text ran under
|
above the input, in the gap over the composer: inside the textarea, the text
|
||||||
it, and in a row below it would knock the Send button out of alignment. */
|
ran under them, and in a row below they would knock the Send button out of
|
||||||
.chat-composer .text-editor-count {
|
alignment. */
|
||||||
|
.chat-composer .text-editor-footer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
left: 0.25rem;
|
||||||
right: 0.25rem;
|
right: 0.25rem;
|
||||||
bottom: calc(100% + 1px);
|
bottom: calc(100% + 1px);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
.chat-composer .text-editor-footer > * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-send-btn {
|
.chat-send-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -6440,6 +6528,78 @@ body.has-player .chat-fab {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The textarea and the emoji button in its corner. */
|
||||||
|
.text-editor-field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
/* Room for the button, so text never runs under it. Qualified to outrank the
|
||||||
|
per-editor and per-theme padding shorthands. */
|
||||||
|
.mention-textarea-wrap .text-editor-field > textarea {
|
||||||
|
padding-right: 2.25rem;
|
||||||
|
}
|
||||||
|
/* Top corner, not bottom: the bottom one is a resizable textarea's grip. */
|
||||||
|
.text-editor-emoji-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.3rem;
|
||||||
|
top: 0.3rem;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--dur-fast), color var(--dur-fast),
|
||||||
|
background var(--dur-fast);
|
||||||
|
}
|
||||||
|
.text-editor-emoji-btn svg {
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.text-editor-emoji-btn svg circle[r=".6"] {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
.text-editor-emoji-btn:hover:not(:disabled),
|
||||||
|
.text-editor-emoji-btn:focus-visible,
|
||||||
|
.text-editor-emoji-btn--open {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.text-editor-emoji-btn:disabled {
|
||||||
|
opacity: 0.25;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
/* Clear of the scrollbar a long chat message grows. */
|
||||||
|
.chat-input + .text-editor-emoji-btn {
|
||||||
|
right: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-editor-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.text-editor-footer .text-editor-count {
|
||||||
|
margin: 0 0 0 auto;
|
||||||
|
}
|
||||||
|
.text-editor-notice {
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.text-editor-notice--error {
|
||||||
|
color: var(--color-danger, #c0392b);
|
||||||
|
}
|
||||||
|
|
||||||
.mention-textarea-wrap--dragover textarea {
|
.mention-textarea-wrap--dragover textarea {
|
||||||
outline: 2px dashed var(--color-accent, #6c8ebf);
|
outline: 2px dashed var(--color-accent, #6c8ebf);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
@@ -6536,10 +6696,29 @@ body.has-player .chat-fab {
|
|||||||
font-size: var(--text-md);
|
font-size: var(--text-md);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
/* The row has its own ✕; the browser's clear button would be a second one. */
|
||||||
|
.emoji-picker-float input::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.emoji-picker-float input::placeholder {
|
.emoji-picker-float input::placeholder {
|
||||||
color: var(--color-text-muted, #888);
|
color: var(--color-text-muted, #888);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emoji-picker-float [frimousse-skin-tone-selector] {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 6px;
|
||||||
|
height: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: var(--text-md);
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.emoji-picker-float [frimousse-skin-tone-selector]:hover {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
.emoji-picker-close-btn {
|
.emoji-picker-close-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useAuth } from "../hooks/useAuth.ts";
|
|||||||
import { can } from "../utils/permissions.ts";
|
import { can } from "../utils/permissions.ts";
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
import { markdownToPlainText, mentionsUser } from "../utils/chatText.ts";
|
import { markdownToPlainText, mentionsUser } from "../utils/chatText.ts";
|
||||||
|
import { isEmojiOnly } from "../utils/editorText.ts";
|
||||||
import { i18n } from "../i18n.ts";
|
import { i18n } from "../i18n.ts";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type {
|
import type {
|
||||||
@@ -77,6 +78,19 @@ function compareMessages(a: ChatMessage, b: ChatMessage): number {
|
|||||||
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What the server holds a message to: its length once trimmed.
|
||||||
|
function sendable(draft: string): boolean {
|
||||||
|
const body = draft.trim();
|
||||||
|
return body.length > 0 && body.length <= MAX_CHAT_LENGTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A message of nothing but a few emoji is shown large, as chats do.
|
||||||
|
function messageTextClass(body: string): string {
|
||||||
|
return isEmojiOnly(body)
|
||||||
|
? "chat-message-text chat-message-text--jumbo"
|
||||||
|
: "chat-message-text";
|
||||||
|
}
|
||||||
|
|
||||||
function snippet(body: string): string {
|
function snippet(body: string): string {
|
||||||
return markdownToPlainText(body, t`image`);
|
return markdownToPlainText(body, t`image`);
|
||||||
}
|
}
|
||||||
@@ -184,9 +198,8 @@ function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
const body = draft.trim();
|
if (!sendable(draft)) return;
|
||||||
if (!body) return;
|
onSave(draft.trim());
|
||||||
onSave(body);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -201,13 +214,11 @@ function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
|
|||||||
countFrom={COUNT_FROM}
|
countFrom={COUNT_FROM}
|
||||||
className="comment-reply-textarea chat-input"
|
className="comment-reply-textarea chat-input"
|
||||||
onSubmit={save}
|
onSubmit={save}
|
||||||
|
onCancel={onCancel}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
save();
|
save();
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
onCancel();
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -216,7 +227,7 @@ function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={!draft.trim()}
|
disabled={!sendable(draft)}
|
||||||
>
|
>
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
</button>
|
</button>
|
||||||
@@ -419,7 +430,11 @@ function ChatMessageItem(
|
|||||||
onCancel={onEndEdit}
|
onCancel={onEndEdit}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: <Markdown className="chat-message-text">{message.body}</Markdown>}
|
: (
|
||||||
|
<Markdown className={messageTextClass(message.body)}>
|
||||||
|
{message.body}
|
||||||
|
</Markdown>
|
||||||
|
)}
|
||||||
|
|
||||||
{grouped && !editing && edited}
|
{grouped && !editing && edited}
|
||||||
</div>
|
</div>
|
||||||
@@ -485,7 +500,9 @@ function PendingItem(
|
|||||||
<span className="chat-message-author">{user.username}</span>
|
<span className="chat-message-author">{user.username}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Markdown className="chat-message-text">{pending.body}</Markdown>
|
<Markdown className={messageTextClass(pending.body)}>
|
||||||
|
{pending.body}
|
||||||
|
</Markdown>
|
||||||
<div
|
<div
|
||||||
className="chat-pending-status"
|
className="chat-pending-status"
|
||||||
role={failed ? "alert" : undefined}
|
role={failed ? "alert" : undefined}
|
||||||
@@ -956,9 +973,8 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
const body = draft.trim();
|
if (!sendable(draft)) return;
|
||||||
if (!body) return;
|
sendChatMessage(draft.trim(), replyTarget ?? undefined);
|
||||||
sendChatMessage(body, replyTarget ?? undefined);
|
|
||||||
setDraft("");
|
setDraft("");
|
||||||
setReplyTarget(null);
|
setReplyTarget(null);
|
||||||
stopTyping();
|
stopTyping();
|
||||||
@@ -1275,7 +1291,7 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn-primary chat-send-btn"
|
className="btn-primary chat-send-btn"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!draft.trim()}
|
disabled={!sendable(draft)}
|
||||||
>
|
>
|
||||||
<Trans>Send</Trans>
|
<Trans>Send</Trans>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -93,6 +93,14 @@ function CommentForm({
|
|||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
onSubmit={() => void submit()}
|
onSubmit={() => void submit()}
|
||||||
|
// Escape backs out of a reply or an edit; the top-level box has
|
||||||
|
// nothing to back out of.
|
||||||
|
onCancel={onCancel && !cancelWhenEmpty
|
||||||
|
? () => {
|
||||||
|
form.reset({ body: "" });
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
: undefined}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
autoResize
|
autoResize
|
||||||
rows={1}
|
rows={1}
|
||||||
@@ -228,6 +236,11 @@ function CommentNode({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleEditSave(body: string) {
|
async function handleEditSave(body: string) {
|
||||||
|
// Saving it unchanged would only stamp it "edited".
|
||||||
|
if (body === comment.body) {
|
||||||
|
setEditOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
|
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ body } satisfies UpdateCommentRequest),
|
body: JSON.stringify({ body } satisfies UpdateCommentRequest),
|
||||||
|
|||||||
@@ -912,7 +912,6 @@ export function DumpCreateModal(
|
|||||||
placeholder={t`What makes it worth it?`}
|
placeholder={t`What makes it worth it?`}
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
||||||
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
|
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
editorRef={commentEditorRef}
|
editorRef={commentEditorRef}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -57,22 +57,74 @@ function remarkMentions() {
|
|||||||
return (tree: MdNode) => walk(tree);
|
return (tree: MdNode) => walk(tree);
|
||||||
}
|
}
|
||||||
|
|
||||||
const REMARK_PLUGINS = [remarkGfm, remarkMentions];
|
/**
|
||||||
|
* A single newline is a line break, as it is in chat everywhere: markdown's
|
||||||
|
* own rule folds it into a space, so a message typed on three lines with
|
||||||
|
* Shift+Enter came out as one. (Code keeps its newlines; it has no text
|
||||||
|
* nodes.)
|
||||||
|
*/
|
||||||
|
function remarkBreaks() {
|
||||||
|
const walk = (node: MdNode) => {
|
||||||
|
if (!node.children) return;
|
||||||
|
node.children = node.children.flatMap((child) => {
|
||||||
|
if (child.type !== "text" || !child.value?.includes("\n")) {
|
||||||
|
walk(child);
|
||||||
|
return [child];
|
||||||
|
}
|
||||||
|
return child.value.split("\n").flatMap((part, i) =>
|
||||||
|
i === 0
|
||||||
|
? [{ type: "text", value: part }]
|
||||||
|
: [{ type: "break" }, { type: "text", value: part }]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (tree: MdNode) => walk(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
const REMARK_PLUGINS = [remarkGfm, remarkMentions, remarkBreaks];
|
||||||
|
|
||||||
|
/** The path of a link into this app — relative, or absolute to this origin —
|
||||||
|
* or null for anywhere else. */
|
||||||
|
function internalPath(href: string | undefined): string | null {
|
||||||
|
if (!href) return null;
|
||||||
|
if (href.startsWith("/") && !href.startsWith("//")) {
|
||||||
|
// Files and attachments are the API's, not routes: a new tab, as before.
|
||||||
|
return href.startsWith("/api/") ? null : href;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(href);
|
||||||
|
if (url.origin !== globalThis.location.origin) return null;
|
||||||
|
if (url.pathname.startsWith("/api/")) return null;
|
||||||
|
return url.pathname + url.search + url.hash;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
|
type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
|
||||||
|
|
||||||
// Static components objects — defined once at module scope to avoid recreation on every render
|
// Static components objects — defined once at module scope to avoid recreation on every render
|
||||||
const MARKDOWN_COMPONENTS: Components = {
|
const MARKDOWN_COMPONENTS: Components = {
|
||||||
|
// Mentions and links to other pages here stay in the app; the rest open
|
||||||
|
// in a new tab.
|
||||||
a: ({ href, children: linkChildren }) => {
|
a: ({ href, children: linkChildren }) => {
|
||||||
if (href?.startsWith("/users/")) {
|
const path = internalPath(href);
|
||||||
return <Link to={href}>{linkChildren}</Link>;
|
if (path) return <Link to={path}>{linkChildren}</Link>;
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||||
{linkChildren}
|
{linkChildren}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
img: ({ src, alt }) => (
|
||||||
|
<img src={src} alt={alt ?? ""} loading="lazy" decoding="async" />
|
||||||
|
),
|
||||||
|
// A wide GFM table scrolls inside its own box instead of the page.
|
||||||
|
table: ({ children: tableChildren }) => (
|
||||||
|
<div className="md-table-wrap">
|
||||||
|
<table>{tableChildren}</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inline renderings are line-clamped teasers inside cards, where an embedded
|
// Inline renderings are line-clamped teasers inside cards, where an embedded
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { CSSProperties } from "react";
|
import { type CSSProperties, useLayoutEffect, useRef } from "react";
|
||||||
import { Avatar } from "./Avatar.tsx";
|
import { Avatar } from "./Avatar.tsx";
|
||||||
import type { UserResult } from "../hooks/useMentionAutocomplete.ts";
|
import type { UserResult } from "../hooks/useMentionAutocomplete.ts";
|
||||||
|
|
||||||
interface MentionDropdownProps {
|
interface MentionDropdownProps {
|
||||||
|
/** For the textarea's `aria-controls` / `aria-activedescendant`. */
|
||||||
|
id: string;
|
||||||
results: UserResult[];
|
results: UserResult[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelect: (username: string) => void;
|
onSelect: (username: string) => void;
|
||||||
@@ -11,14 +13,33 @@ interface MentionDropdownProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MentionDropdown(
|
export function MentionDropdown(
|
||||||
{ results, selectedIndex, onSelect, style }: MentionDropdownProps,
|
{ id, results, selectedIndex, onSelect, style }: MentionDropdownProps,
|
||||||
) {
|
) {
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
|
// Arrowing past the visible rows keeps the selection in view.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
listRef.current
|
||||||
|
?.querySelector<HTMLElement>("[aria-selected='true']")
|
||||||
|
?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [selectedIndex, results]);
|
||||||
|
|
||||||
if (results.length === 0) return null;
|
if (results.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<ul className="mention-dropdown" style={style}>
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
id={id}
|
||||||
|
role="listbox"
|
||||||
|
className="mention-dropdown"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
{results.map((user, i) => (
|
{results.map((user, i) => (
|
||||||
<li
|
<li
|
||||||
key={user.id}
|
key={user.id}
|
||||||
|
// TextEditor points aria-activedescendant at `${id}-${index}`.
|
||||||
|
id={`${id}-${i}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === selectedIndex}
|
||||||
className={`mention-dropdown-item${
|
className={`mention-dropdown-item${
|
||||||
i === selectedIndex ? " mention-dropdown-item--selected" : ""
|
i === selectedIndex ? " mention-dropdown-item--selected" : ""
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ export function PlaylistCreateForm(
|
|||||||
placeholder={t`Description (optional)`}
|
placeholder={t`Description (optional)`}
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||||
rules={{ maxLength: VALIDATION.PLAYLIST_DESCRIPTION_MAX }}
|
|
||||||
/>
|
/>
|
||||||
<VisibilityToggle<Values> name="isPublic" />
|
<VisibilityToggle<Values> name="isPublic" />
|
||||||
<FormError title={t`Failed to create playlist`} />
|
<FormError title={t`Failed to create playlist`} />
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ import {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useId,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { EmojiPicker, type Locale as EmojiLocale } from "frimousse";
|
import {
|
||||||
|
EmojiPicker,
|
||||||
|
type Locale as EmojiLocale,
|
||||||
|
type SkinTone,
|
||||||
|
useSkinTone,
|
||||||
|
} from "frimousse";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { useLingui } from "@lingui/react";
|
import { useLingui } from "@lingui/react";
|
||||||
import { charCountClass } from "../utils/charCount.ts";
|
import { charCountClass } from "../utils/charCount.ts";
|
||||||
@@ -34,12 +41,56 @@ interface TextEditorProps {
|
|||||||
id?: string;
|
id?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
autoResize?: boolean;
|
autoResize?: boolean;
|
||||||
|
/** Ctrl/Cmd+Enter. Not called while an image is still uploading. */
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
|
/** Escape, once no popup of the editor's own is left to close it: for the
|
||||||
|
* inline editors (an edit, a reply), where it means "never mind". */
|
||||||
|
onCancel?: () => void;
|
||||||
|
/** Runs after the editor's own keys (autocompletes, Ctrl+Enter) and never
|
||||||
|
* while an IME is composing, so an Enter that confirms a candidate isn't
|
||||||
|
* taken for "send". */
|
||||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
/** Hide the character counter until the text is at least this long, for
|
/** Show the character counter only from this length. Defaults to 80% of
|
||||||
* places where a standing "0 / 2000" is just noise. */
|
* `maxLength`: a standing "0 / 5000" is just noise, but the counter turns
|
||||||
|
* up well before the warning colour does. */
|
||||||
countFrom?: number;
|
countFrom?: number;
|
||||||
|
"aria-invalid"?: boolean;
|
||||||
|
"aria-describedby"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKIN_TONE_KEY = "emoji-skin-tone";
|
||||||
|
|
||||||
|
function readSkinTone(): SkinTone {
|
||||||
|
try {
|
||||||
|
return (localStorage.getItem(SKIN_TONE_KEY) as SkinTone | null) ?? "none";
|
||||||
|
} catch {
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remembers the skin tone picked in the selector for the next picker. */
|
||||||
|
function SkinTonePersist() {
|
||||||
|
const [skinTone] = useSkinTone();
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SKIN_TONE_KEY, skinTone);
|
||||||
|
} catch {
|
||||||
|
// Private mode: the tone lasts as long as the picker.
|
||||||
|
}
|
||||||
|
}, [skinTone]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFiles(dt: DataTransfer): boolean {
|
||||||
|
return Array.from(dt.types).includes("Files");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingInsert {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
focus: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||||
@@ -54,24 +105,36 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
|||||||
className,
|
className,
|
||||||
autoResize = false,
|
autoResize = false,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
maxLength,
|
maxLength,
|
||||||
countFrom = 0,
|
countFrom,
|
||||||
|
"aria-invalid": ariaInvalid,
|
||||||
|
"aria-describedby": ariaDescribedBy,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const floatRef = useRef<HTMLDivElement>(null);
|
||||||
|
const emojiButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const emojiViewportRef = useRef<HTMLDivElement>(null);
|
const emojiViewportRef = useRef<HTMLDivElement>(null);
|
||||||
const emojiSearchRef = useRef<HTMLInputElement>(null);
|
const emojiSearchRef = useRef<HTMLInputElement>(null);
|
||||||
const valueRef = useRef(value);
|
const onChangeRef = useRef(onChange);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const pendingInsertRef = useRef<PendingInsert | null>(null);
|
||||||
|
// Whether the highlighted emoji was chosen with the arrows, rather than
|
||||||
|
// being frimousse's automatic first result for the query.
|
||||||
|
const arrowedRef = useRef(false);
|
||||||
const { authFetch } = useAuth();
|
const { authFetch } = useAuth();
|
||||||
|
const mentionListId = useId();
|
||||||
|
const noticeId = useId();
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
valueRef.current = value;
|
onChangeRef.current = onChange;
|
||||||
}, [value]);
|
});
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: (options) => {
|
focus: (options) => {
|
||||||
@@ -85,28 +148,86 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
|||||||
},
|
},
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts `text` over `[start, end)` of the textarea, then a space when
|
||||||
|
* `word` is set and none follows already, and leaves the caret after it.
|
||||||
|
* Goes through execCommand where it can, so the insertion joins the
|
||||||
|
* browser's undo stack — Ctrl+Z takes back the emoji or mention itself,
|
||||||
|
* not everything typed before it — and fires the input event onChange
|
||||||
|
* listens for.
|
||||||
|
*/
|
||||||
|
const replace = useCallback(
|
||||||
|
(
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
text: string,
|
||||||
|
word: boolean,
|
||||||
|
focus = true,
|
||||||
|
) => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const current = el.value;
|
||||||
|
const spaced = word && /^\s/.test(current.slice(end));
|
||||||
|
const insert = word && !spaced ? `${text} ` : text;
|
||||||
|
const caret = start + insert.length + (spaced ? 1 : 0);
|
||||||
|
if (focus) {
|
||||||
|
el.focus({ preventScroll: true });
|
||||||
|
el.setSelectionRange(start, end);
|
||||||
|
if (document.execCommand("insertText", false, insert)) {
|
||||||
|
el.setSelectionRange(caret, caret);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChangeRef.current(
|
||||||
|
current.slice(0, start) + insert + current.slice(end),
|
||||||
|
);
|
||||||
|
if (!focus) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textareaRef.current?.setSelectionRange(caret, caret);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mentionOpen,
|
mentionOpen,
|
||||||
mentionResults,
|
mentionResults,
|
||||||
mentionSelectedIndex,
|
mentionSelectedIndex,
|
||||||
handleMentionChange,
|
syncMention,
|
||||||
|
closeMention,
|
||||||
handleMentionKeyDown,
|
handleMentionKeyDown,
|
||||||
handleMentionSelect,
|
handleMentionSelect,
|
||||||
} = useMentionAutocomplete(value, onChange, textareaRef);
|
} = useMentionAutocomplete(replace);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
emojiOpen,
|
emojiMode,
|
||||||
emojiQuery,
|
emojiQuery,
|
||||||
detectEmojiTrigger,
|
syncEmojiTrigger,
|
||||||
handleEmojiSelect,
|
openEmojiBrowser,
|
||||||
|
setEmojiQuery,
|
||||||
|
selectEmoji,
|
||||||
closeEmoji,
|
closeEmoji,
|
||||||
} = useEmojiTrigger(value, onChange, textareaRef);
|
} = useEmojiTrigger(replace);
|
||||||
|
const emojiOpen = emojiMode !== "closed";
|
||||||
|
|
||||||
|
// A press anywhere outside the picker puts it away — except, for an
|
||||||
|
// inline `:query`, a press in the textarea: that's a caret move, which
|
||||||
|
// keeps or closes it depending on where the caret lands.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (emojiOpen) {
|
if (emojiMode === "closed") return;
|
||||||
emojiViewportRef.current?.focus({ preventScroll: true });
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
}
|
const target = e.target as Node;
|
||||||
}, [emojiOpen]);
|
if (
|
||||||
|
floatRef.current?.contains(target) ||
|
||||||
|
emojiButtonRef.current?.contains(target) ||
|
||||||
|
(emojiMode === "inline" && textareaRef.current?.contains(target))
|
||||||
|
) return;
|
||||||
|
closeEmoji();
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", onPointerDown, true);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||||
|
}, [emojiMode, closeEmoji]);
|
||||||
|
|
||||||
// Both popups are portaled to <body> and anchored to the textarea: the
|
// Both popups are portaled to <body> and anchored to the textarea: the
|
||||||
// editor sits inside cards, modal bodies and scrollers that clip anything
|
// editor sits inside cards, modal bodies and scrollers that clip anything
|
||||||
@@ -118,6 +239,20 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
|||||||
maxContentHeight: 220,
|
maxContentHeight: 220,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Browsing starts in the picker's search; an inline `:query` keeps typing
|
||||||
|
// in the textarea and only lends the picker its query. Only once the
|
||||||
|
// picker is placed: until then it's hidden, and a hidden field can't take
|
||||||
|
// focus.
|
||||||
|
const emojiPlaced = emojiStyle != null;
|
||||||
|
useEffect(() => {
|
||||||
|
arrowedRef.current = false;
|
||||||
|
}, [emojiQuery, emojiMode]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (emojiMode === "browse" && emojiPlaced) {
|
||||||
|
emojiSearchRef.current?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}, [emojiMode, emojiPlaced]);
|
||||||
|
|
||||||
const mentionStyle = useAnchoredFloat(textareaRef, mentionOpen, {
|
const mentionStyle = useAnchoredFloat(textareaRef, mentionOpen, {
|
||||||
width: "anchor", // it used to be stretched edge to edge by left/right: 0
|
width: "anchor", // it used to be stretched edge to edge by left/right: 0
|
||||||
chromeHeight: 10, // list padding + borders
|
chromeHeight: 10, // list padding + borders
|
||||||
@@ -135,118 +270,292 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
|||||||
el.style.height = `${el.scrollHeight}px`;
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
}, [value, autoResize]);
|
}, [value, autoResize]);
|
||||||
|
|
||||||
const insertAtCursor = useCallback((text: string) => {
|
// The textarea is read-only while images upload (so the spot they're
|
||||||
const el = textareaRef.current;
|
// going to can't move); once it isn't, the images go in.
|
||||||
if (!el) return;
|
useLayoutEffect(() => {
|
||||||
const start = el.selectionStart ?? valueRef.current.length;
|
if (uploading) return;
|
||||||
const end = el.selectionEnd ?? valueRef.current.length;
|
const pending = pendingInsertRef.current;
|
||||||
const newValue = valueRef.current.slice(0, start) +
|
if (!pending) return;
|
||||||
text +
|
pendingInsertRef.current = null;
|
||||||
valueRef.current.slice(end);
|
replace(pending.start, pending.end, pending.text, false, pending.focus);
|
||||||
onChange(newValue);
|
}, [uploading, replace]);
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!textareaRef.current) return;
|
|
||||||
const pos = start + text.length;
|
|
||||||
textareaRef.current.selectionStart = pos;
|
|
||||||
textareaRef.current.selectionEnd = pos;
|
|
||||||
});
|
|
||||||
}, [onChange]);
|
|
||||||
|
|
||||||
const uploadImage = useCallback(async (file: File) => {
|
const uploadImages = useCallback(async (files: File[]) => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el || files.length === 0) return;
|
||||||
|
const focus = document.activeElement === el;
|
||||||
|
const start = el.selectionStart;
|
||||||
|
const end = el.selectionEnd;
|
||||||
|
setNotice(null);
|
||||||
|
setUploading(true);
|
||||||
|
const images: string[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
const res = await authFetch(`${API_URL}/api/attachments`, {
|
const res = await authFetch(`${API_URL}/api/attachments`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json.success) {
|
if (!json.success) throw new Error(json.error?.message);
|
||||||
insertAtCursor(``);
|
images.push(``);
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// silently ignore — user can retry
|
setNotice(
|
||||||
} finally {
|
files.length > 1
|
||||||
setUploading(false);
|
? t`Some images couldn't be uploaded.`
|
||||||
textareaRef.current?.focus();
|
: t`The image couldn't be uploaded.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [authFetch, insertAtCursor]);
|
}
|
||||||
|
if (images.length > 0) {
|
||||||
|
pendingInsertRef.current = {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
text: images.join("\n"),
|
||||||
|
focus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
}, [authFetch]);
|
||||||
|
|
||||||
const handlePaste = useCallback(
|
const handlePaste = useCallback(
|
||||||
async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
const imageItem = Array.from(e.clipboardData.items).find(
|
// Copying from a spreadsheet or a document puts a picture of the
|
||||||
(item) => item.kind === "file" && item.type.startsWith("image/"),
|
// selection on the clipboard next to its text: the text is what was
|
||||||
|
// meant.
|
||||||
|
if (e.clipboardData.types.includes("text/plain")) return;
|
||||||
|
const images = Array.from(e.clipboardData.files).filter((f) =>
|
||||||
|
f.type.startsWith("image/")
|
||||||
);
|
);
|
||||||
if (!imageItem) return;
|
if (images.length === 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = imageItem.getAsFile();
|
void uploadImages(images);
|
||||||
if (file) await uploadImage(file);
|
|
||||||
},
|
},
|
||||||
[uploadImage],
|
[uploadImages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
async (e: React.DragEvent<HTMLTextAreaElement>) => {
|
(e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||||
e.preventDefault();
|
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
const files = Array.from(e.dataTransfer.files).filter((f) =>
|
if (!hasFiles(e.dataTransfer)) return; // dragged text: the default
|
||||||
f.type.startsWith("image/")
|
e.preventDefault();
|
||||||
);
|
if (disabled || uploading) return;
|
||||||
for (const file of files) {
|
const files = Array.from(e.dataTransfer.files);
|
||||||
await uploadImage(file);
|
const images = files.filter((f) => f.type.startsWith("image/"));
|
||||||
|
if (images.length < files.length) {
|
||||||
|
setNotice(t`Only images can be dropped here.`);
|
||||||
}
|
}
|
||||||
|
void uploadImages(images);
|
||||||
},
|
},
|
||||||
[uploadImage],
|
[uploadImages, disabled, uploading],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Any file is caught, not just images: a file dropped on a textarea that
|
||||||
|
// doesn't take it is opened by the browser in place of the page, and a
|
||||||
|
// half-written message goes with it.
|
||||||
const handleDragOver = useCallback(
|
const handleDragOver = useCallback(
|
||||||
(e: React.DragEvent<HTMLTextAreaElement>) => {
|
(e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||||
const hasImage = Array.from(e.dataTransfer.items).some(
|
if (!hasFiles(e.dataTransfer)) return;
|
||||||
(item) => item.kind === "file" && item.type.startsWith("image/"),
|
|
||||||
);
|
|
||||||
if (!hasImage) return;
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOver(true);
|
const accepts = !disabled && !uploading;
|
||||||
|
e.dataTransfer.dropEffect = accepts ? "copy" : "none";
|
||||||
|
setDragOver(accepts);
|
||||||
},
|
},
|
||||||
[],
|
[disabled, uploading],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragLeave = useCallback(() => setDragOver(false), []);
|
const handleDragLeave = useCallback(() => setDragOver(false), []);
|
||||||
|
|
||||||
|
// The best match for the query among the results on screen. frimousse
|
||||||
|
// lists matches in emoji order, so ":rocket" would lead with 🧑🚀; a name
|
||||||
|
// that is the query, then one that starts with it, comes first. (It also
|
||||||
|
// renders a hidden row of unlabelled stand-ins to measure rows with;
|
||||||
|
// those aren't picks.)
|
||||||
|
const firstEmoji = () => {
|
||||||
|
const buttons = Array.from(
|
||||||
|
floatRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||||
|
"[frimousse-list] [frimousse-emoji]:not([aria-label=''])",
|
||||||
|
) ?? [],
|
||||||
|
);
|
||||||
|
const query = emojiQuery.trim().toLowerCase();
|
||||||
|
const name = (b: HTMLButtonElement) =>
|
||||||
|
(b.getAttribute("aria-label") ?? "").toLowerCase();
|
||||||
|
return buttons.find((b) => name(b) === query) ??
|
||||||
|
buttons.find((b) => name(b).startsWith(query)) ??
|
||||||
|
buttons[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Moves the keyboard into the picker's grid: frimousse only listens for
|
||||||
|
// arrows while focus is inside it, and the arrow that got us there is
|
||||||
|
// replayed so the first press already lands on an emoji.
|
||||||
|
const enterPicker = (key: string) => {
|
||||||
|
const viewport = emojiViewportRef.current;
|
||||||
|
if (!viewport) return;
|
||||||
|
viewport.focus({ preventScroll: true });
|
||||||
|
setTimeout(() => {
|
||||||
|
viewport.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key, bubbles: true }),
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keys for an inline `:query` picker, pressed in the textarea. */
|
||||||
|
const handleInlineEmojiKey = (
|
||||||
|
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||||
|
): boolean => {
|
||||||
|
const plain = !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
|
||||||
|
if ((e.key === "Enter" || e.key === "Tab") && plain) {
|
||||||
|
const first = firstEmoji();
|
||||||
|
if (!first) {
|
||||||
|
// Nothing to pick (no match, or the data is still loading): the
|
||||||
|
// key does what it would have.
|
||||||
|
closeEmoji();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
first.click();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Every arrow steps into the grid, left and right included: while a
|
||||||
|
// `:query` is open they pick among its results, not move the caret
|
||||||
|
// (Escape first to get the caret back).
|
||||||
|
if (e.key.startsWith("Arrow") && plain) {
|
||||||
|
if (!firstEmoji()) return false;
|
||||||
|
e.preventDefault();
|
||||||
|
arrowedRef.current = true;
|
||||||
|
enterPicker(e.key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
closeEmoji();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openBrowser = () => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (emojiMode === "browse") {
|
||||||
|
closeEmoji();
|
||||||
|
el.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeMention();
|
||||||
|
openEmojiBrowser(el.selectionStart, el.selectionEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showCount = maxLength != null &&
|
||||||
|
value.length >= (countFrom ?? Math.floor(maxLength * 0.8));
|
||||||
|
const describedBy = [ariaDescribedBy, notice ? noticeId : null]
|
||||||
|
.filter(Boolean).join(" ") || undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`mention-textarea-wrap${
|
className={`mention-textarea-wrap${
|
||||||
dragOver ? " mention-textarea-wrap--dragover" : ""
|
dragOver ? " mention-textarea-wrap--dragover" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<div className="text-editor-field">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleMentionChange(e);
|
const next = e.target.value;
|
||||||
detectEmojiTrigger(e.target.value, e.target.selectionStart ?? 0);
|
const cursor = e.target.selectionStart ?? next.length;
|
||||||
|
onChange(next);
|
||||||
|
if (notice) setNotice(null);
|
||||||
|
syncMention(next, cursor, true);
|
||||||
|
syncEmojiTrigger(next, cursor, true);
|
||||||
}}
|
}}
|
||||||
|
onSelect={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
syncMention(el.value, el.selectionStart, false);
|
||||||
|
syncEmojiTrigger(el.value, el.selectionStart, false);
|
||||||
|
}}
|
||||||
|
onBlur={() => closeMention()}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
// Enter while an IME is composing confirms the candidate; it
|
||||||
|
// mustn't also pick, send or save.
|
||||||
|
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
||||||
|
// Nothing is sent or saved with an image still on its way.
|
||||||
|
if (uploading && e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (emojiMode === "inline" && handleInlineEmojiKey(e)) return;
|
||||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSubmit?.();
|
onSubmit?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleMentionKeyDown(e);
|
if (handleMentionKeyDown(e)) return;
|
||||||
if (!e.defaultPrevented) onKeyDown?.(e);
|
if (e.key === "Escape" && onCancel) {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onKeyDown?.(e);
|
||||||
}}
|
}}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
placeholder={uploading ? "Uploading image…" : placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled || uploading}
|
disabled={disabled}
|
||||||
|
readOnly={uploading}
|
||||||
|
aria-busy={uploading || undefined}
|
||||||
|
aria-invalid={ariaInvalid ||
|
||||||
|
(maxLength != null && value.length > maxLength) || undefined}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls={mentionOpen ? mentionListId : undefined}
|
||||||
|
aria-activedescendant={mentionOpen
|
||||||
|
? `${mentionListId}-${mentionSelectedIndex}`
|
||||||
|
: undefined}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
id={id}
|
id={id}
|
||||||
className={className}
|
className={className}
|
||||||
/>
|
/>
|
||||||
{maxLength != null && value.length >= countFrom && (
|
<button
|
||||||
|
ref={emojiButtonRef}
|
||||||
|
type="button"
|
||||||
|
className={`text-editor-emoji-btn${
|
||||||
|
emojiMode === "browse" ? " text-editor-emoji-btn--open" : ""
|
||||||
|
}`}
|
||||||
|
// Keeps the textarea's caret (and focus) where it was.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={openBrowser}
|
||||||
|
disabled={disabled || uploading}
|
||||||
|
aria-label={t`Insert emoji`}
|
||||||
|
aria-expanded={emojiMode === "browse"}
|
||||||
|
title={t`Insert emoji`}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M8.5 14.5c.9 1.2 2.1 1.8 3.5 1.8s2.6-.6 3.5-1.8" />
|
||||||
|
<circle cx="9" cy="10" r=".6" />
|
||||||
|
<circle cx="15" cy="10" r=".6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(uploading || notice || showCount) && (
|
||||||
|
<div className="text-editor-footer">
|
||||||
|
{(uploading || notice) && (
|
||||||
|
<span
|
||||||
|
id={noticeId}
|
||||||
|
className={`text-editor-notice${
|
||||||
|
notice ? " text-editor-notice--error" : ""
|
||||||
|
}`}
|
||||||
|
role={notice ? "alert" : "status"}
|
||||||
|
>
|
||||||
|
{uploading ? <Trans>Uploading image…</Trans> : notice}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showCount && (
|
||||||
<span
|
<span
|
||||||
className={`text-editor-count${
|
className={`text-editor-count${
|
||||||
charCountClass(value.length, maxLength)
|
charCountClass(value.length, maxLength)
|
||||||
@@ -255,8 +564,11 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
|||||||
{value.length} / {maxLength}
|
{value.length} / {maxLength}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{mentionOpen && createPortal(
|
{mentionOpen && createPortal(
|
||||||
<MentionDropdown
|
<MentionDropdown
|
||||||
|
id={mentionListId}
|
||||||
results={mentionResults}
|
results={mentionResults}
|
||||||
selectedIndex={mentionSelectedIndex}
|
selectedIndex={mentionSelectedIndex}
|
||||||
onSelect={handleMentionSelect}
|
onSelect={handleMentionSelect}
|
||||||
@@ -266,6 +578,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
|||||||
)}
|
)}
|
||||||
{emojiOpen && createPortal(
|
{emojiOpen && createPortal(
|
||||||
<div
|
<div
|
||||||
|
ref={floatRef}
|
||||||
className="emoji-picker-float"
|
className="emoji-picker-float"
|
||||||
style={emojiStyle ?? { visibility: "hidden" }}
|
style={emojiStyle ?? { visibility: "hidden" }}
|
||||||
onKeyDownCapture={(e) => {
|
onKeyDownCapture={(e) => {
|
||||||
@@ -273,47 +586,74 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
closeEmoji();
|
closeEmoji();
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
// Redirect printable characters to the search input when it
|
|
||||||
// doesn't already have focus (e.g. while navigating with arrows)
|
|
||||||
if (
|
|
||||||
e.key.length === 1 &&
|
|
||||||
!e.ctrlKey && !e.metaKey && !e.altKey &&
|
|
||||||
document.activeElement !== emojiSearchRef.current
|
|
||||||
) {
|
|
||||||
const search = emojiSearchRef.current;
|
const search = emojiSearchRef.current;
|
||||||
if (search) {
|
if (e.key.startsWith("Arrow")) arrowedRef.current = true;
|
||||||
|
// Enter takes the emoji arrowed to, or else the best match —
|
||||||
|
// not the first result frimousse highlights on its own.
|
||||||
|
if (e.key === "Enter" && !arrowedRef.current) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
search.focus({ preventScroll: true });
|
e.stopPropagation();
|
||||||
// Inject the character via the native setter so React's
|
firstEmoji()?.click();
|
||||||
// synthetic onChange fires and frimousse updates its search state
|
return;
|
||||||
const setter = Object.getOwnPropertyDescriptor(
|
|
||||||
globalThis.HTMLInputElement.prototype,
|
|
||||||
"value",
|
|
||||||
)?.set;
|
|
||||||
setter?.call(search, search.value + e.key);
|
|
||||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
}
|
}
|
||||||
|
const printable = e.key.length === 1 && !e.ctrlKey &&
|
||||||
|
!e.metaKey && !e.altKey;
|
||||||
|
if (
|
||||||
|
(!printable && e.key !== "Backspace") ||
|
||||||
|
document.activeElement === search
|
||||||
|
) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (emojiMode === "inline") {
|
||||||
|
// Typing while arrowing through the grid goes on with the
|
||||||
|
// `:query` in the textarea, which the search follows.
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.focus({ preventScroll: true });
|
||||||
|
document.execCommand(
|
||||||
|
printable ? "insertText" : "delete",
|
||||||
|
false,
|
||||||
|
printable ? e.key : undefined,
|
||||||
|
);
|
||||||
|
} else if (search) {
|
||||||
|
search.focus({ preventScroll: true });
|
||||||
|
setEmojiQuery(
|
||||||
|
printable ? search.value + e.key : search.value.slice(0, -1),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EmojiPicker.Root
|
<EmojiPicker.Root
|
||||||
onEmojiSelect={(e) => handleEmojiSelect(e.emoji)}
|
onEmojiSelect={(e) => selectEmoji(e.emoji)}
|
||||||
locale={i18n.locale as EmojiLocale}
|
locale={i18n.locale as EmojiLocale}
|
||||||
|
skinTone={readSkinTone()}
|
||||||
>
|
>
|
||||||
|
<SkinTonePersist />
|
||||||
<div className="emoji-picker-search-row">
|
<div className="emoji-picker-search-row">
|
||||||
<EmojiPicker.Search
|
<EmojiPicker.Search
|
||||||
ref={emojiSearchRef}
|
ref={emojiSearchRef}
|
||||||
defaultValue={emojiQuery}
|
value={emojiQuery}
|
||||||
placeholder="Search emoji…"
|
onChange={(e) => setEmojiQuery(e.target.value)}
|
||||||
|
placeholder={t`Search emoji…`}
|
||||||
|
aria-label={t`Search emoji`}
|
||||||
|
// Inline, the query is typed in the textarea; the field
|
||||||
|
// only shows it.
|
||||||
|
readOnly={emojiMode === "inline"}
|
||||||
|
tabIndex={emojiMode === "inline" ? -1 : undefined}
|
||||||
|
/>
|
||||||
|
<EmojiPicker.SkinToneSelector
|
||||||
|
aria-label={t`Change skin tone`}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="emoji-picker-close-btn"
|
className="emoji-picker-close-btn"
|
||||||
onClick={closeEmoji}
|
onClick={() => {
|
||||||
aria-label="Close emoji picker"
|
closeEmoji();
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}}
|
||||||
|
aria-label={t`Close emoji picker`}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
type FieldValues,
|
type FieldValues,
|
||||||
@@ -14,6 +15,7 @@ interface RichTextFieldProps<T extends FieldValues> {
|
|||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
/** Counted in the editor and enforced on submit, with a message saying so. */
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
autoResize?: boolean;
|
autoResize?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -42,8 +44,18 @@ export function RichTextField<T extends FieldValues>({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
}: RichTextFieldProps<T>) {
|
}: RichTextFieldProps<T>) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
|
const hintId = useId();
|
||||||
const { control, formState: { errors } } = useFormContext<T>();
|
const { control, formState: { errors } } = useFormContext<T>();
|
||||||
const error = errors[name];
|
const error = errors[name];
|
||||||
|
// A bare numeric rule fails without a message, which left a submit that did
|
||||||
|
// nothing and said nothing.
|
||||||
|
const allRules = maxLength == null ? rules : {
|
||||||
|
...rules,
|
||||||
|
maxLength: {
|
||||||
|
value: maxLength,
|
||||||
|
message: t`At most ${maxLength} characters.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
@@ -55,7 +67,7 @@ export function RichTextField<T extends FieldValues>({
|
|||||||
<Controller
|
<Controller
|
||||||
name={name}
|
name={name}
|
||||||
control={control}
|
control={control}
|
||||||
rules={rules}
|
rules={allRules}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextEditor
|
<TextEditor
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
@@ -68,11 +80,13 @@ export function RichTextField<T extends FieldValues>({
|
|||||||
autoResize={autoResize}
|
autoResize={autoResize}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
aria-describedby={error?.message ? hintId : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{error?.message && (
|
{error?.message && (
|
||||||
<span className="form-hint form-hint--error">
|
<span id={hintId} className="form-hint form-hint--error">
|
||||||
{String(error.message)}
|
{String(error.message)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,81 +1,97 @@
|
|||||||
import { type RefObject, useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { insideCode } from "../utils/editorText.ts";
|
||||||
|
|
||||||
// Trigger: ':' not preceded by a word character, followed by a letter then
|
// Trigger: ':' not preceded by a letter, digit or another ':', followed by a
|
||||||
// optional word chars / '+' / '-'. Requiring a letter first prevents ASCII
|
// letter then at least one more word char / '+' / '-'. Two characters, not
|
||||||
// emoticons like ':-' or ':(' from opening the picker.
|
// one, so emoticons like ':D' or ':P' — and "note:" or "12:30" — don't open
|
||||||
const TRIGGER_RE = /(?<![A-Za-z0-9_]):([A-Za-z][A-Za-z0-9_+-]*)$/;
|
// the picker; Unicode letters so French names ("coeur", "cœur") search too.
|
||||||
|
const TRIGGER_RE = /(?<![\p{L}\p{N}_:]):(\p{L}[\p{L}\p{N}_+-]+)$/u;
|
||||||
|
|
||||||
interface EmojiTriggerState {
|
/**
|
||||||
open: boolean;
|
* `inline`: opened by typing `:query` — focus stays in the textarea, the
|
||||||
|
* picker's search follows what's typed, and a pick replaces the `:query`.
|
||||||
|
* `browse`: opened from the button — focus goes to the picker's own search,
|
||||||
|
* and a pick lands at the caret.
|
||||||
|
*/
|
||||||
|
export type EmojiMode = "closed" | "inline" | "browse";
|
||||||
|
|
||||||
|
interface EmojiState {
|
||||||
|
mode: EmojiMode;
|
||||||
query: string;
|
query: string;
|
||||||
|
/** The range of the textarea's value a pick replaces. */
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CLOSED: EmojiState = { mode: "closed", query: "", start: 0, end: 0 };
|
||||||
|
|
||||||
export function useEmojiTrigger(
|
export function useEmojiTrigger(
|
||||||
value: string,
|
/** Replaces `[start, end)` of the textarea's value, with a space after
|
||||||
onChange: (value: string) => void,
|
* when `word` is set; see TextEditor. */
|
||||||
textareaRef: RefObject<HTMLTextAreaElement | null>,
|
replace: (start: number, end: number, text: string, word: boolean) => void,
|
||||||
) {
|
) {
|
||||||
const [state, setState] = useState<EmojiTriggerState>({
|
const [state, setStateRaw] = useState<EmojiState>(CLOSED);
|
||||||
open: false,
|
// Mirrored synchronously, so a change and a caret move in the same event
|
||||||
query: "",
|
// each see what the other just decided.
|
||||||
});
|
const stateRef = useRef(state);
|
||||||
// Saved positions at the moment the trigger was detected
|
const setState = useCallback((next: EmojiState) => {
|
||||||
const triggerStartRef = useRef(0);
|
stateRef.current = next;
|
||||||
const triggerCursorRef = useRef(0);
|
setStateRaw(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call this from the textarea's onChange (alongside the mention handler).
|
* Call on every change (with `open`) and caret move (without): follows an
|
||||||
* It receives the new value and cursor position already read from the event.
|
* inline `:query` as it's typed, and closes once the caret leaves it.
|
||||||
|
* Leaves a browse-mode picker alone.
|
||||||
*/
|
*/
|
||||||
const detectEmojiTrigger = useCallback(
|
const syncEmojiTrigger = useCallback(
|
||||||
(newValue: string, cursor: number) => {
|
(value: string, cursor: number, open: boolean) => {
|
||||||
const textBefore = newValue.slice(0, cursor);
|
const s = stateRef.current;
|
||||||
const match = TRIGGER_RE.exec(textBefore);
|
if (s.mode === "browse") return;
|
||||||
|
if (!open && s.mode === "closed") return;
|
||||||
|
const before = value.slice(0, cursor);
|
||||||
|
const match = insideCode(before) ? null : TRIGGER_RE.exec(before);
|
||||||
if (match) {
|
if (match) {
|
||||||
triggerStartRef.current = match.index;
|
setState({
|
||||||
triggerCursorRef.current = cursor;
|
mode: "inline",
|
||||||
setState({ open: true, query: match[1] });
|
query: match[1],
|
||||||
} else {
|
start: match.index,
|
||||||
setState((s) => s.open ? { open: false, query: "" } : s);
|
end: cursor,
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when frimousse fires onEmojiSelect.
|
|
||||||
* Replaces the ':query' text with the selected emoji.
|
|
||||||
*/
|
|
||||||
const handleEmojiSelect = useCallback(
|
|
||||||
(native: string) => {
|
|
||||||
const start = triggerStartRef.current;
|
|
||||||
const cursor = triggerCursorRef.current;
|
|
||||||
const newValue = value.slice(0, start) + native + " " +
|
|
||||||
value.slice(cursor);
|
|
||||||
onChange(newValue);
|
|
||||||
setState({ open: false, query: "" });
|
|
||||||
const newPos = start + native.length + 1; // +1 for trailing space
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const el = textareaRef.current;
|
|
||||||
if (el) {
|
|
||||||
el.focus();
|
|
||||||
el.setSelectionRange(newPos, newPos);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
} else if (s.mode !== "closed") {
|
||||||
|
setState(CLOSED);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[value, onChange, textareaRef],
|
[setState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeEmoji = useCallback(() => {
|
const openEmojiBrowser = useCallback((start: number, end: number) => {
|
||||||
setState({ open: false, query: "" });
|
setState({ mode: "browse", query: "", start, end });
|
||||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
}, [setState]);
|
||||||
}, [textareaRef]);
|
|
||||||
|
const setEmojiQuery = useCallback((query: string) => {
|
||||||
|
const s = stateRef.current;
|
||||||
|
if (s.mode !== "closed") setState({ ...s, query });
|
||||||
|
}, [setState]);
|
||||||
|
|
||||||
|
const selectEmoji = useCallback((native: string) => {
|
||||||
|
const { mode, start, end } = stateRef.current;
|
||||||
|
if (mode === "closed") return;
|
||||||
|
setState(CLOSED);
|
||||||
|
// An inline pick finishes a word, so it takes a space after it the way a
|
||||||
|
// mention does; a browse pick goes in exactly where the caret was.
|
||||||
|
replace(start, end, native, mode === "inline");
|
||||||
|
}, [replace, setState]);
|
||||||
|
|
||||||
|
const closeEmoji = useCallback(() => setState(CLOSED), [setState]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
emojiOpen: state.open,
|
emojiMode: state.mode,
|
||||||
emojiQuery: state.query,
|
emojiQuery: state.query,
|
||||||
detectEmojiTrigger,
|
syncEmojiTrigger,
|
||||||
handleEmojiSelect,
|
openEmojiBrowser,
|
||||||
|
setEmojiQuery,
|
||||||
|
selectEmoji,
|
||||||
closeEmoji,
|
closeEmoji,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import {
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
type RefObject,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import { insideCode } from "../utils/editorText.ts";
|
||||||
|
|
||||||
export interface UserResult {
|
export interface UserResult {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,7 +11,10 @@ export interface UserResult {
|
|||||||
interface MentionState {
|
interface MentionState {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
query: string;
|
query: string;
|
||||||
|
/** Where the `@` sits, and the caret after the query: the range a pick
|
||||||
|
* replaces. */
|
||||||
start: number;
|
start: number;
|
||||||
|
end: number;
|
||||||
results: UserResult[];
|
results: UserResult[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
}
|
}
|
||||||
@@ -25,146 +23,170 @@ const CLOSED: MentionState = {
|
|||||||
open: false,
|
open: false,
|
||||||
query: "",
|
query: "",
|
||||||
start: 0,
|
start: 0,
|
||||||
|
end: 0,
|
||||||
results: [],
|
results: [],
|
||||||
selectedIndex: 0,
|
selectedIndex: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The server's mention rule (api/services/notification-service.ts): an `@` not
|
||||||
|
// preceded by `[`, `(` or a word character, so what's offered is exactly what
|
||||||
|
// would notify.
|
||||||
|
const TRIGGER_RE = /(?<![[(\w])@(\w+)$/;
|
||||||
|
|
||||||
function getMentionQuery(
|
function getMentionQuery(
|
||||||
value: string,
|
value: string,
|
||||||
pos: number,
|
pos: number,
|
||||||
): { query: string; start: number } | null {
|
): { query: string; start: number } | null {
|
||||||
const textBefore = value.slice(0, pos);
|
const textBefore = value.slice(0, pos);
|
||||||
// Match @word at end of text before cursor, not preceded by [ ( or word char
|
if (insideCode(textBefore)) return null;
|
||||||
const match = textBefore.match(/(?<![[(A-Za-z0-9_])@([A-Za-z0-9_]*)$/);
|
const match = TRIGGER_RE.exec(textBefore);
|
||||||
if (!match || match[1].length === 0) return null;
|
if (!match) return null;
|
||||||
const start = pos - match[0].length;
|
return { query: match[1], start: match.index };
|
||||||
return { query: match[1], start };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMentionAutocomplete(
|
export function useMentionAutocomplete(
|
||||||
value: string,
|
/** Replaces `[start, end)` of the textarea's value, with a space after
|
||||||
onChange: (v: string) => void,
|
* when `word` is set; see TextEditor. */
|
||||||
textareaRef: RefObject<HTMLTextAreaElement | null>,
|
replace: (start: number, end: number, text: string, word: boolean) => void,
|
||||||
) {
|
) {
|
||||||
const [state, setState] = useState<MentionState>(CLOSED);
|
const [state, setStateRaw] = useState<MentionState>(CLOSED);
|
||||||
|
// Mirrored synchronously so the key handler and a fetch landing late both
|
||||||
|
// read what the last keystroke decided, not the last render.
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
const setState = useCallback((next: MentionState) => {
|
||||||
|
stateRef.current = next;
|
||||||
|
setStateRaw(next);
|
||||||
|
}, []);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const cursorRef = useRef<number>(0);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const handleMentionChange = useCallback(
|
const cancelLookup = useCallback(() => {
|
||||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const newValue = e.target.value;
|
|
||||||
const pos = e.target.selectionStart ?? newValue.length;
|
|
||||||
cursorRef.current = pos;
|
|
||||||
onChange(newValue);
|
|
||||||
|
|
||||||
const mention = getMentionQuery(newValue, pos);
|
|
||||||
if (!mention) {
|
|
||||||
setState(CLOSED);
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = null;
|
||||||
|
abortRef.current?.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
cancelLookup();
|
||||||
|
if (stateRef.current !== CLOSED) setState(CLOSED);
|
||||||
|
}, [cancelLookup, setState]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call on every change (with `lookup`) and caret move (without): tracks the
|
||||||
|
* `@query` under the caret, searching for it after a short pause, and closes
|
||||||
|
* once the caret leaves it.
|
||||||
|
*/
|
||||||
|
const syncMention = useCallback(
|
||||||
|
(value: string, pos: number, lookup: boolean) => {
|
||||||
|
const mention = getMentionQuery(value, pos);
|
||||||
|
const s = stateRef.current;
|
||||||
|
if (!mention) {
|
||||||
|
close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!lookup) {
|
||||||
|
// A caret move within the same @query keeps the list; one that lands
|
||||||
|
// on some other @word doesn't open anything.
|
||||||
|
if (mention.start !== s.start || mention.query !== s.query) close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mention.query === s.query && mention.start === s.start) return;
|
||||||
|
|
||||||
setState((s) => ({
|
setState({
|
||||||
...s,
|
...s,
|
||||||
open: false,
|
|
||||||
query: mention.query,
|
query: mention.query,
|
||||||
start: mention.start,
|
start: mention.start,
|
||||||
|
end: pos,
|
||||||
selectedIndex: 0,
|
selectedIndex: 0,
|
||||||
}));
|
});
|
||||||
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
cancelLookup();
|
||||||
debounceRef.current = setTimeout(async () => {
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_URL}/api/users/search?q=${
|
`${API_URL}/api/users/search?q=${
|
||||||
encodeURIComponent(mention.query)
|
encodeURIComponent(mention.query)
|
||||||
}`,
|
}`,
|
||||||
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
const current = stateRef.current;
|
||||||
|
// Only the lookup for what's typed now may open or close the list.
|
||||||
|
if (current.query !== mention.query) return;
|
||||||
if (body.success && body.data.length > 0) {
|
if (body.success && body.data.length > 0) {
|
||||||
setState((s) =>
|
setState({
|
||||||
s.query === mention.query
|
...current,
|
||||||
? { ...s, open: true, results: body.data, selectedIndex: 0 }
|
open: true,
|
||||||
: s
|
results: body.data,
|
||||||
);
|
selectedIndex: 0,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(CLOSED);
|
setState({ ...current, open: false, results: [] });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setState(CLOSED);
|
// Aborted by a newer keystroke, or offline: nothing to offer.
|
||||||
}
|
}
|
||||||
}, 150);
|
}, 150);
|
||||||
},
|
},
|
||||||
[onChange],
|
[cancelLookup, close, setState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const doSelect = useCallback(
|
const select = useCallback(
|
||||||
(username: string, start: number, cursorPos: number) => {
|
(username: string) => {
|
||||||
const before = value.slice(0, start);
|
const { start, end } = stateRef.current;
|
||||||
const after = value.slice(cursorPos);
|
close();
|
||||||
onChange(`${before}@${username} ${after}`);
|
replace(start, end, `@${username}`, true);
|
||||||
setState(CLOSED);
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
setTimeout(() => {
|
|
||||||
const el = textareaRef.current;
|
|
||||||
if (el) {
|
|
||||||
const newPos = start + username.length + 2; // @ + username + space
|
|
||||||
el.focus();
|
|
||||||
el.setSelectionRange(newPos, newPos);
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
},
|
},
|
||||||
[value, onChange, textareaRef],
|
[close, replace],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Returns whether it handled the key. */
|
||||||
const handleMentionKeyDown = useCallback(
|
const handleMentionKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
||||||
if (!state.open || state.results.length === 0) return;
|
const s = stateRef.current;
|
||||||
if (e.key === "ArrowDown") {
|
if (!s.open || s.results.length === 0) return false;
|
||||||
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setState((s) => ({
|
const n = s.results.length;
|
||||||
...s,
|
const step = e.key === "ArrowDown" ? 1 : -1;
|
||||||
selectedIndex: Math.min(s.selectedIndex + 1, s.results.length - 1),
|
// Wraps, like any listbox.
|
||||||
}));
|
setState({ ...s, selectedIndex: (s.selectedIndex + step + n) % n });
|
||||||
} else if (e.key === "ArrowUp") {
|
return true;
|
||||||
e.preventDefault();
|
}
|
||||||
setState((s) => ({
|
if (
|
||||||
...s,
|
(e.key === "Enter" || e.key === "Tab") &&
|
||||||
selectedIndex: Math.max(s.selectedIndex - 1, 0),
|
!e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey
|
||||||
}));
|
|
||||||
} else if (
|
|
||||||
(e.key === "Enter" || e.key === "Tab") && !e.ctrlKey && !e.metaKey
|
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const user = state.results[state.selectedIndex];
|
const user = s.results[s.selectedIndex];
|
||||||
if (user) doSelect(user.username, state.start, cursorRef.current);
|
if (user) select(user.username);
|
||||||
} else if (e.key === "Escape") {
|
return true;
|
||||||
e.preventDefault();
|
|
||||||
setState(CLOSED);
|
|
||||||
}
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
// Just the list: whatever Escape means to the editor's owner (cancel
|
||||||
|
// an edit, close the chat) waits for a second press.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
[state, doSelect],
|
[close, select, setState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMentionSelect = useCallback(
|
useEffect(() => cancelLookup, [cancelLookup]);
|
||||||
(username: string) => {
|
|
||||||
doSelect(username, state.start, cursorRef.current);
|
|
||||||
},
|
|
||||||
[doSelect, state.start],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mentionOpen: state.open,
|
mentionOpen: state.open && state.results.length > 0,
|
||||||
mentionResults: state.results,
|
mentionResults: state.results,
|
||||||
mentionSelectedIndex: state.selectedIndex,
|
mentionSelectedIndex: state.selectedIndex,
|
||||||
handleMentionChange,
|
syncMention,
|
||||||
|
closeMention: close,
|
||||||
handleMentionKeyDown,
|
handleMentionKeyDown,
|
||||||
handleMentionSelect,
|
handleMentionSelect: select,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -13,7 +13,7 @@ msgstr ""
|
|||||||
"Language-Team: \n"
|
"Language-Team: \n"
|
||||||
"Plural-Forms: \n"
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:257
|
#: src/components/CommentThread.tsx:270
|
||||||
msgid "[deleted]"
|
msgid "[deleted]"
|
||||||
msgstr "[deleted]"
|
msgstr "[deleted]"
|
||||||
|
|
||||||
@@ -35,17 +35,17 @@ msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
|
|||||||
|
|
||||||
#. placeholder {0}: typingNames[0]
|
#. placeholder {0}: typingNames[0]
|
||||||
#. placeholder {1}: typingNames[1]
|
#. placeholder {1}: typingNames[1]
|
||||||
#: src/components/ChatModal.tsx:1063
|
#: src/components/ChatModal.tsx:1079
|
||||||
msgid "{0} and {1} are typing…"
|
msgid "{0} and {1} are typing…"
|
||||||
msgstr "{0} and {1} are typing…"
|
msgstr "{0} and {1} are typing…"
|
||||||
|
|
||||||
#. placeholder {0}: typingNames[0]
|
#. placeholder {0}: typingNames[0]
|
||||||
#: src/components/ChatModal.tsx:1061
|
#: src/components/ChatModal.tsx:1077
|
||||||
msgid "{0} is typing…"
|
msgid "{0} is typing…"
|
||||||
msgstr "{0} is typing…"
|
msgstr "{0} is typing…"
|
||||||
|
|
||||||
#. placeholder {0}: rest.length
|
#. placeholder {0}: rest.length
|
||||||
#: src/components/PresenceRow.tsx:44
|
#: src/components/PresenceRow.tsx:66
|
||||||
msgid "{0} more online: {restNames}"
|
msgid "{0} more online: {restNames}"
|
||||||
msgstr "{0} more online: {restNames}"
|
msgstr "{0} more online: {restNames}"
|
||||||
|
|
||||||
@@ -59,15 +59,15 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
|
|||||||
msgid "{label} ({count})"
|
msgid "{label} ({count})"
|
||||||
msgstr "{label} ({count})"
|
msgstr "{label} ({count})"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1192
|
#: src/components/ChatModal.tsx:1208
|
||||||
msgid "{unseenCount} new messages"
|
msgid "{unseenCount} new messages"
|
||||||
msgstr "{unseenCount} new messages"
|
msgstr "{unseenCount} new messages"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:462
|
#: src/components/CommentThread.tsx:475
|
||||||
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:943
|
#: src/components/DumpCreateModal.tsx:942
|
||||||
#: src/pages/PlaylistDetail.tsx:572
|
#: src/pages/PlaylistDetail.tsx:572
|
||||||
#: src/pages/UserPublicProfile.tsx:790
|
#: src/pages/UserPublicProfile.tsx:790
|
||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
@@ -143,7 +143,7 @@ msgstr "<0>{0}</0> upvoted <1>{1}</1>"
|
|||||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||||
msgstr "<0>{0}</0> was added to <1>{1}</1>"
|
msgstr "<0>{0}</0> was added to <1>{1}</1>"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1191
|
#: src/components/ChatModal.tsx:1207
|
||||||
msgid "1 new message"
|
msgid "1 new message"
|
||||||
msgstr "1 new message"
|
msgstr "1 new message"
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ msgstr "a post"
|
|||||||
msgid "Account"
|
msgid "Account"
|
||||||
msgstr "Account"
|
msgstr "Account"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:482
|
#: src/components/CommentThread.tsx:495
|
||||||
msgid "Add a comment…"
|
msgid "Add a comment…"
|
||||||
msgstr "Add a comment…"
|
msgstr "Add a comment…"
|
||||||
|
|
||||||
@@ -207,6 +207,10 @@ msgstr "Appearance"
|
|||||||
msgid "At least {0} characters"
|
msgid "At least {0} characters"
|
||||||
msgstr "At least {0} characters"
|
msgstr "At least {0} characters"
|
||||||
|
|
||||||
|
#: src/components/form/RichTextField.tsx:56
|
||||||
|
msgid "At most {maxLength} characters."
|
||||||
|
msgstr "At most {maxLength} characters."
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1252
|
#: src/pages/UserPublicProfile.tsx:1252
|
||||||
msgid "Auto"
|
msgid "Auto"
|
||||||
msgstr "Auto"
|
msgstr "Auto"
|
||||||
@@ -223,16 +227,16 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
|
|||||||
|
|
||||||
#: src/components/CategoryManager.tsx:210
|
#: src/components/CategoryManager.tsx:210
|
||||||
#: src/components/CategoryManager.tsx:211
|
#: src/components/CategoryManager.tsx:211
|
||||||
#: src/components/ChatModal.tsx:224
|
#: src/components/ChatModal.tsx:235
|
||||||
#: src/components/CommentThread.tsx:121
|
#: src/components/CommentThread.tsx:129
|
||||||
#: src/components/ConfirmModal.tsx:34
|
#: src/components/ConfirmModal.tsx:34
|
||||||
#: src/components/DumpCreateModal.tsx:953
|
#: src/components/DumpCreateModal.tsx:952
|
||||||
#: src/components/form/FormActions.tsx:32
|
#: src/components/form/FormActions.tsx:32
|
||||||
#: src/pages/Dump.tsx:402
|
#: src/pages/Dump.tsx:402
|
||||||
#: src/pages/DumpEdit.tsx:484
|
#: src/pages/DumpEdit.tsx:483
|
||||||
#: src/pages/PlaylistDetail.tsx:923
|
#: src/pages/PlaylistDetail.tsx:929
|
||||||
#: src/pages/UserPublicProfile.tsx:1764
|
#: src/pages/UserPublicProfile.tsx:1764
|
||||||
#: src/pages/UserPublicProfile.tsx:1837
|
#: src/pages/UserPublicProfile.tsx:1838
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Cancel"
|
msgstr "Cancel"
|
||||||
|
|
||||||
@@ -240,12 +244,12 @@ msgstr "Cancel"
|
|||||||
msgid "Cancel removal"
|
msgid "Cancel removal"
|
||||||
msgstr "Cancel removal"
|
msgstr "Cancel removal"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1252
|
#: src/components/ChatModal.tsx:1268
|
||||||
#: src/components/ChatModal.tsx:1253
|
#: src/components/ChatModal.tsx:1269
|
||||||
msgid "Cancel reply"
|
msgid "Cancel reply"
|
||||||
msgstr "Cancel reply"
|
msgstr "Cancel reply"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:139
|
#: src/components/AppHeader.tsx:127
|
||||||
msgid "Cancel search"
|
msgid "Cancel search"
|
||||||
msgstr "Cancel search"
|
msgstr "Cancel search"
|
||||||
|
|
||||||
@@ -271,10 +275,14 @@ msgstr "Change password"
|
|||||||
msgid "Change password…"
|
msgid "Change password…"
|
||||||
msgstr "Change password…"
|
msgstr "Change password…"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:619
|
||||||
|
msgid "Change skin tone"
|
||||||
|
msgstr "Change skin tone"
|
||||||
|
|
||||||
#: src/components/ChatButton.tsx:17
|
#: src/components/ChatButton.tsx:17
|
||||||
#: src/components/ChatFab.tsx:49
|
#: src/components/ChatFab.tsx:49
|
||||||
#: src/components/ChatFab.tsx:50
|
#: src/components/ChatFab.tsx:50
|
||||||
#: src/components/ChatModal.tsx:1152
|
#: src/components/ChatModal.tsx:1168
|
||||||
msgid "Chat"
|
msgid "Chat"
|
||||||
msgstr "Chat"
|
msgstr "Chat"
|
||||||
|
|
||||||
@@ -296,6 +304,10 @@ msgstr "Close"
|
|||||||
msgid "Close anyway"
|
msgid "Close anyway"
|
||||||
msgstr "Close anyway"
|
msgstr "Close anyway"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:628
|
||||||
|
msgid "Close emoji picker"
|
||||||
|
msgstr "Close emoji picker"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:242
|
#: src/components/GlobalPlayer.tsx:242
|
||||||
msgid "Close player"
|
msgid "Close player"
|
||||||
msgstr "Close player"
|
msgstr "Close player"
|
||||||
@@ -345,15 +357,15 @@ msgstr "Could not save"
|
|||||||
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
|
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
|
||||||
msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
|
msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1078
|
#: src/components/ChatModal.tsx:1094
|
||||||
msgid "Couldn't load the chat."
|
msgid "Couldn't load the chat."
|
||||||
msgstr "Couldn't load the chat."
|
msgstr "Couldn't load the chat."
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:91
|
#: src/components/PlaylistCreateForm.tsx:90
|
||||||
msgid "Create"
|
msgid "Create"
|
||||||
msgstr "Create"
|
msgstr "Create"
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:91
|
#: src/components/PlaylistCreateForm.tsx:90
|
||||||
msgid "Create & Add"
|
msgid "Create & Add"
|
||||||
msgstr "Create & Add"
|
msgstr "Create & Add"
|
||||||
|
|
||||||
@@ -363,7 +375,7 @@ msgstr "Create & Add"
|
|||||||
msgid "Created ({0}{1})"
|
msgid "Created ({0}{1})"
|
||||||
msgstr "Created ({0}{1})"
|
msgstr "Created ({0}{1})"
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:90
|
#: src/components/PlaylistCreateForm.tsx:89
|
||||||
msgid "Creating…"
|
msgid "Creating…"
|
||||||
msgstr "Creating…"
|
msgstr "Creating…"
|
||||||
|
|
||||||
@@ -381,13 +393,13 @@ msgstr "Default tab"
|
|||||||
|
|
||||||
#: src/components/CategoryManager.tsx:221
|
#: src/components/CategoryManager.tsx:221
|
||||||
#: src/components/CategoryManager.tsx:222
|
#: src/components/CategoryManager.tsx:222
|
||||||
#: src/components/ChatModal.tsx:336
|
#: src/components/ChatModal.tsx:347
|
||||||
#: src/components/ChatModal.tsx:337
|
#: src/components/ChatModal.tsx:348
|
||||||
#: src/components/ChatModal.tsx:431
|
#: src/components/ChatModal.tsx:446
|
||||||
#: src/components/CommentThread.tsx:382
|
#: src/components/CommentThread.tsx:395
|
||||||
#: src/components/CommentThread.tsx:388
|
#: src/components/CommentThread.tsx:401
|
||||||
#: src/components/ConfirmModal.tsx:17
|
#: src/components/ConfirmModal.tsx:17
|
||||||
#: src/pages/PlaylistDetail.tsx:919
|
#: src/pages/PlaylistDetail.tsx:925
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Delete"
|
msgstr "Delete"
|
||||||
|
|
||||||
@@ -401,7 +413,7 @@ msgid "Delete category \"{0}\"? This cannot be undone."
|
|||||||
msgstr "Delete category \"{0}\"? This cannot be undone."
|
msgstr "Delete category \"{0}\"? This cannot be undone."
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:265
|
#: src/pages/DumpEdit.tsx:265
|
||||||
#: src/pages/DumpEdit.tsx:480
|
#: src/pages/DumpEdit.tsx:479
|
||||||
msgid "Delete dump"
|
msgid "Delete dump"
|
||||||
msgstr "Delete dump"
|
msgstr "Delete dump"
|
||||||
|
|
||||||
@@ -411,7 +423,7 @@ msgstr "Delete dump"
|
|||||||
msgid "Delete playlist"
|
msgid "Delete playlist"
|
||||||
msgstr "Delete playlist"
|
msgstr "Delete playlist"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:387
|
#: src/components/CommentThread.tsx:400
|
||||||
msgid "Delete this comment?"
|
msgid "Delete this comment?"
|
||||||
msgstr "Delete this comment?"
|
msgstr "Delete this comment?"
|
||||||
|
|
||||||
@@ -419,7 +431,7 @@ msgstr "Delete this comment?"
|
|||||||
msgid "Delete this dump? This cannot be undone."
|
msgid "Delete this dump? This cannot be undone."
|
||||||
msgstr "Delete this dump? This cannot be undone."
|
msgstr "Delete this dump? This cannot be undone."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:430
|
#: src/components/ChatModal.tsx:445
|
||||||
msgid "Delete this message?"
|
msgid "Delete this message?"
|
||||||
msgstr "Delete this message?"
|
msgstr "Delete this message?"
|
||||||
|
|
||||||
@@ -428,25 +440,25 @@ msgstr "Delete this message?"
|
|||||||
msgid "Delete this playlist? This cannot be undone."
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
msgstr "Delete this playlist? This cannot be undone."
|
msgstr "Delete this playlist? This cannot be undone."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:258
|
#: src/components/ChatModal.tsx:269
|
||||||
msgid "deleted message"
|
msgid "deleted message"
|
||||||
msgstr "deleted message"
|
msgstr "deleted message"
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:82
|
#: src/components/PlaylistCreateForm.tsx:82
|
||||||
#: src/pages/PlaylistDetail.tsx:880
|
#: src/pages/PlaylistDetail.tsx:886
|
||||||
msgid "Description (optional)"
|
msgid "Description (optional)"
|
||||||
msgstr "Description (optional)"
|
msgstr "Description (optional)"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:516
|
#: src/components/ChatModal.tsx:533
|
||||||
msgid "Discard"
|
msgid "Discard"
|
||||||
msgstr "Discard"
|
msgstr "Discard"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1156
|
#: src/components/ChatModal.tsx:1172
|
||||||
msgid "Disconnected — reconnecting…"
|
msgid "Disconnected — reconnecting…"
|
||||||
msgstr "Disconnected — reconnecting…"
|
msgstr "Disconnected — reconnecting…"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1228
|
#: src/components/ChatModal.tsx:1244
|
||||||
#: src/components/ChatModal.tsx:1229
|
#: src/components/ChatModal.tsx:1245
|
||||||
msgid "Dismiss"
|
msgid "Dismiss"
|
||||||
msgstr "Dismiss"
|
msgstr "Dismiss"
|
||||||
|
|
||||||
@@ -466,11 +478,11 @@ msgstr "Drop a file here, or paste one"
|
|||||||
msgid "Drop a replacement here"
|
msgid "Drop a replacement here"
|
||||||
msgstr "Drop a replacement here"
|
msgstr "Drop a replacement here"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:179
|
#: src/components/AppHeader.tsx:167
|
||||||
msgid "Dump"
|
msgid "Dump"
|
||||||
msgstr "Dump"
|
msgstr "Dump"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:966
|
#: src/components/DumpCreateModal.tsx:965
|
||||||
msgid "Dump it"
|
msgid "Dump it"
|
||||||
msgstr "Dump it"
|
msgstr "Dump it"
|
||||||
|
|
||||||
@@ -494,10 +506,10 @@ msgstr "Dumps ({0}{1})"
|
|||||||
msgid "Earlier"
|
msgid "Earlier"
|
||||||
msgstr "Earlier"
|
msgstr "Earlier"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:325
|
#: src/components/ChatModal.tsx:336
|
||||||
#: src/components/ChatModal.tsx:326
|
#: src/components/ChatModal.tsx:337
|
||||||
#: src/components/ChatModal.tsx:509
|
#: src/components/ChatModal.tsx:526
|
||||||
#: src/components/CommentThread.tsx:373
|
#: src/components/CommentThread.tsx:386
|
||||||
#: src/pages/Dump.tsx:516
|
#: src/pages/Dump.tsx:516
|
||||||
#: src/pages/PlaylistDetail.tsx:627
|
#: src/pages/PlaylistDetail.tsx:627
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
@@ -512,15 +524,15 @@ msgstr "Edit {0}"
|
|||||||
msgid "Edit title"
|
msgid "Edit title"
|
||||||
msgstr "Edit title"
|
msgstr "Edit title"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:349
|
#: src/components/ChatModal.tsx:360
|
||||||
msgid "edited"
|
msgid "edited"
|
||||||
msgstr "edited"
|
msgstr "edited"
|
||||||
|
|
||||||
#. placeholder {0}: relativeTime(comment.updatedAt)
|
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||||
#: src/components/ChatModal.tsx:350
|
#: src/components/ChatModal.tsx:361
|
||||||
#: src/components/CommentThread.tsx:322
|
#: src/components/CommentThread.tsx:335
|
||||||
#: src/pages/Dump.tsx:455
|
#: src/pages/Dump.tsx:455
|
||||||
#: src/pages/PlaylistDetail.tsx:666
|
#: src/pages/PlaylistDetail.tsx:666
|
||||||
msgid "edited {0}"
|
msgid "edited {0}"
|
||||||
@@ -529,8 +541,8 @@ msgstr "edited {0}"
|
|||||||
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||||
#: src/components/ChatModal.tsx:346
|
#: src/components/ChatModal.tsx:357
|
||||||
#: src/components/CommentThread.tsx:320
|
#: src/components/CommentThread.tsx:333
|
||||||
#: src/pages/Dump.tsx:453
|
#: src/pages/Dump.tsx:453
|
||||||
#: src/pages/PlaylistDetail.tsx:663
|
#: src/pages/PlaylistDetail.tsx:663
|
||||||
msgid "Edited {0}"
|
msgid "Edited {0}"
|
||||||
@@ -561,7 +573,7 @@ msgstr "Expand player"
|
|||||||
msgid "Failed to create category"
|
msgid "Failed to create category"
|
||||||
msgstr "Failed to create category"
|
msgstr "Failed to create category"
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:88
|
#: src/components/PlaylistCreateForm.tsx:87
|
||||||
msgid "Failed to create playlist"
|
msgid "Failed to create playlist"
|
||||||
msgstr "Failed to create playlist"
|
msgstr "Failed to create playlist"
|
||||||
|
|
||||||
@@ -590,17 +602,17 @@ msgstr "Failed to load"
|
|||||||
msgid "Failed to post"
|
msgid "Failed to post"
|
||||||
msgstr "Failed to post"
|
msgstr "Failed to post"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:481
|
#: src/components/CommentThread.tsx:494
|
||||||
msgid "Failed to post comment"
|
msgid "Failed to post comment"
|
||||||
msgstr "Failed to post comment"
|
msgstr "Failed to post comment"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:403
|
#: src/components/CommentThread.tsx:416
|
||||||
msgid "Failed to post reply"
|
msgid "Failed to post reply"
|
||||||
msgstr "Failed to post reply"
|
msgstr "Failed to post reply"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:911
|
#: src/pages/PlaylistDetail.tsx:917
|
||||||
#: src/pages/UserPublicProfile.tsx:1767
|
#: src/pages/UserPublicProfile.tsx:1767
|
||||||
#: src/pages/UserPublicProfile.tsx:1839
|
#: src/pages/UserPublicProfile.tsx:1840
|
||||||
msgid "Failed to save"
|
msgid "Failed to save"
|
||||||
msgstr "Failed to save"
|
msgstr "Failed to save"
|
||||||
|
|
||||||
@@ -608,7 +620,7 @@ msgstr "Failed to save"
|
|||||||
msgid "Failed to save category"
|
msgid "Failed to save category"
|
||||||
msgstr "Failed to save category"
|
msgstr "Failed to save category"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:335
|
#: src/components/CommentThread.tsx:348
|
||||||
msgid "Failed to save edit"
|
msgid "Failed to save edit"
|
||||||
msgstr "Failed to save edit"
|
msgstr "Failed to save edit"
|
||||||
|
|
||||||
@@ -704,7 +716,7 @@ msgstr "Hot"
|
|||||||
msgid "If that address is registered you'll receive a reset link shortly."
|
msgid "If that address is registered you'll receive a reset link shortly."
|
||||||
msgstr "If that address is registered you'll receive a reset link shortly."
|
msgstr "If that address is registered you'll receive a reset link shortly."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:81
|
#: src/components/ChatModal.tsx:95
|
||||||
msgid "image"
|
msgid "image"
|
||||||
msgstr "image"
|
msgstr "image"
|
||||||
|
|
||||||
@@ -712,6 +724,11 @@ msgstr "image"
|
|||||||
msgid "In collections"
|
msgid "In collections"
|
||||||
msgstr "In collections"
|
msgstr "In collections"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:504
|
||||||
|
#: src/components/TextEditor.tsx:506
|
||||||
|
msgid "Insert emoji"
|
||||||
|
msgstr "Insert emoji"
|
||||||
|
|
||||||
#: src/pages/UserRegister.tsx:96
|
#: src/pages/UserRegister.tsx:96
|
||||||
msgid "Invalid invite"
|
msgid "Invalid invite"
|
||||||
msgstr "Invalid invite"
|
msgstr "Invalid invite"
|
||||||
@@ -763,7 +780,7 @@ msgstr "Link or file"
|
|||||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:212
|
#: src/components/AppHeader.tsx:200
|
||||||
msgid "Live updates unavailable."
|
msgid "Live updates unavailable."
|
||||||
msgstr "Live updates unavailable."
|
msgstr "Live updates unavailable."
|
||||||
|
|
||||||
@@ -772,7 +789,7 @@ msgstr "Live updates unavailable."
|
|||||||
msgid "Load more"
|
msgid "Load more"
|
||||||
msgstr "Load more"
|
msgstr "Load more"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1177
|
#: src/components/ChatModal.tsx:1193
|
||||||
msgid "Load older messages"
|
msgid "Load older messages"
|
||||||
msgstr "Load older messages"
|
msgstr "Load older messages"
|
||||||
|
|
||||||
@@ -781,7 +798,7 @@ msgstr "Load older messages"
|
|||||||
msgid "Loading dump…"
|
msgid "Loading dump…"
|
||||||
msgstr "Loading dump…"
|
msgstr "Loading dump…"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1070
|
#: src/components/ChatModal.tsx:1086
|
||||||
msgid "Loading messages…"
|
msgid "Loading messages…"
|
||||||
msgstr "Loading messages…"
|
msgstr "Loading messages…"
|
||||||
|
|
||||||
@@ -806,9 +823,9 @@ msgid "Loading profile…"
|
|||||||
msgstr "Loading profile…"
|
msgstr "Loading profile…"
|
||||||
|
|
||||||
#: src/components/CategoryManager.tsx:52
|
#: src/components/CategoryManager.tsx:52
|
||||||
#: src/components/ChatModal.tsx:1176
|
#: src/components/ChatModal.tsx:1192
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||||
#: src/components/TextEditor.tsx:328
|
#: src/components/TextEditor.tsx:640
|
||||||
#: src/components/UserListPopover.tsx:192
|
#: src/components/UserListPopover.tsx:192
|
||||||
#: src/components/UserListPopover.tsx:230
|
#: src/components/UserListPopover.tsx:230
|
||||||
#: src/pages/index/FollowedFeed.tsx:78
|
#: src/pages/index/FollowedFeed.tsx:78
|
||||||
@@ -826,7 +843,7 @@ msgstr "Loading profile…"
|
|||||||
msgid "Loading…"
|
msgid "Loading…"
|
||||||
msgstr "Loading…"
|
msgstr "Loading…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:187
|
#: src/components/AppHeader.tsx:175
|
||||||
#: src/pages/UserLogin.tsx:35
|
#: src/pages/UserLogin.tsx:35
|
||||||
#: src/pages/UserLogin.tsx:61
|
#: src/pages/UserLogin.tsx:61
|
||||||
#: src/pages/UserLogin.tsx:80
|
#: src/pages/UserLogin.tsx:80
|
||||||
@@ -875,7 +892,7 @@ msgstr "Name"
|
|||||||
msgid "new"
|
msgid "new"
|
||||||
msgstr "new"
|
msgstr "new"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1105
|
#: src/components/ChatModal.tsx:1121
|
||||||
#: src/components/FeedTabBar.tsx:31
|
#: src/components/FeedTabBar.tsx:31
|
||||||
#: src/pages/UserPublicProfile.tsx:1294
|
#: src/pages/UserPublicProfile.tsx:1294
|
||||||
msgid "New"
|
msgid "New"
|
||||||
@@ -899,7 +916,7 @@ msgstr "New password"
|
|||||||
msgid "New playlist"
|
msgid "New playlist"
|
||||||
msgstr "New playlist"
|
msgstr "New playlist"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:965
|
#: src/components/DumpCreateModal.tsx:964
|
||||||
msgid "Next →"
|
msgid "Next →"
|
||||||
msgstr "Next →"
|
msgstr "Next →"
|
||||||
|
|
||||||
@@ -921,7 +938,7 @@ msgstr "No dumps match \"{q}\"."
|
|||||||
msgid "No dumps yet. Be the first!"
|
msgid "No dumps yet. Be the first!"
|
||||||
msgstr "No dumps yet. Be the first!"
|
msgstr "No dumps yet. Be the first!"
|
||||||
|
|
||||||
#: src/components/TextEditor.tsx:331
|
#: src/components/TextEditor.tsx:643
|
||||||
msgid "No emoji found."
|
msgid "No emoji found."
|
||||||
msgstr "No emoji found."
|
msgstr "No emoji found."
|
||||||
|
|
||||||
@@ -934,7 +951,7 @@ msgstr "No followed playlists yet."
|
|||||||
msgid "No invitees yet."
|
msgid "No invitees yet."
|
||||||
msgstr "No invitees yet."
|
msgstr "No invitees yet."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1089
|
#: src/components/ChatModal.tsx:1105
|
||||||
msgid "No messages yet. Say hello!"
|
msgid "No messages yet. Say hello!"
|
||||||
msgstr "No messages yet. Say hello!"
|
msgstr "No messages yet. Say hello!"
|
||||||
|
|
||||||
@@ -991,6 +1008,10 @@ msgstr "Off"
|
|||||||
msgid "On"
|
msgid "On"
|
||||||
msgstr "On"
|
msgstr "On"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:338
|
||||||
|
msgid "Only images can be dropped here."
|
||||||
|
msgstr "Only images can be dropped here."
|
||||||
|
|
||||||
#: src/components/FileDropZone.tsx:134
|
#: src/components/FileDropZone.tsx:134
|
||||||
msgid "or <0>browse files</0>"
|
msgid "or <0>browse files</0>"
|
||||||
msgstr "or <0>browse files</0>"
|
msgstr "or <0>browse files</0>"
|
||||||
@@ -1030,11 +1051,11 @@ msgstr "Paste a link…"
|
|||||||
msgid "Picked up where you left off."
|
msgid "Picked up where you left off."
|
||||||
msgstr "Picked up where you left off."
|
msgstr "Picked up where you left off."
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:866
|
#: src/pages/PlaylistDetail.tsx:870
|
||||||
msgid "Playlist title"
|
msgid "Playlist title"
|
||||||
msgstr "Playlist title"
|
msgstr "Playlist title"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:160
|
#: src/components/AppHeader.tsx:148
|
||||||
#: src/components/DumpCreateModal.tsx:68
|
#: src/components/DumpCreateModal.tsx:68
|
||||||
#: src/components/UserMenu.tsx:62
|
#: src/components/UserMenu.tsx:62
|
||||||
#: src/pages/Search.tsx:177
|
#: src/pages/Search.tsx:177
|
||||||
@@ -1049,17 +1070,17 @@ msgstr "Playlists"
|
|||||||
msgid "Playlists ({0}{1})"
|
msgid "Playlists ({0}{1})"
|
||||||
msgstr "Playlists ({0}{1})"
|
msgstr "Playlists ({0}{1})"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:479
|
#: src/components/CommentThread.tsx:492
|
||||||
msgid "Post comment"
|
msgid "Post comment"
|
||||||
msgstr "Post comment"
|
msgstr "Post comment"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:401
|
#: src/components/CommentThread.tsx:414
|
||||||
msgid "Post reply"
|
msgid "Post reply"
|
||||||
msgstr "Post reply"
|
msgstr "Post reply"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:402
|
#: src/components/CommentThread.tsx:415
|
||||||
#: src/components/CommentThread.tsx:480
|
#: src/components/CommentThread.tsx:493
|
||||||
#: src/components/DumpCreateModal.tsx:959
|
#: src/components/DumpCreateModal.tsx:958
|
||||||
msgid "Posting…"
|
msgid "Posting…"
|
||||||
msgstr "Posting…"
|
msgstr "Posting…"
|
||||||
|
|
||||||
@@ -1077,7 +1098,7 @@ msgid "private"
|
|||||||
msgstr "private"
|
msgstr "private"
|
||||||
|
|
||||||
#: src/components/form/SegmentedField.tsx:81
|
#: src/components/form/SegmentedField.tsx:81
|
||||||
#: src/pages/PlaylistDetail.tsx:905
|
#: src/pages/PlaylistDetail.tsx:911
|
||||||
msgid "Private"
|
msgid "Private"
|
||||||
msgstr "Private"
|
msgstr "Private"
|
||||||
|
|
||||||
@@ -1087,7 +1108,7 @@ msgid "public"
|
|||||||
msgstr "public"
|
msgstr "public"
|
||||||
|
|
||||||
#: src/components/form/SegmentedField.tsx:80
|
#: src/components/form/SegmentedField.tsx:80
|
||||||
#: src/pages/PlaylistDetail.tsx:898
|
#: src/pages/PlaylistDetail.tsx:904
|
||||||
msgid "Public"
|
msgid "Public"
|
||||||
msgstr "Public"
|
msgstr "Public"
|
||||||
|
|
||||||
@@ -1141,13 +1162,13 @@ msgstr "Remove vote"
|
|||||||
msgid "Replace file"
|
msgid "Replace file"
|
||||||
msgstr "Replace file"
|
msgstr "Replace file"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:315
|
#: src/components/ChatModal.tsx:326
|
||||||
#: src/components/ChatModal.tsx:316
|
#: src/components/ChatModal.tsx:327
|
||||||
#: src/components/CommentThread.tsx:361
|
#: src/components/CommentThread.tsx:374
|
||||||
msgid "Reply"
|
msgid "Reply"
|
||||||
msgstr "Reply"
|
msgstr "Reply"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1240
|
#: src/components/ChatModal.tsx:1256
|
||||||
msgid "Replying to"
|
msgid "Replying to"
|
||||||
msgstr "Replying to"
|
msgstr "Replying to"
|
||||||
|
|
||||||
@@ -1173,7 +1194,7 @@ msgstr "Reset to default"
|
|||||||
msgid "Reset to the suggested title"
|
msgid "Reset to the suggested title"
|
||||||
msgstr "Reset to the suggested title"
|
msgstr "Reset to the suggested title"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:502
|
#: src/components/ChatModal.tsx:519
|
||||||
#: src/pages/Dump.tsx:283
|
#: src/pages/Dump.tsx:283
|
||||||
#: src/pages/DumpEdit.tsx:180
|
#: src/pages/DumpEdit.tsx:180
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
@@ -1183,27 +1204,27 @@ msgstr "Retry"
|
|||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr "Role"
|
msgstr "Role"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:221
|
#: src/components/ChatModal.tsx:232
|
||||||
#: src/components/CommentThread.tsx:333
|
#: src/components/CommentThread.tsx:346
|
||||||
#: src/pages/Dump.tsx:394
|
#: src/pages/Dump.tsx:394
|
||||||
#: src/pages/DumpEdit.tsx:487
|
#: src/pages/DumpEdit.tsx:486
|
||||||
#: src/pages/PlaylistDetail.tsx:930
|
#: src/pages/PlaylistDetail.tsx:936
|
||||||
#: src/pages/UserPublicProfile.tsx:1756
|
#: src/pages/UserPublicProfile.tsx:1756
|
||||||
#: src/pages/UserPublicProfile.tsx:1829
|
#: src/pages/UserPublicProfile.tsx:1830
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Save"
|
msgstr "Save"
|
||||||
|
|
||||||
#: src/components/ChangePasswordModal.tsx:103
|
#: src/components/ChangePasswordModal.tsx:103
|
||||||
#: src/components/CommentThread.tsx:334
|
#: src/components/CommentThread.tsx:347
|
||||||
#: src/pages/Dump.tsx:393
|
#: src/pages/Dump.tsx:393
|
||||||
#: src/pages/PlaylistDetail.tsx:926
|
#: src/pages/PlaylistDetail.tsx:932
|
||||||
#: src/pages/ResetPassword.tsx:126
|
#: src/pages/ResetPassword.tsx:126
|
||||||
#: src/pages/UserPublicProfile.tsx:1753
|
#: src/pages/UserPublicProfile.tsx:1753
|
||||||
#: src/pages/UserPublicProfile.tsx:1826
|
#: src/pages/UserPublicProfile.tsx:1827
|
||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Saving…"
|
msgstr "Saving…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:139
|
#: src/components/AppHeader.tsx:127
|
||||||
#: src/components/SearchBar.tsx:73
|
#: src/components/SearchBar.tsx:73
|
||||||
#: src/pages/Search.tsx:50
|
#: src/pages/Search.tsx:50
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
@@ -1213,6 +1234,14 @@ msgstr "Search"
|
|||||||
msgid "Search dumps, users, playlists…"
|
msgid "Search dumps, users, playlists…"
|
||||||
msgstr "Search dumps, users, playlists…"
|
msgstr "Search dumps, users, playlists…"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:612
|
||||||
|
msgid "Search emoji"
|
||||||
|
msgstr "Search emoji"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:611
|
||||||
|
msgid "Search emoji…"
|
||||||
|
msgstr "Search emoji…"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:218
|
#: src/pages/Search.tsx:218
|
||||||
msgid "Search failed"
|
msgid "Search failed"
|
||||||
msgstr "Search failed"
|
msgstr "Search failed"
|
||||||
@@ -1221,7 +1250,7 @@ msgstr "Search failed"
|
|||||||
msgid "Searching…"
|
msgid "Searching…"
|
||||||
msgstr "Searching…"
|
msgstr "Searching…"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1280
|
#: src/components/ChatModal.tsx:1296
|
||||||
msgid "Send"
|
msgid "Send"
|
||||||
msgstr "Send"
|
msgstr "Send"
|
||||||
|
|
||||||
@@ -1229,12 +1258,12 @@ msgstr "Send"
|
|||||||
msgid "Send reset link"
|
msgid "Send reset link"
|
||||||
msgstr "Send reset link"
|
msgstr "Send reset link"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:520
|
#: src/components/ChatModal.tsx:537
|
||||||
#: src/pages/UserLogin.tsx:142
|
#: src/pages/UserLogin.tsx:142
|
||||||
msgid "Sending…"
|
msgid "Sending…"
|
||||||
msgstr "Sending…"
|
msgstr "Sending…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:176
|
#: src/components/AppHeader.tsx:164
|
||||||
msgid "Server unreachable"
|
msgid "Server unreachable"
|
||||||
msgstr "Server unreachable"
|
msgstr "Server unreachable"
|
||||||
|
|
||||||
@@ -1247,7 +1276,7 @@ msgstr "Set new password"
|
|||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr "Settings"
|
msgstr "Settings"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1064
|
#: src/components/ChatModal.tsx:1080
|
||||||
msgid "Several people are typing…"
|
msgid "Several people are typing…"
|
||||||
msgstr "Several people are typing…"
|
msgstr "Several people are typing…"
|
||||||
|
|
||||||
@@ -1259,6 +1288,10 @@ msgstr "Single use, valid for 7 days."
|
|||||||
msgid "slug"
|
msgid "slug"
|
||||||
msgstr "slug"
|
msgstr "slug"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:297
|
||||||
|
msgid "Some images couldn't be uploaded."
|
||||||
|
msgstr "Some images couldn't be uploaded."
|
||||||
|
|
||||||
#: src/components/form/FormError.tsx:15
|
#: src/components/form/FormError.tsx:15
|
||||||
#: src/components/PageError.tsx:13
|
#: src/components/PageError.tsx:13
|
||||||
#: src/contexts/WSProvider.tsx:663
|
#: src/contexts/WSProvider.tsx:663
|
||||||
@@ -1285,6 +1318,10 @@ msgstr "Submit search"
|
|||||||
msgid "the chat"
|
msgid "the chat"
|
||||||
msgstr "the chat"
|
msgstr "the chat"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:298
|
||||||
|
msgid "The image couldn't be uploaded."
|
||||||
|
msgstr "The image couldn't be uploaded."
|
||||||
|
|
||||||
#: src/pages/UserRegister.tsx:97
|
#: src/pages/UserRegister.tsx:97
|
||||||
msgid "This invite link is missing, expired, or already used."
|
msgid "This invite link is missing, expired, or already used."
|
||||||
msgstr "This invite link is missing, expired, or already used."
|
msgstr "This invite link is missing, expired, or already used."
|
||||||
@@ -1315,7 +1352,7 @@ msgstr "Title"
|
|||||||
msgid "Title is required."
|
msgid "Title is required."
|
||||||
msgstr "Title is required."
|
msgstr "Title is required."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:133
|
#: src/components/ChatModal.tsx:147
|
||||||
#: src/pages/Notifications.tsx:398
|
#: src/pages/Notifications.tsx:398
|
||||||
msgid "Today"
|
msgid "Today"
|
||||||
msgstr "Today"
|
msgstr "Today"
|
||||||
@@ -1325,11 +1362,11 @@ msgstr "Today"
|
|||||||
msgid "Track {0}"
|
msgid "Track {0}"
|
||||||
msgstr "Track {0}"
|
msgstr "Track {0}"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1081
|
#: src/components/ChatModal.tsx:1097
|
||||||
msgid "Try again"
|
msgid "Try again"
|
||||||
msgstr "Try again"
|
msgstr "Try again"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1265
|
#: src/components/ChatModal.tsx:1281
|
||||||
msgid "Type a message…"
|
msgid "Type a message…"
|
||||||
msgstr "Type a message…"
|
msgstr "Type a message…"
|
||||||
|
|
||||||
@@ -1354,7 +1391,11 @@ msgstr "Upload failed"
|
|||||||
msgid "Uploading {0}%"
|
msgid "Uploading {0}%"
|
||||||
msgstr "Uploading {0}%"
|
msgstr "Uploading {0}%"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:962
|
#: src/components/TextEditor.tsx:526
|
||||||
|
msgid "Uploading image…"
|
||||||
|
msgstr "Uploading image…"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:961
|
||||||
msgid "Uploading…"
|
msgid "Uploading…"
|
||||||
msgstr "Uploading…"
|
msgstr "Uploading…"
|
||||||
|
|
||||||
@@ -1423,11 +1464,11 @@ msgid "What makes it worth it?"
|
|||||||
msgstr "What makes it worth it?"
|
msgstr "What makes it worth it?"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:925
|
#: src/pages/UserPublicProfile.tsx:925
|
||||||
#: src/pages/UserPublicProfile.tsx:1818
|
#: src/pages/UserPublicProfile.tsx:1819
|
||||||
msgid "Who am I?"
|
msgid "Who am I?"
|
||||||
msgstr "Who am I?"
|
msgstr "Who am I?"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:928
|
#: src/components/DumpCreateModal.tsx:927
|
||||||
msgid "Who can see it"
|
msgid "Who can see it"
|
||||||
msgstr "Who can see it"
|
msgstr "Who can see it"
|
||||||
|
|
||||||
@@ -1440,11 +1481,11 @@ msgstr "Why & where"
|
|||||||
msgid "Why?"
|
msgid "Why?"
|
||||||
msgstr "Why?"
|
msgstr "Why?"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:405
|
#: src/components/CommentThread.tsx:418
|
||||||
msgid "Write a reply…"
|
msgid "Write a reply…"
|
||||||
msgstr "Write a reply…"
|
msgstr "Write a reply…"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:136
|
#: src/components/ChatModal.tsx:150
|
||||||
#: src/pages/Notifications.tsx:400
|
#: src/pages/Notifications.tsx:400
|
||||||
msgid "Yesterday"
|
msgid "Yesterday"
|
||||||
msgstr "Yesterday"
|
msgstr "Yesterday"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -13,7 +13,7 @@ msgstr ""
|
|||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: \n"
|
"Language-Team: \n"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:257
|
#: src/components/CommentThread.tsx:270
|
||||||
msgid "[deleted]"
|
msgid "[deleted]"
|
||||||
msgstr "[supprimé]"
|
msgstr "[supprimé]"
|
||||||
|
|
||||||
@@ -35,17 +35,17 @@ msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus ancienn
|
|||||||
|
|
||||||
#. placeholder {0}: typingNames[0]
|
#. placeholder {0}: typingNames[0]
|
||||||
#. placeholder {1}: typingNames[1]
|
#. placeholder {1}: typingNames[1]
|
||||||
#: src/components/ChatModal.tsx:1063
|
#: src/components/ChatModal.tsx:1079
|
||||||
msgid "{0} and {1} are typing…"
|
msgid "{0} and {1} are typing…"
|
||||||
msgstr "{0} et {1} sont en train d'écrire…"
|
msgstr "{0} et {1} sont en train d'écrire…"
|
||||||
|
|
||||||
#. placeholder {0}: typingNames[0]
|
#. placeholder {0}: typingNames[0]
|
||||||
#: src/components/ChatModal.tsx:1061
|
#: src/components/ChatModal.tsx:1077
|
||||||
msgid "{0} is typing…"
|
msgid "{0} is typing…"
|
||||||
msgstr "{0} est en train d'écrire…"
|
msgstr "{0} est en train d'écrire…"
|
||||||
|
|
||||||
#. placeholder {0}: rest.length
|
#. placeholder {0}: rest.length
|
||||||
#: src/components/PresenceRow.tsx:44
|
#: src/components/PresenceRow.tsx:66
|
||||||
msgid "{0} more online: {restNames}"
|
msgid "{0} more online: {restNames}"
|
||||||
msgstr "{0} de plus en ligne : {restNames}"
|
msgstr "{0} de plus en ligne : {restNames}"
|
||||||
|
|
||||||
@@ -59,15 +59,15 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
|
|||||||
msgid "{label} ({count})"
|
msgid "{label} ({count})"
|
||||||
msgstr "{label} ({count})"
|
msgstr "{label} ({count})"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1192
|
#: src/components/ChatModal.tsx:1208
|
||||||
msgid "{unseenCount} new messages"
|
msgid "{unseenCount} new messages"
|
||||||
msgstr "{unseenCount} nouveaux messages"
|
msgstr "{unseenCount} nouveaux messages"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:462
|
#: src/components/CommentThread.tsx:475
|
||||||
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:943
|
#: src/components/DumpCreateModal.tsx:942
|
||||||
#: src/pages/PlaylistDetail.tsx:572
|
#: src/pages/PlaylistDetail.tsx:572
|
||||||
#: src/pages/UserPublicProfile.tsx:790
|
#: src/pages/UserPublicProfile.tsx:790
|
||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
@@ -143,7 +143,7 @@ msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
|
|||||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||||
msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
|
msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1191
|
#: src/components/ChatModal.tsx:1207
|
||||||
msgid "1 new message"
|
msgid "1 new message"
|
||||||
msgstr "1 nouveau message"
|
msgstr "1 nouveau message"
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ msgstr "une publication"
|
|||||||
msgid "Account"
|
msgid "Account"
|
||||||
msgstr "Compte"
|
msgstr "Compte"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:482
|
#: src/components/CommentThread.tsx:495
|
||||||
msgid "Add a comment…"
|
msgid "Add a comment…"
|
||||||
msgstr "Ajouter un commentaire…"
|
msgstr "Ajouter un commentaire…"
|
||||||
|
|
||||||
@@ -207,6 +207,10 @@ msgstr "Apparence"
|
|||||||
msgid "At least {0} characters"
|
msgid "At least {0} characters"
|
||||||
msgstr "Au moins {0} caractères"
|
msgstr "Au moins {0} caractères"
|
||||||
|
|
||||||
|
#: src/components/form/RichTextField.tsx:56
|
||||||
|
msgid "At most {maxLength} characters."
|
||||||
|
msgstr "{maxLength} caractères au maximum."
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1252
|
#: src/pages/UserPublicProfile.tsx:1252
|
||||||
msgid "Auto"
|
msgid "Auto"
|
||||||
msgstr "Auto"
|
msgstr "Auto"
|
||||||
@@ -223,16 +227,16 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
|
|||||||
|
|
||||||
#: src/components/CategoryManager.tsx:210
|
#: src/components/CategoryManager.tsx:210
|
||||||
#: src/components/CategoryManager.tsx:211
|
#: src/components/CategoryManager.tsx:211
|
||||||
#: src/components/ChatModal.tsx:224
|
#: src/components/ChatModal.tsx:235
|
||||||
#: src/components/CommentThread.tsx:121
|
#: src/components/CommentThread.tsx:129
|
||||||
#: src/components/ConfirmModal.tsx:34
|
#: src/components/ConfirmModal.tsx:34
|
||||||
#: src/components/DumpCreateModal.tsx:953
|
#: src/components/DumpCreateModal.tsx:952
|
||||||
#: src/components/form/FormActions.tsx:32
|
#: src/components/form/FormActions.tsx:32
|
||||||
#: src/pages/Dump.tsx:402
|
#: src/pages/Dump.tsx:402
|
||||||
#: src/pages/DumpEdit.tsx:484
|
#: src/pages/DumpEdit.tsx:483
|
||||||
#: src/pages/PlaylistDetail.tsx:923
|
#: src/pages/PlaylistDetail.tsx:929
|
||||||
#: src/pages/UserPublicProfile.tsx:1764
|
#: src/pages/UserPublicProfile.tsx:1764
|
||||||
#: src/pages/UserPublicProfile.tsx:1837
|
#: src/pages/UserPublicProfile.tsx:1838
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Annuler"
|
msgstr "Annuler"
|
||||||
|
|
||||||
@@ -240,12 +244,12 @@ msgstr "Annuler"
|
|||||||
msgid "Cancel removal"
|
msgid "Cancel removal"
|
||||||
msgstr "Annuler la suppression"
|
msgstr "Annuler la suppression"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1252
|
#: src/components/ChatModal.tsx:1268
|
||||||
#: src/components/ChatModal.tsx:1253
|
#: src/components/ChatModal.tsx:1269
|
||||||
msgid "Cancel reply"
|
msgid "Cancel reply"
|
||||||
msgstr "Annuler la réponse"
|
msgstr "Annuler la réponse"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:139
|
#: src/components/AppHeader.tsx:127
|
||||||
msgid "Cancel search"
|
msgid "Cancel search"
|
||||||
msgstr "Annuler la recherche"
|
msgstr "Annuler la recherche"
|
||||||
|
|
||||||
@@ -271,10 +275,14 @@ msgstr "Changer le mot de passe"
|
|||||||
msgid "Change password…"
|
msgid "Change password…"
|
||||||
msgstr "Changer le mot de passe…"
|
msgstr "Changer le mot de passe…"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:619
|
||||||
|
msgid "Change skin tone"
|
||||||
|
msgstr "Changer la couleur de peau"
|
||||||
|
|
||||||
#: src/components/ChatButton.tsx:17
|
#: src/components/ChatButton.tsx:17
|
||||||
#: src/components/ChatFab.tsx:49
|
#: src/components/ChatFab.tsx:49
|
||||||
#: src/components/ChatFab.tsx:50
|
#: src/components/ChatFab.tsx:50
|
||||||
#: src/components/ChatModal.tsx:1152
|
#: src/components/ChatModal.tsx:1168
|
||||||
msgid "Chat"
|
msgid "Chat"
|
||||||
msgstr "Tribune"
|
msgstr "Tribune"
|
||||||
|
|
||||||
@@ -296,6 +304,10 @@ msgstr "Fermer"
|
|||||||
msgid "Close anyway"
|
msgid "Close anyway"
|
||||||
msgstr "Fermer quand même"
|
msgstr "Fermer quand même"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:628
|
||||||
|
msgid "Close emoji picker"
|
||||||
|
msgstr "Fermer le sélecteur d'emoji"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:242
|
#: src/components/GlobalPlayer.tsx:242
|
||||||
msgid "Close player"
|
msgid "Close player"
|
||||||
msgstr "Fermer le lecteur"
|
msgstr "Fermer le lecteur"
|
||||||
@@ -345,15 +357,15 @@ msgstr "Sauvegarde impossible"
|
|||||||
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
|
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
|
||||||
msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard."
|
msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1078
|
#: src/components/ChatModal.tsx:1094
|
||||||
msgid "Couldn't load the chat."
|
msgid "Couldn't load the chat."
|
||||||
msgstr "Impossible de charger le chat."
|
msgstr "Impossible de charger le chat."
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:91
|
#: src/components/PlaylistCreateForm.tsx:90
|
||||||
msgid "Create"
|
msgid "Create"
|
||||||
msgstr "Créer"
|
msgstr "Créer"
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:91
|
#: src/components/PlaylistCreateForm.tsx:90
|
||||||
msgid "Create & Add"
|
msgid "Create & Add"
|
||||||
msgstr "Créer et ajouter"
|
msgstr "Créer et ajouter"
|
||||||
|
|
||||||
@@ -363,7 +375,7 @@ msgstr "Créer et ajouter"
|
|||||||
msgid "Created ({0}{1})"
|
msgid "Created ({0}{1})"
|
||||||
msgstr "Créées ({0}{1})"
|
msgstr "Créées ({0}{1})"
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:90
|
#: src/components/PlaylistCreateForm.tsx:89
|
||||||
msgid "Creating…"
|
msgid "Creating…"
|
||||||
msgstr "Création…"
|
msgstr "Création…"
|
||||||
|
|
||||||
@@ -381,13 +393,13 @@ msgstr "Onglet par défaut"
|
|||||||
|
|
||||||
#: src/components/CategoryManager.tsx:221
|
#: src/components/CategoryManager.tsx:221
|
||||||
#: src/components/CategoryManager.tsx:222
|
#: src/components/CategoryManager.tsx:222
|
||||||
#: src/components/ChatModal.tsx:336
|
#: src/components/ChatModal.tsx:347
|
||||||
#: src/components/ChatModal.tsx:337
|
#: src/components/ChatModal.tsx:348
|
||||||
#: src/components/ChatModal.tsx:431
|
#: src/components/ChatModal.tsx:446
|
||||||
#: src/components/CommentThread.tsx:382
|
#: src/components/CommentThread.tsx:395
|
||||||
#: src/components/CommentThread.tsx:388
|
#: src/components/CommentThread.tsx:401
|
||||||
#: src/components/ConfirmModal.tsx:17
|
#: src/components/ConfirmModal.tsx:17
|
||||||
#: src/pages/PlaylistDetail.tsx:919
|
#: src/pages/PlaylistDetail.tsx:925
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Supprimer"
|
msgstr "Supprimer"
|
||||||
|
|
||||||
@@ -401,7 +413,7 @@ msgid "Delete category \"{0}\"? This cannot be undone."
|
|||||||
msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible."
|
msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible."
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:265
|
#: src/pages/DumpEdit.tsx:265
|
||||||
#: src/pages/DumpEdit.tsx:480
|
#: src/pages/DumpEdit.tsx:479
|
||||||
msgid "Delete dump"
|
msgid "Delete dump"
|
||||||
msgstr "Supprimer la reco"
|
msgstr "Supprimer la reco"
|
||||||
|
|
||||||
@@ -411,7 +423,7 @@ msgstr "Supprimer la reco"
|
|||||||
msgid "Delete playlist"
|
msgid "Delete playlist"
|
||||||
msgstr "Supprimer la collection"
|
msgstr "Supprimer la collection"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:387
|
#: src/components/CommentThread.tsx:400
|
||||||
msgid "Delete this comment?"
|
msgid "Delete this comment?"
|
||||||
msgstr "Supprimer ce commentaire ?"
|
msgstr "Supprimer ce commentaire ?"
|
||||||
|
|
||||||
@@ -419,7 +431,7 @@ msgstr "Supprimer ce commentaire ?"
|
|||||||
msgid "Delete this dump? This cannot be undone."
|
msgid "Delete this dump? This cannot be undone."
|
||||||
msgstr "Supprimer cette reco ? Cette action est irréversible."
|
msgstr "Supprimer cette reco ? Cette action est irréversible."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:430
|
#: src/components/ChatModal.tsx:445
|
||||||
msgid "Delete this message?"
|
msgid "Delete this message?"
|
||||||
msgstr "Supprimer ce message ?"
|
msgstr "Supprimer ce message ?"
|
||||||
|
|
||||||
@@ -428,25 +440,25 @@ msgstr "Supprimer ce message ?"
|
|||||||
msgid "Delete this playlist? This cannot be undone."
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:258
|
#: src/components/ChatModal.tsx:269
|
||||||
msgid "deleted message"
|
msgid "deleted message"
|
||||||
msgstr "message supprimé"
|
msgstr "message supprimé"
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:82
|
#: src/components/PlaylistCreateForm.tsx:82
|
||||||
#: src/pages/PlaylistDetail.tsx:880
|
#: src/pages/PlaylistDetail.tsx:886
|
||||||
msgid "Description (optional)"
|
msgid "Description (optional)"
|
||||||
msgstr "Description (facultatif)"
|
msgstr "Description (facultatif)"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:516
|
#: src/components/ChatModal.tsx:533
|
||||||
msgid "Discard"
|
msgid "Discard"
|
||||||
msgstr "Abandonner"
|
msgstr "Abandonner"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1156
|
#: src/components/ChatModal.tsx:1172
|
||||||
msgid "Disconnected — reconnecting…"
|
msgid "Disconnected — reconnecting…"
|
||||||
msgstr "Déconnecté — reconnexion…"
|
msgstr "Déconnecté — reconnexion…"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1228
|
#: src/components/ChatModal.tsx:1244
|
||||||
#: src/components/ChatModal.tsx:1229
|
#: src/components/ChatModal.tsx:1245
|
||||||
msgid "Dismiss"
|
msgid "Dismiss"
|
||||||
msgstr "Fermer"
|
msgstr "Fermer"
|
||||||
|
|
||||||
@@ -466,11 +478,11 @@ msgstr "Déposez un fichier ici, ou collez-en un"
|
|||||||
msgid "Drop a replacement here"
|
msgid "Drop a replacement here"
|
||||||
msgstr "Déposez un fichier de remplacement ici"
|
msgstr "Déposez un fichier de remplacement ici"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:179
|
#: src/components/AppHeader.tsx:167
|
||||||
msgid "Dump"
|
msgid "Dump"
|
||||||
msgstr "Reco"
|
msgstr "Reco"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:966
|
#: src/components/DumpCreateModal.tsx:965
|
||||||
msgid "Dump it"
|
msgid "Dump it"
|
||||||
msgstr "Recommander"
|
msgstr "Recommander"
|
||||||
|
|
||||||
@@ -494,10 +506,10 @@ msgstr "Recos ({0}{1})"
|
|||||||
msgid "Earlier"
|
msgid "Earlier"
|
||||||
msgstr "Plus tôt"
|
msgstr "Plus tôt"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:325
|
#: src/components/ChatModal.tsx:336
|
||||||
#: src/components/ChatModal.tsx:326
|
#: src/components/ChatModal.tsx:337
|
||||||
#: src/components/ChatModal.tsx:509
|
#: src/components/ChatModal.tsx:526
|
||||||
#: src/components/CommentThread.tsx:373
|
#: src/components/CommentThread.tsx:386
|
||||||
#: src/pages/Dump.tsx:516
|
#: src/pages/Dump.tsx:516
|
||||||
#: src/pages/PlaylistDetail.tsx:627
|
#: src/pages/PlaylistDetail.tsx:627
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
@@ -512,15 +524,15 @@ msgstr "Modifier {0}"
|
|||||||
msgid "Edit title"
|
msgid "Edit title"
|
||||||
msgstr "Modifier le titre"
|
msgstr "Modifier le titre"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:349
|
#: src/components/ChatModal.tsx:360
|
||||||
msgid "edited"
|
msgid "edited"
|
||||||
msgstr "modifié"
|
msgstr "modifié"
|
||||||
|
|
||||||
#. placeholder {0}: relativeTime(comment.updatedAt)
|
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||||
#: src/components/ChatModal.tsx:350
|
#: src/components/ChatModal.tsx:361
|
||||||
#: src/components/CommentThread.tsx:322
|
#: src/components/CommentThread.tsx:335
|
||||||
#: src/pages/Dump.tsx:455
|
#: src/pages/Dump.tsx:455
|
||||||
#: src/pages/PlaylistDetail.tsx:666
|
#: src/pages/PlaylistDetail.tsx:666
|
||||||
msgid "edited {0}"
|
msgid "edited {0}"
|
||||||
@@ -529,8 +541,8 @@ msgstr "modifié {0}"
|
|||||||
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||||
#: src/components/ChatModal.tsx:346
|
#: src/components/ChatModal.tsx:357
|
||||||
#: src/components/CommentThread.tsx:320
|
#: src/components/CommentThread.tsx:333
|
||||||
#: src/pages/Dump.tsx:453
|
#: src/pages/Dump.tsx:453
|
||||||
#: src/pages/PlaylistDetail.tsx:663
|
#: src/pages/PlaylistDetail.tsx:663
|
||||||
msgid "Edited {0}"
|
msgid "Edited {0}"
|
||||||
@@ -561,7 +573,7 @@ msgstr "Agrandir le lecteur"
|
|||||||
msgid "Failed to create category"
|
msgid "Failed to create category"
|
||||||
msgstr "Échec de la création de la catégorie"
|
msgstr "Échec de la création de la catégorie"
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:88
|
#: src/components/PlaylistCreateForm.tsx:87
|
||||||
msgid "Failed to create playlist"
|
msgid "Failed to create playlist"
|
||||||
msgstr "Impossible de créer la collection"
|
msgstr "Impossible de créer la collection"
|
||||||
|
|
||||||
@@ -590,17 +602,17 @@ msgstr "Chargement échoué"
|
|||||||
msgid "Failed to post"
|
msgid "Failed to post"
|
||||||
msgstr "Publication échouée"
|
msgstr "Publication échouée"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:481
|
#: src/components/CommentThread.tsx:494
|
||||||
msgid "Failed to post comment"
|
msgid "Failed to post comment"
|
||||||
msgstr "Impossible de publier le commentaire"
|
msgstr "Impossible de publier le commentaire"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:403
|
#: src/components/CommentThread.tsx:416
|
||||||
msgid "Failed to post reply"
|
msgid "Failed to post reply"
|
||||||
msgstr "Impossible de publier la réponse"
|
msgstr "Impossible de publier la réponse"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:911
|
#: src/pages/PlaylistDetail.tsx:917
|
||||||
#: src/pages/UserPublicProfile.tsx:1767
|
#: src/pages/UserPublicProfile.tsx:1767
|
||||||
#: src/pages/UserPublicProfile.tsx:1839
|
#: src/pages/UserPublicProfile.tsx:1840
|
||||||
msgid "Failed to save"
|
msgid "Failed to save"
|
||||||
msgstr "Enregistrement échoué"
|
msgstr "Enregistrement échoué"
|
||||||
|
|
||||||
@@ -608,7 +620,7 @@ msgstr "Enregistrement échoué"
|
|||||||
msgid "Failed to save category"
|
msgid "Failed to save category"
|
||||||
msgstr "Échec de l’enregistrement de la catégorie"
|
msgstr "Échec de l’enregistrement de la catégorie"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:335
|
#: src/components/CommentThread.tsx:348
|
||||||
msgid "Failed to save edit"
|
msgid "Failed to save edit"
|
||||||
msgstr "Impossible d'enregistrer la modification"
|
msgstr "Impossible d'enregistrer la modification"
|
||||||
|
|
||||||
@@ -704,7 +716,7 @@ msgstr "Tendances"
|
|||||||
msgid "If that address is registered you'll receive a reset link shortly."
|
msgid "If that address is registered you'll receive a reset link shortly."
|
||||||
msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."
|
msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:81
|
#: src/components/ChatModal.tsx:95
|
||||||
msgid "image"
|
msgid "image"
|
||||||
msgstr "image"
|
msgstr "image"
|
||||||
|
|
||||||
@@ -712,6 +724,11 @@ msgstr "image"
|
|||||||
msgid "In collections"
|
msgid "In collections"
|
||||||
msgstr "Dans les collections"
|
msgstr "Dans les collections"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:504
|
||||||
|
#: src/components/TextEditor.tsx:506
|
||||||
|
msgid "Insert emoji"
|
||||||
|
msgstr "Insérer un emoji"
|
||||||
|
|
||||||
#: src/pages/UserRegister.tsx:96
|
#: src/pages/UserRegister.tsx:96
|
||||||
msgid "Invalid invite"
|
msgid "Invalid invite"
|
||||||
msgstr "Invitation invalide"
|
msgstr "Invitation invalide"
|
||||||
@@ -763,7 +780,7 @@ msgstr "Lien ou fichier"
|
|||||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:212
|
#: src/components/AppHeader.tsx:200
|
||||||
msgid "Live updates unavailable."
|
msgid "Live updates unavailable."
|
||||||
msgstr "Mises à jour en direct indisponibles."
|
msgstr "Mises à jour en direct indisponibles."
|
||||||
|
|
||||||
@@ -772,7 +789,7 @@ msgstr "Mises à jour en direct indisponibles."
|
|||||||
msgid "Load more"
|
msgid "Load more"
|
||||||
msgstr "Charger plus"
|
msgstr "Charger plus"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1177
|
#: src/components/ChatModal.tsx:1193
|
||||||
msgid "Load older messages"
|
msgid "Load older messages"
|
||||||
msgstr "Charger les messages plus anciens"
|
msgstr "Charger les messages plus anciens"
|
||||||
|
|
||||||
@@ -781,7 +798,7 @@ msgstr "Charger les messages plus anciens"
|
|||||||
msgid "Loading dump…"
|
msgid "Loading dump…"
|
||||||
msgstr "Chargement de la reco…"
|
msgstr "Chargement de la reco…"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1070
|
#: src/components/ChatModal.tsx:1086
|
||||||
msgid "Loading messages…"
|
msgid "Loading messages…"
|
||||||
msgstr "Chargement des messages…"
|
msgstr "Chargement des messages…"
|
||||||
|
|
||||||
@@ -806,9 +823,9 @@ msgid "Loading profile…"
|
|||||||
msgstr "Chargement du profil…"
|
msgstr "Chargement du profil…"
|
||||||
|
|
||||||
#: src/components/CategoryManager.tsx:52
|
#: src/components/CategoryManager.tsx:52
|
||||||
#: src/components/ChatModal.tsx:1176
|
#: src/components/ChatModal.tsx:1192
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||||
#: src/components/TextEditor.tsx:328
|
#: src/components/TextEditor.tsx:640
|
||||||
#: src/components/UserListPopover.tsx:192
|
#: src/components/UserListPopover.tsx:192
|
||||||
#: src/components/UserListPopover.tsx:230
|
#: src/components/UserListPopover.tsx:230
|
||||||
#: src/pages/index/FollowedFeed.tsx:78
|
#: src/pages/index/FollowedFeed.tsx:78
|
||||||
@@ -826,7 +843,7 @@ msgstr "Chargement du profil…"
|
|||||||
msgid "Loading…"
|
msgid "Loading…"
|
||||||
msgstr "Chargement…"
|
msgstr "Chargement…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:187
|
#: src/components/AppHeader.tsx:175
|
||||||
#: src/pages/UserLogin.tsx:35
|
#: src/pages/UserLogin.tsx:35
|
||||||
#: src/pages/UserLogin.tsx:61
|
#: src/pages/UserLogin.tsx:61
|
||||||
#: src/pages/UserLogin.tsx:80
|
#: src/pages/UserLogin.tsx:80
|
||||||
@@ -875,7 +892,7 @@ msgstr "Nom"
|
|||||||
msgid "new"
|
msgid "new"
|
||||||
msgstr "nouveau"
|
msgstr "nouveau"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1105
|
#: src/components/ChatModal.tsx:1121
|
||||||
#: src/components/FeedTabBar.tsx:31
|
#: src/components/FeedTabBar.tsx:31
|
||||||
#: src/pages/UserPublicProfile.tsx:1294
|
#: src/pages/UserPublicProfile.tsx:1294
|
||||||
msgid "New"
|
msgid "New"
|
||||||
@@ -899,7 +916,7 @@ msgstr "Nouveau mot de passe"
|
|||||||
msgid "New playlist"
|
msgid "New playlist"
|
||||||
msgstr "Nouvelle collection"
|
msgstr "Nouvelle collection"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:965
|
#: src/components/DumpCreateModal.tsx:964
|
||||||
msgid "Next →"
|
msgid "Next →"
|
||||||
msgstr "Suivant →"
|
msgstr "Suivant →"
|
||||||
|
|
||||||
@@ -921,7 +938,7 @@ msgstr "Aucune reco ne correspond à « {q} »."
|
|||||||
msgid "No dumps yet. Be the first!"
|
msgid "No dumps yet. Be the first!"
|
||||||
msgstr "Pas encore de recos. Soyez le premier !"
|
msgstr "Pas encore de recos. Soyez le premier !"
|
||||||
|
|
||||||
#: src/components/TextEditor.tsx:331
|
#: src/components/TextEditor.tsx:643
|
||||||
msgid "No emoji found."
|
msgid "No emoji found."
|
||||||
msgstr "Aucun emoji trouvé."
|
msgstr "Aucun emoji trouvé."
|
||||||
|
|
||||||
@@ -934,7 +951,7 @@ msgstr "Pas encore de collections suivies."
|
|||||||
msgid "No invitees yet."
|
msgid "No invitees yet."
|
||||||
msgstr "Aucun invité pour le moment."
|
msgstr "Aucun invité pour le moment."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1089
|
#: src/components/ChatModal.tsx:1105
|
||||||
msgid "No messages yet. Say hello!"
|
msgid "No messages yet. Say hello!"
|
||||||
msgstr "Aucun message pour l'instant. Dites bonjour !"
|
msgstr "Aucun message pour l'instant. Dites bonjour !"
|
||||||
|
|
||||||
@@ -991,6 +1008,10 @@ msgstr "Désactivés"
|
|||||||
msgid "On"
|
msgid "On"
|
||||||
msgstr "Activés"
|
msgstr "Activés"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:338
|
||||||
|
msgid "Only images can be dropped here."
|
||||||
|
msgstr "Seules les images peuvent être déposées ici."
|
||||||
|
|
||||||
#: src/components/FileDropZone.tsx:134
|
#: src/components/FileDropZone.tsx:134
|
||||||
msgid "or <0>browse files</0>"
|
msgid "or <0>browse files</0>"
|
||||||
msgstr "ou <0>parcourir les fichiers</0>"
|
msgstr "ou <0>parcourir les fichiers</0>"
|
||||||
@@ -1030,11 +1051,11 @@ msgstr "Collez un lien…"
|
|||||||
msgid "Picked up where you left off."
|
msgid "Picked up where you left off."
|
||||||
msgstr "Reprise de votre brouillon."
|
msgstr "Reprise de votre brouillon."
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:866
|
#: src/pages/PlaylistDetail.tsx:870
|
||||||
msgid "Playlist title"
|
msgid "Playlist title"
|
||||||
msgstr "Titre de la collection"
|
msgstr "Titre de la collection"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:160
|
#: src/components/AppHeader.tsx:148
|
||||||
#: src/components/DumpCreateModal.tsx:68
|
#: src/components/DumpCreateModal.tsx:68
|
||||||
#: src/components/UserMenu.tsx:62
|
#: src/components/UserMenu.tsx:62
|
||||||
#: src/pages/Search.tsx:177
|
#: src/pages/Search.tsx:177
|
||||||
@@ -1049,17 +1070,17 @@ msgstr "Collections"
|
|||||||
msgid "Playlists ({0}{1})"
|
msgid "Playlists ({0}{1})"
|
||||||
msgstr "Collections ({0}{1})"
|
msgstr "Collections ({0}{1})"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:479
|
#: src/components/CommentThread.tsx:492
|
||||||
msgid "Post comment"
|
msgid "Post comment"
|
||||||
msgstr "Publier le commentaire"
|
msgstr "Publier le commentaire"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:401
|
#: src/components/CommentThread.tsx:414
|
||||||
msgid "Post reply"
|
msgid "Post reply"
|
||||||
msgstr "Publier la réponse"
|
msgstr "Publier la réponse"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:402
|
#: src/components/CommentThread.tsx:415
|
||||||
#: src/components/CommentThread.tsx:480
|
#: src/components/CommentThread.tsx:493
|
||||||
#: src/components/DumpCreateModal.tsx:959
|
#: src/components/DumpCreateModal.tsx:958
|
||||||
msgid "Posting…"
|
msgid "Posting…"
|
||||||
msgstr "Publication…"
|
msgstr "Publication…"
|
||||||
|
|
||||||
@@ -1077,7 +1098,7 @@ msgid "private"
|
|||||||
msgstr "privé"
|
msgstr "privé"
|
||||||
|
|
||||||
#: src/components/form/SegmentedField.tsx:81
|
#: src/components/form/SegmentedField.tsx:81
|
||||||
#: src/pages/PlaylistDetail.tsx:905
|
#: src/pages/PlaylistDetail.tsx:911
|
||||||
msgid "Private"
|
msgid "Private"
|
||||||
msgstr "Privé"
|
msgstr "Privé"
|
||||||
|
|
||||||
@@ -1087,7 +1108,7 @@ msgid "public"
|
|||||||
msgstr "public"
|
msgstr "public"
|
||||||
|
|
||||||
#: src/components/form/SegmentedField.tsx:80
|
#: src/components/form/SegmentedField.tsx:80
|
||||||
#: src/pages/PlaylistDetail.tsx:898
|
#: src/pages/PlaylistDetail.tsx:904
|
||||||
msgid "Public"
|
msgid "Public"
|
||||||
msgstr "Public"
|
msgstr "Public"
|
||||||
|
|
||||||
@@ -1141,13 +1162,13 @@ msgstr "Retirer le vote"
|
|||||||
msgid "Replace file"
|
msgid "Replace file"
|
||||||
msgstr "Remplacer le fichier"
|
msgstr "Remplacer le fichier"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:315
|
#: src/components/ChatModal.tsx:326
|
||||||
#: src/components/ChatModal.tsx:316
|
#: src/components/ChatModal.tsx:327
|
||||||
#: src/components/CommentThread.tsx:361
|
#: src/components/CommentThread.tsx:374
|
||||||
msgid "Reply"
|
msgid "Reply"
|
||||||
msgstr "Répondre"
|
msgstr "Répondre"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1240
|
#: src/components/ChatModal.tsx:1256
|
||||||
msgid "Replying to"
|
msgid "Replying to"
|
||||||
msgstr "En réponse à"
|
msgstr "En réponse à"
|
||||||
|
|
||||||
@@ -1173,7 +1194,7 @@ msgstr "Réinitialiser par défaut"
|
|||||||
msgid "Reset to the suggested title"
|
msgid "Reset to the suggested title"
|
||||||
msgstr "Rétablir le titre suggéré"
|
msgstr "Rétablir le titre suggéré"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:502
|
#: src/components/ChatModal.tsx:519
|
||||||
#: src/pages/Dump.tsx:283
|
#: src/pages/Dump.tsx:283
|
||||||
#: src/pages/DumpEdit.tsx:180
|
#: src/pages/DumpEdit.tsx:180
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
@@ -1183,27 +1204,27 @@ msgstr "Réessayer"
|
|||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr "Rôle"
|
msgstr "Rôle"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:221
|
#: src/components/ChatModal.tsx:232
|
||||||
#: src/components/CommentThread.tsx:333
|
#: src/components/CommentThread.tsx:346
|
||||||
#: src/pages/Dump.tsx:394
|
#: src/pages/Dump.tsx:394
|
||||||
#: src/pages/DumpEdit.tsx:487
|
#: src/pages/DumpEdit.tsx:486
|
||||||
#: src/pages/PlaylistDetail.tsx:930
|
#: src/pages/PlaylistDetail.tsx:936
|
||||||
#: src/pages/UserPublicProfile.tsx:1756
|
#: src/pages/UserPublicProfile.tsx:1756
|
||||||
#: src/pages/UserPublicProfile.tsx:1829
|
#: src/pages/UserPublicProfile.tsx:1830
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Enregistrer"
|
msgstr "Enregistrer"
|
||||||
|
|
||||||
#: src/components/ChangePasswordModal.tsx:103
|
#: src/components/ChangePasswordModal.tsx:103
|
||||||
#: src/components/CommentThread.tsx:334
|
#: src/components/CommentThread.tsx:347
|
||||||
#: src/pages/Dump.tsx:393
|
#: src/pages/Dump.tsx:393
|
||||||
#: src/pages/PlaylistDetail.tsx:926
|
#: src/pages/PlaylistDetail.tsx:932
|
||||||
#: src/pages/ResetPassword.tsx:126
|
#: src/pages/ResetPassword.tsx:126
|
||||||
#: src/pages/UserPublicProfile.tsx:1753
|
#: src/pages/UserPublicProfile.tsx:1753
|
||||||
#: src/pages/UserPublicProfile.tsx:1826
|
#: src/pages/UserPublicProfile.tsx:1827
|
||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Enregistrement…"
|
msgstr "Enregistrement…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:139
|
#: src/components/AppHeader.tsx:127
|
||||||
#: src/components/SearchBar.tsx:73
|
#: src/components/SearchBar.tsx:73
|
||||||
#: src/pages/Search.tsx:50
|
#: src/pages/Search.tsx:50
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
@@ -1213,6 +1234,14 @@ msgstr "Rechercher"
|
|||||||
msgid "Search dumps, users, playlists…"
|
msgid "Search dumps, users, playlists…"
|
||||||
msgstr "Rechercher des recos, utilisateurs, collections…"
|
msgstr "Rechercher des recos, utilisateurs, collections…"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:612
|
||||||
|
msgid "Search emoji"
|
||||||
|
msgstr "Rechercher un emoji"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:611
|
||||||
|
msgid "Search emoji…"
|
||||||
|
msgstr "Rechercher un emoji…"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:218
|
#: src/pages/Search.tsx:218
|
||||||
msgid "Search failed"
|
msgid "Search failed"
|
||||||
msgstr "Recherche échouée"
|
msgstr "Recherche échouée"
|
||||||
@@ -1221,7 +1250,7 @@ msgstr "Recherche échouée"
|
|||||||
msgid "Searching…"
|
msgid "Searching…"
|
||||||
msgstr "Recherche…"
|
msgstr "Recherche…"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1280
|
#: src/components/ChatModal.tsx:1296
|
||||||
msgid "Send"
|
msgid "Send"
|
||||||
msgstr "Envoyer"
|
msgstr "Envoyer"
|
||||||
|
|
||||||
@@ -1229,12 +1258,12 @@ msgstr "Envoyer"
|
|||||||
msgid "Send reset link"
|
msgid "Send reset link"
|
||||||
msgstr "Envoyer le lien de réinitialisation"
|
msgstr "Envoyer le lien de réinitialisation"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:520
|
#: src/components/ChatModal.tsx:537
|
||||||
#: src/pages/UserLogin.tsx:142
|
#: src/pages/UserLogin.tsx:142
|
||||||
msgid "Sending…"
|
msgid "Sending…"
|
||||||
msgstr "Envoi…"
|
msgstr "Envoi…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:176
|
#: src/components/AppHeader.tsx:164
|
||||||
msgid "Server unreachable"
|
msgid "Server unreachable"
|
||||||
msgstr "Serveur inaccessible"
|
msgstr "Serveur inaccessible"
|
||||||
|
|
||||||
@@ -1247,7 +1276,7 @@ msgstr "Définir un nouveau mot de passe"
|
|||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr "Paramètres"
|
msgstr "Paramètres"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1064
|
#: src/components/ChatModal.tsx:1080
|
||||||
msgid "Several people are typing…"
|
msgid "Several people are typing…"
|
||||||
msgstr "Plusieurs personnes écrivent…"
|
msgstr "Plusieurs personnes écrivent…"
|
||||||
|
|
||||||
@@ -1259,6 +1288,10 @@ msgstr "Usage unique, valable 7 jours."
|
|||||||
msgid "slug"
|
msgid "slug"
|
||||||
msgstr "identifiant"
|
msgstr "identifiant"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:297
|
||||||
|
msgid "Some images couldn't be uploaded."
|
||||||
|
msgstr "Certaines images n'ont pas pu être envoyées."
|
||||||
|
|
||||||
#: src/components/form/FormError.tsx:15
|
#: src/components/form/FormError.tsx:15
|
||||||
#: src/components/PageError.tsx:13
|
#: src/components/PageError.tsx:13
|
||||||
#: src/contexts/WSProvider.tsx:663
|
#: src/contexts/WSProvider.tsx:663
|
||||||
@@ -1285,6 +1318,10 @@ msgstr "Lancer la recherche"
|
|||||||
msgid "the chat"
|
msgid "the chat"
|
||||||
msgstr "la tribune"
|
msgstr "la tribune"
|
||||||
|
|
||||||
|
#: src/components/TextEditor.tsx:298
|
||||||
|
msgid "The image couldn't be uploaded."
|
||||||
|
msgstr "L'image n'a pas pu être envoyée."
|
||||||
|
|
||||||
#: src/pages/UserRegister.tsx:97
|
#: src/pages/UserRegister.tsx:97
|
||||||
msgid "This invite link is missing, expired, or already used."
|
msgid "This invite link is missing, expired, or already used."
|
||||||
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
|
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
|
||||||
@@ -1315,7 +1352,7 @@ msgstr "Titre"
|
|||||||
msgid "Title is required."
|
msgid "Title is required."
|
||||||
msgstr "Un titre est requis."
|
msgstr "Un titre est requis."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:133
|
#: src/components/ChatModal.tsx:147
|
||||||
#: src/pages/Notifications.tsx:398
|
#: src/pages/Notifications.tsx:398
|
||||||
msgid "Today"
|
msgid "Today"
|
||||||
msgstr "Aujourd'hui"
|
msgstr "Aujourd'hui"
|
||||||
@@ -1325,11 +1362,11 @@ msgstr "Aujourd'hui"
|
|||||||
msgid "Track {0}"
|
msgid "Track {0}"
|
||||||
msgstr "Piste {0}"
|
msgstr "Piste {0}"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1081
|
#: src/components/ChatModal.tsx:1097
|
||||||
msgid "Try again"
|
msgid "Try again"
|
||||||
msgstr "Réessayer"
|
msgstr "Réessayer"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:1265
|
#: src/components/ChatModal.tsx:1281
|
||||||
msgid "Type a message…"
|
msgid "Type a message…"
|
||||||
msgstr "Écrivez un message…"
|
msgstr "Écrivez un message…"
|
||||||
|
|
||||||
@@ -1354,7 +1391,11 @@ msgstr "Envoi échoué"
|
|||||||
msgid "Uploading {0}%"
|
msgid "Uploading {0}%"
|
||||||
msgstr "Envoi {0} %"
|
msgstr "Envoi {0} %"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:962
|
#: src/components/TextEditor.tsx:526
|
||||||
|
msgid "Uploading image…"
|
||||||
|
msgstr "Envoi de l'image…"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:961
|
||||||
msgid "Uploading…"
|
msgid "Uploading…"
|
||||||
msgstr "Envoi…"
|
msgstr "Envoi…"
|
||||||
|
|
||||||
@@ -1423,11 +1464,11 @@ msgid "What makes it worth it?"
|
|||||||
msgstr "Pourquoi on en voudrait ?"
|
msgstr "Pourquoi on en voudrait ?"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:925
|
#: src/pages/UserPublicProfile.tsx:925
|
||||||
#: src/pages/UserPublicProfile.tsx:1818
|
#: src/pages/UserPublicProfile.tsx:1819
|
||||||
msgid "Who am I?"
|
msgid "Who am I?"
|
||||||
msgstr "Qui suis-je ?"
|
msgstr "Qui suis-je ?"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:928
|
#: src/components/DumpCreateModal.tsx:927
|
||||||
msgid "Who can see it"
|
msgid "Who can see it"
|
||||||
msgstr "Qui peut la voir"
|
msgstr "Qui peut la voir"
|
||||||
|
|
||||||
@@ -1440,11 +1481,11 @@ msgstr "Pourquoi & où"
|
|||||||
msgid "Why?"
|
msgid "Why?"
|
||||||
msgstr "Pourquoi ?"
|
msgstr "Pourquoi ?"
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:405
|
#: src/components/CommentThread.tsx:418
|
||||||
msgid "Write a reply…"
|
msgid "Write a reply…"
|
||||||
msgstr "Écrire une réponse…"
|
msgstr "Écrire une réponse…"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:136
|
#: src/components/ChatModal.tsx:150
|
||||||
#: src/pages/Notifications.tsx:400
|
#: src/pages/Notifications.tsx:400
|
||||||
msgid "Yesterday"
|
msgid "Yesterday"
|
||||||
msgstr "Hier"
|
msgstr "Hier"
|
||||||
|
|||||||
@@ -459,7 +459,6 @@ function DumpEditForm({
|
|||||||
placeholder={t`What makes it worth it?`}
|
placeholder={t`What makes it worth it?`}
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
||||||
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CategorySelect
|
<CategorySelect
|
||||||
|
|||||||
@@ -794,6 +794,7 @@ function PlaylistEditForm(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const description = form.watch("description");
|
const description = form.watch("description");
|
||||||
|
const title = form.watch("title");
|
||||||
|
|
||||||
const onSubmit = form.submit(
|
const onSubmit = form.submit(
|
||||||
async ({ title, description, isPublic, image }) => {
|
async ({ title, description, isPublic, image }) => {
|
||||||
@@ -862,6 +863,9 @@ function PlaylistEditForm(
|
|||||||
className="playlist-edit-input"
|
className="playlist-edit-input"
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onCancel();
|
||||||
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder={t`Playlist title`}
|
placeholder={t`Playlist title`}
|
||||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||||
@@ -877,6 +881,8 @@ function PlaylistEditForm(
|
|||||||
className="playlist-edit-textarea"
|
className="playlist-edit-textarea"
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
|
onSubmit={() => void onSubmit()}
|
||||||
|
onCancel={onCancel}
|
||||||
placeholder={t`Description (optional)`}
|
placeholder={t`Description (optional)`}
|
||||||
autoResize
|
autoResize
|
||||||
rows={2}
|
rows={2}
|
||||||
@@ -924,8 +930,8 @@ function PlaylistEditForm(
|
|||||||
</button>
|
</button>
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
pendingLabel={<Trans>Saving…</Trans>}
|
pendingLabel={<Trans>Saving…</Trans>}
|
||||||
disabled={description.length >
|
disabled={!title.trim() ||
|
||||||
VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||||
>
|
>
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
|
|||||||
@@ -1815,6 +1815,7 @@ function DescriptionEditor(
|
|||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
onSubmit={() => void onSubmit()}
|
onSubmit={() => void onSubmit()}
|
||||||
|
onCancel={submitting ? undefined : onCancel}
|
||||||
placeholder={t`Who am I?`}
|
placeholder={t`Who am I?`}
|
||||||
autoResize
|
autoResize
|
||||||
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
|
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
|
||||||
|
|||||||
@@ -94,6 +94,8 @@
|
|||||||
[data-style="brutalist"] .vote-btn,
|
[data-style="brutalist"] .vote-btn,
|
||||||
[data-style="brutalist"] .emoji-picker-float [frimousse-emoji],
|
[data-style="brutalist"] .emoji-picker-float [frimousse-emoji],
|
||||||
[data-style="brutalist"] .emoji-picker-close-btn,
|
[data-style="brutalist"] .emoji-picker-close-btn,
|
||||||
|
[data-style="brutalist"] .text-editor-emoji-btn,
|
||||||
|
[data-style="brutalist"] .emoji-picker-float [frimousse-skin-tone-selector],
|
||||||
[data-style="brutalist"] .new-item-toggle,
|
[data-style="brutalist"] .new-item-toggle,
|
||||||
[data-style="brutalist"] .btn--ghost,
|
[data-style="brutalist"] .btn--ghost,
|
||||||
[data-style="brutalist"] .audio-player-btn,
|
[data-style="brutalist"] .audio-player-btn,
|
||||||
@@ -128,6 +130,8 @@
|
|||||||
[data-style="brutalist"] .vote-btn:hover:not(:disabled),
|
[data-style="brutalist"] .vote-btn:hover:not(:disabled),
|
||||||
[data-style="brutalist"] .emoji-picker-float [frimousse-emoji]:hover,
|
[data-style="brutalist"] .emoji-picker-float [frimousse-emoji]:hover,
|
||||||
[data-style="brutalist"] .emoji-picker-close-btn:hover,
|
[data-style="brutalist"] .emoji-picker-close-btn:hover,
|
||||||
|
[data-style="brutalist"] .text-editor-emoji-btn:hover:not(:disabled),
|
||||||
|
[data-style="brutalist"] .emoji-picker-float [frimousse-skin-tone-selector]:hover,
|
||||||
[data-style="brutalist"] .new-item-toggle:hover:not(:disabled),
|
[data-style="brutalist"] .new-item-toggle:hover:not(:disabled),
|
||||||
[data-style="brutalist"] .btn--ghost:hover:not(:disabled),
|
[data-style="brutalist"] .btn--ghost:hover:not(:disabled),
|
||||||
[data-style="brutalist"] .audio-player-btn:hover:not(:disabled),
|
[data-style="brutalist"] .audio-player-btn:hover:not(:disabled),
|
||||||
|
|||||||
@@ -189,12 +189,6 @@
|
|||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ghost / icon-only buttons stay flat */
|
|
||||||
[data-style="geocities"] .modal-close-btn,
|
|
||||||
[data-style="geocities"] .playlist-remove-btn,
|
|
||||||
[data-style="geocities"] .playlist-card-delete-btn,
|
|
||||||
[data-style="geocities"] .emoji-picker-float [frimousse-emoji],
|
|
||||||
[data-style="geocities"] .emoji-picker-close-btn,
|
|
||||||
/* Turn the tint up and give it hard diagonal bands — a flat wash reads as an
|
/* Turn the tint up and give it hard diagonal bands — a flat wash reads as an
|
||||||
unstyled gap in a theme built on saturation. */
|
unstyled gap in a theme built on saturation. */
|
||||||
[data-style="geocities"] {
|
[data-style="geocities"] {
|
||||||
@@ -209,6 +203,14 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ghost / icon-only buttons stay flat */
|
||||||
|
[data-style="geocities"] .modal-close-btn,
|
||||||
|
[data-style="geocities"] .playlist-remove-btn,
|
||||||
|
[data-style="geocities"] .playlist-card-delete-btn,
|
||||||
|
[data-style="geocities"] .emoji-picker-float [frimousse-emoji],
|
||||||
|
[data-style="geocities"] .emoji-picker-close-btn,
|
||||||
|
[data-style="geocities"] .text-editor-emoji-btn,
|
||||||
|
[data-style="geocities"] .emoji-picker-float [frimousse-skin-tone-selector],
|
||||||
[data-style="geocities"] .btn--ghost,
|
[data-style="geocities"] .btn--ghost,
|
||||||
[data-style="geocities"] .new-item-toggle,
|
[data-style="geocities"] .new-item-toggle,
|
||||||
[data-style="geocities"] .audio-player-btn,
|
[data-style="geocities"] .audio-player-btn,
|
||||||
|
|||||||
@@ -3,3 +3,9 @@ export function charCountClass(len: number, max: number): string {
|
|||||||
if (len >= max * 0.85) return " text-editor-count--warn";
|
if (len >= max * 0.85) return " text-editor-count--warn";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether `value` is past `max` — the counter's danger state past its
|
||||||
|
* edge, and what the server would refuse. */
|
||||||
|
export function overLimit(value: string, max: number): boolean {
|
||||||
|
return value.length > max;
|
||||||
|
}
|
||||||
|
|||||||
33
src/utils/editorText.ts
Normal file
33
src/utils/editorText.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Text helpers shared by the editor's autocompletes (mentions, emoji).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the caret at the end of `textBefore` sits inside markdown code — an
|
||||||
|
* open ``` fence, or an unclosed `span` on the current line — where `@bob`
|
||||||
|
* and `:smile` are literal text, not something to complete.
|
||||||
|
*/
|
||||||
|
export function insideCode(textBefore: string): boolean {
|
||||||
|
const fences = textBefore.match(/^\s{0,3}```/gm)?.length ?? 0;
|
||||||
|
if (fences % 2 === 1) return true;
|
||||||
|
const line = textBefore.slice(textBefore.lastIndexOf("\n") + 1);
|
||||||
|
if (/^\s{0,3}```/.test(line)) return false;
|
||||||
|
return (line.match(/`/g)?.length ?? 0) % 2 === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJI_ONLY_RE =
|
||||||
|
/^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|[\u{1F3FB}-\u{1F3FF}#*0-9]|\u200D|\uFE0F|\u20E3|\s)+$/u;
|
||||||
|
const PICTOGRAPH_RE = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u;
|
||||||
|
|
||||||
|
/** Whether `text` is nothing but a few emoji (at most `max`), for the
|
||||||
|
* jumbo rendering chats give a message like "🎉" or "👍👍". */
|
||||||
|
export function isEmojiOnly(text: string, max = 3): boolean {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed || !EMOJI_ONLY_RE.test(trimmed)) return false;
|
||||||
|
if (!PICTOGRAPH_RE.test(trimmed)) return false; // "123", "#"
|
||||||
|
const segments = [
|
||||||
|
...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(
|
||||||
|
trimmed.replace(/\s+/g, ""),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return segments.length <= max &&
|
||||||
|
segments.every((s) => PICTOGRAPH_RE.test(s.segment));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user