Sort a notebook, import Markdown back, and stop the page shrinking in Draw
The paper measurably shrank on switching to Draw — 733px to 460px on the same screen. The editor was a flex item at its default `flex: 0 1 auto`, so its width was its widest row's: the toolbar. Swapping a dozen formatting buttons for six drawing ones collapsed it. It now fills the pane, so the width is a property of the window. Each notebook remembers how its notes are ordered, on the server rather than per device. The manual order has to survive being looked away from, so `reindex` renumbers every note in the notebook rather than the filtered view, and dragging is switched off while a search narrows the list. Reordering has its own route: `PUT /notes/:id` would bump `updated_at` and snapshot a version, so a drag would move a note to the top of "last changed" and spend a slot of its history. Markdown and our own ZIP archives come back as notes, with their images re-uploaded as attachments. A drawing returns as the PNG it was exported as — vectors are not recoverable from pixels — and the import says so rather than leaving it to be discovered. Writing the parser turned up a real bug in the exporter: blocks were emitted with no blank line between them, so a list after a list became one list and a quote after an item was read as part of it. Creating or importing from "All Notes" now asks which notebook instead of silently taking the first one. The web-link prompt is a real dialog: a bare domain becomes https, javascript: is refused with a reason. The PDF viewer's hide-drawing control is a labelled button rather than a filled-or-hollow dot, and the export icon points down into a tray like every other download. 186 unit tests, 179 browser checks across eleven suites, including a new `offline` suite that loads the app cold with the network off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3acb8b66a4
commit
0a0864c58b
27 files changed
+2962
-86
No files matched your search
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { importFiles } from "../import/importNotes";
|
||||
|
||||
/**
|
||||
* Runs an import and says plainly what came of it.
|
||||
*
|
||||
* The report is the point. An import can succeed in the sense that no error was
|
||||
* thrown and still not be what the user expected — a drawing arrives as a flat
|
||||
* picture, an image the archive did not contain cannot be restored. Those are
|
||||
* stated afterwards rather than left to be discovered, because the moment to
|
||||
* learn that a drawing is no longer editable is now, while the original export
|
||||
* is still on disk.
|
||||
*/
|
||||
export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
|
||||
const [progress, setProgress] = useState({ done: 0, total: 0, label: "" });
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState("");
|
||||
const started = useRef(false);
|
||||
|
||||
const T = useMemo(
|
||||
() => ({
|
||||
bg: dark ? "#161b22" : "#fff",
|
||||
border: dark ? "#30363d" : "#d0d7de",
|
||||
text: dark ? "#e6edf3" : "#1f2328",
|
||||
muted: dark ? "#8b949e" : "#656d76",
|
||||
accent: dark ? "#388bfd" : "#0969da",
|
||||
warning: dark ? "#d29922" : "#9a6700",
|
||||
danger: dark ? "#f85149" : "#cf222e",
|
||||
track: dark ? "#21262d" : "#e5e7eb",
|
||||
}),
|
||||
[dark]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// React runs effects twice in development; importing twice would create
|
||||
// every note a second time.
|
||||
if (started.current) return;
|
||||
started.current = true;
|
||||
|
||||
importFiles(files, folderId, setProgress)
|
||||
.then((r) => {
|
||||
setResult(r);
|
||||
onDone?.(r);
|
||||
})
|
||||
.catch((e) => setError(e?.message || "Der Import ist fehlgeschlagen."));
|
||||
}, [files, folderId, onDone]);
|
||||
|
||||
const done = result || error;
|
||||
const pct = progress.total ? Math.round((progress.done / progress.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2000,
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
onClick={done ? onClose : undefined}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: T.bg,
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
width: 440,
|
||||
maxWidth: "92vw",
|
||||
border: `1px solid ${T.border}`,
|
||||
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
|
||||
fontFamily: "'DM Sans',sans-serif",
|
||||
color: T.text,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
marginBottom: 14,
|
||||
fontFamily: "'Newsreader',Georgia,serif",
|
||||
}}
|
||||
>
|
||||
{done ? "Import abgeschlossen" : "Notizen werden importiert…"}
|
||||
</div>
|
||||
|
||||
{!done && (
|
||||
<>
|
||||
<div style={{ height: 6, borderRadius: 3, background: T.track, overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
background: T.accent,
|
||||
transition: "width .2s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginTop: 8 }}>
|
||||
{progress.total ? `${progress.done} von ${progress.total}` : "Archiv wird gelesen…"}
|
||||
{progress.label ? ` — ${progress.label}` : ""}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div style={{ fontSize: 13, color: T.danger }}>{error}</div>}
|
||||
|
||||
{result && (
|
||||
<div style={{ fontSize: 13, lineHeight: 1.7 }}>
|
||||
<div>
|
||||
<strong>{result.notes.length}</strong>{" "}
|
||||
{result.notes.length === 1 ? "Notiz importiert" : "Notizen importiert"}
|
||||
</div>
|
||||
|
||||
{result.flattenedDrawings > 0 && (
|
||||
<div style={{ color: T.warning, marginTop: 10, fontSize: 12.5 }}>
|
||||
{result.flattenedDrawings === 1
|
||||
? "Eine Zeichnung kam als Bild zurück"
|
||||
: `${result.flattenedDrawings} Zeichnungen kamen als Bilder zurück`}{" "}
|
||||
— im Archiv liegen sie als PNG, und aus Pixeln lassen sich die Striche nicht zurückrechnen.
|
||||
Sie sind sichtbar und exportierbar, aber nicht mehr mit dem Stift zu bearbeiten.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.missingImages > 0 && (
|
||||
<div style={{ color: T.warning, marginTop: 10, fontSize: 12.5 }}>
|
||||
{result.missingImages} {result.missingImages === 1 ? "Bild lag" : "Bilder lagen"} nicht im
|
||||
Archiv. Die Stelle in der Notiz bleibt erhalten, das Bild fehlt.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.failed.length > 0 && (
|
||||
<div style={{ color: T.danger, marginTop: 10, fontSize: 12.5 }}>
|
||||
Nicht gelesen: {result.failed.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 20 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={!done}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: done ? T.accent : T.track,
|
||||
color: done ? "#fff" : T.muted,
|
||||
cursor: done ? "pointer" : "default",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{done ? "Fertig" : "Läuft…"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Attaching a web address to the selected text.
|
||||
*
|
||||
* This replaces `window.prompt`. That dialog cannot be styled, cannot be
|
||||
* reached by a phone keyboard's paste button on every browser, gives no room to
|
||||
* say *why* an address was refused, and — worst here — it is modal to the whole
|
||||
* browser, so it steals focus from the editor and takes the selection with it on
|
||||
* some engines. A real dialog also lets an existing link be removed with a
|
||||
* button instead of the folklore of "leave the box empty".
|
||||
*
|
||||
* Only `http`, `https` and `mailto` are allowed. `javascript:` in an href is a
|
||||
* script that runs with the page's own privileges when a note is clicked, and
|
||||
* notes are exported, shared and re-imported; the check lives here and in the
|
||||
* editor schema both, because either one alone is one mistake from a hole.
|
||||
*/
|
||||
const ALLOWED = /^(https?|mailto):/i;
|
||||
|
||||
export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark }) {
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
const [error, setError] = useState("");
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const T = useMemo(
|
||||
() => ({
|
||||
bg: dark ? "#161b22" : "#fff",
|
||||
border: dark ? "#30363d" : "#d0d7de",
|
||||
text: dark ? "#e6edf3" : "#1f2328",
|
||||
muted: dark ? "#8b949e" : "#656d76",
|
||||
accent: dark ? "#388bfd" : "#0969da",
|
||||
danger: dark ? "#f85149" : "#cf222e",
|
||||
field: dark ? "#0d1117" : "#f6f8fa",
|
||||
}),
|
||||
[dark]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const submit = (e) => {
|
||||
e?.preventDefault();
|
||||
const value = url.trim();
|
||||
if (!value) {
|
||||
setError("Bitte eine Adresse eingeben — oder den Link entfernen.");
|
||||
return;
|
||||
}
|
||||
// A bare "example.com" is what people type; assume https rather than
|
||||
// refusing something whose meaning is perfectly clear.
|
||||
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`;
|
||||
if (!ALLOWED.test(candidate)) {
|
||||
setError("Nur http-, https- und mailto-Adressen sind erlaubt.");
|
||||
return;
|
||||
}
|
||||
onSubmit(candidate);
|
||||
};
|
||||
|
||||
const button = (kind) => ({
|
||||
padding: "8px 18px",
|
||||
borderRadius: 10,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
border: kind === "primary" ? "none" : `1px solid ${T.border}`,
|
||||
background: kind === "primary" ? T.accent : "transparent",
|
||||
color: kind === "primary" ? "#fff" : kind === "danger" ? T.danger : T.text,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2000,
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<form
|
||||
onSubmit={submit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: T.bg,
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
width: 430,
|
||||
maxWidth: "92vw",
|
||||
border: `1px solid ${T.border}`,
|
||||
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
|
||||
fontFamily: "'DM Sans',sans-serif",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
marginBottom: 14,
|
||||
fontFamily: "'Newsreader',Georgia,serif",
|
||||
}}
|
||||
>
|
||||
{initialUrl ? "Link bearbeiten" : "Link einfügen"}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
aria-label="Link address"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
spellCheck={false}
|
||||
autoComplete="url"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${error ? T.danger : T.border}`,
|
||||
background: T.field,
|
||||
color: T.text,
|
||||
fontSize: 13,
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<div style={{ minHeight: 18, marginTop: 6, fontSize: 11.5, color: error ? T.danger : T.muted }}>
|
||||
{error || "http, https oder mailto"}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 12 }}>
|
||||
{initialUrl && (
|
||||
<button type="button" onClick={onRemove} style={{ ...button("danger"), marginRight: "auto" }}>
|
||||
Link entfernen
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onClose} style={button()}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" style={button("primary")}>
|
||||
{initialUrl ? "Ändern" : "Einfügen"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { COVERS } from "./constants.jsx";
|
||||
|
||||
/**
|
||||
* Asks which notebook something should go into.
|
||||
*
|
||||
* "All Notes", "Starred" and the dashboard are views, not places — a note
|
||||
* created while one of them is open has no notebook to belong to. The app used
|
||||
* to answer that silently with `notebooks[0]`, so a note or an imported PDF
|
||||
* landed in whichever notebook happened to sort first. Nothing was lost, but
|
||||
* finding it again meant knowing that rule.
|
||||
*
|
||||
* The last choice is remembered, so the common case — always the same notebook —
|
||||
* is one Enter away, while the answer stays visible instead of being guessed.
|
||||
*/
|
||||
const LAST_KEY = "inknote_last_notebook";
|
||||
|
||||
export const rememberNotebook = (id) => {
|
||||
try {
|
||||
localStorage.setItem(LAST_KEY, id);
|
||||
} catch {
|
||||
/* private mode: the default simply is not remembered */
|
||||
}
|
||||
};
|
||||
|
||||
const recalledNotebook = () => {
|
||||
try {
|
||||
return localStorage.getItem(LAST_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function NotebookPicker({ notebooks, title, confirmLabel = "Weiter", onPick, onClose, dark }) {
|
||||
const remembered = recalledNotebook();
|
||||
const [selected, setSelected] = useState(
|
||||
() => notebooks.find((nb) => nb.id === remembered)?.id || notebooks[0]?.id || null
|
||||
);
|
||||
|
||||
const T = useMemo(
|
||||
() => ({
|
||||
bg: dark ? "#161b22" : "#fff",
|
||||
border: dark ? "#30363d" : "#d0d7de",
|
||||
text: dark ? "#e6edf3" : "#1f2328",
|
||||
muted: dark ? "#8b949e" : "#656d76",
|
||||
accent: dark ? "#388bfd" : "#0969da",
|
||||
row: dark ? "#0d1117" : "#f6f8fa",
|
||||
}),
|
||||
[dark]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Enter" && selected) confirm();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
const confirm = () => {
|
||||
if (!selected) return;
|
||||
rememberNotebook(selected);
|
||||
onPick(selected);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2000,
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: T.bg,
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
width: 400,
|
||||
maxWidth: "92vw",
|
||||
border: `1px solid ${T.border}`,
|
||||
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
|
||||
fontFamily: "'DM Sans',sans-serif",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
marginBottom: 4,
|
||||
fontFamily: "'Newsreader',Georgia,serif",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 14 }}>In welches Notizbuch soll es?</div>
|
||||
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Notebook"
|
||||
style={{ maxHeight: 280, overflowY: "auto", display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
{notebooks.map((nb) => {
|
||||
const active = nb.id === selected;
|
||||
return (
|
||||
<button
|
||||
key={nb.id}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
data-notebook-option={nb.id}
|
||||
onClick={() => setSelected(nb.id)}
|
||||
onDoubleClick={confirm}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
width: "100%",
|
||||
padding: "9px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${active ? T.accent + "66" : "transparent"}`,
|
||||
background: active ? T.accent + "1a" : T.row,
|
||||
color: T.text,
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 3,
|
||||
flexShrink: 0,
|
||||
background: COVERS[nb.cover % COVERS.length].bg,
|
||||
}}
|
||||
/>
|
||||
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{nb.name}
|
||||
</span>
|
||||
{nb.id === remembered && (
|
||||
<span style={{ fontSize: 10, color: T.muted }}>zuletzt benutzt</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 18 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: "transparent",
|
||||
color: T.text,
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={confirm}
|
||||
disabled={!selected}
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: selected ? T.accent : T.border,
|
||||
color: "#fff",
|
||||
cursor: selected ? "pointer" : "default",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -470,6 +470,9 @@ export const I = {
|
||||
<path d="M14 18v-6h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
/* Export saves a file to the device, so the arrow points down into the tray.
|
||||
* It used to point up and out, which is the shape every other program uses
|
||||
* for uploading or sharing — the opposite of what this button does. */
|
||||
export: (
|
||||
<svg
|
||||
width="16"
|
||||
@@ -482,8 +485,75 @@ export const I = {
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="15" />
|
||||
<polyline points="8 11 12 15 16 11" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
),
|
||||
/* Reading a file in — the mirror of `export`, for the Markdown importer. */
|
||||
import: (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="16 7 12 3 8 7" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
),
|
||||
eye: (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
),
|
||||
eyeOff: (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
),
|
||||
sort: (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="4" y1="7" x2="16" y2="7" />
|
||||
<line x1="4" y1="12" x2="13" y2="12" />
|
||||
<line x1="4" y1="17" x2="10" y2="17" />
|
||||
<polyline points="17 14 20 17 23 14" />
|
||||
<line x1="20" y1="17" x2="20" y2="7" />
|
||||
</svg>
|
||||
),
|
||||
users: (
|
||||
|
||||
Reference in new issue
Block a user