;
const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewProps) => {
const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]);
@@ -117,8 +186,8 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
-
- {visual.length > 0 &&
}
+
+ {visual.length > 0 &&
}
{visual.length > 0 && sectionCount > 1 &&
}
From c0d5854f678f357cf4054d448e0311f2ee90f8ac Mon Sep 17 00:00:00 2001
From: memoclaw
Date: Wed, 1 Apr 2026 09:36:33 +0800
Subject: [PATCH 3/4] feat(editor): add voice note recording to the memo
composer (#5801)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
---
.../definition.md | 47 +++++
.../2026-03-31-quick-voice-input/design.md | 58 ++++++
.../2026-03-31-quick-voice-input/execution.md | 40 ++++
.../2026-03-31-quick-voice-input/plan.md | 63 ++++++
.../MemoEditor/Toolbar/InsertMenu.tsx | 20 +-
.../MemoEditor/components/EditorToolbar.tsx | 3 +-
.../components/VoiceRecorderPanel.tsx | 135 ++++++++++++
.../components/MemoEditor/components/index.ts | 1 +
web/src/components/MemoEditor/hooks/index.ts | 1 +
.../MemoEditor/hooks/useVoiceRecorder.ts | 192 ++++++++++++++++++
web/src/components/MemoEditor/index.tsx | 93 ++++++++-
.../MemoEditor/services/memoService.ts | 8 +
.../MemoEditor/services/validationService.ts | 5 +
.../components/MemoEditor/state/actions.ts | 32 ++-
.../components/MemoEditor/state/reducer.ts | 55 +++++
web/src/components/MemoEditor/state/types.ts | 30 +++
.../components/MemoEditor/types/attachment.ts | 3 +-
.../components/MemoEditor/types/components.ts | 13 ++
.../Attachment/AttachmentListEditor.tsx | 163 +++++++++------
.../Attachment/AudioAttachmentItem.tsx | 45 ++--
.../MemoMetadata/Attachment/index.ts | 1 +
web/src/locales/en.json | 23 ++-
web/src/utils/format.ts | 11 +-
23 files changed, 948 insertions(+), 94 deletions(-)
create mode 100644 docs/issues/2026-03-31-quick-voice-input/definition.md
create mode 100644 docs/issues/2026-03-31-quick-voice-input/design.md
create mode 100644 docs/issues/2026-03-31-quick-voice-input/execution.md
create mode 100644 docs/issues/2026-03-31-quick-voice-input/plan.md
create mode 100644 web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx
create mode 100644 web/src/components/MemoEditor/hooks/useVoiceRecorder.ts
diff --git a/docs/issues/2026-03-31-quick-voice-input/definition.md b/docs/issues/2026-03-31-quick-voice-input/definition.md
new file mode 100644
index 000000000..9c8b5cd22
--- /dev/null
+++ b/docs/issues/2026-03-31-quick-voice-input/definition.md
@@ -0,0 +1,47 @@
+## Background & Context
+
+Memos is a self-hosted note-taking product whose main write path is the React memo composer in `web/src/components/MemoEditor`. Memo content is stored as Markdown text, attachments are uploaded through the v1 attachment API, and the server already has dedicated file-serving behavior for media playback. The most recent relevant change in this area was commit `63a17d89`, which refactored audio attachment rendering into reusable playback components. That change improved how audio files are displayed after upload; it did not add a microphone-driven input path inside the compose flow.
+
+## Issue Statement
+
+Memo creation currently starts from typed text plus file upload and metadata pickers, while audio support in the product begins only after an audio file already exists as an attachment. Users who want to capture memo content by speaking must leave the compose flow to record elsewhere, then upload or manually transcribe the result, because the editor has no direct path from microphone input to memo text or an in-progress audio attachment.
+
+## Current State
+
+- `web/src/components/MemoEditor/index.tsx:26-154` assembles the compose flow from `EditorContent`, `EditorMetadata`, and `EditorToolbar`, and persists drafts through `memoService.save`.
+- `web/src/components/MemoEditor/Editor/index.tsx:27-214` implements the editor surface as a `
diff --git a/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx b/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx
new file mode 100644
index 000000000..47aa9ce38
--- /dev/null
+++ b/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx
@@ -0,0 +1,135 @@
+import { AudioLinesIcon, LoaderCircleIcon, MicIcon, RotateCcwIcon, SquareIcon, Trash2Icon } from "lucide-react";
+import type { FC } from "react";
+import { AudioAttachmentItem } from "@/components/MemoMetadata/Attachment";
+import { formatAudioTime } from "@/components/MemoMetadata/Attachment/attachmentViewHelpers";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { useTranslate } from "@/utils/i18n";
+import type { VoiceRecorderPanelProps } from "../types/components";
+
+export const VoiceRecorderPanel: FC
= ({
+ voiceRecorder,
+ onStart,
+ onStop,
+ onKeep,
+ onDiscard,
+ onRecordAgain,
+ onClose,
+}) => {
+ const t = useTranslate();
+ const { status, elapsedSeconds, error, recording } = voiceRecorder;
+
+ const isRecording = status === "recording";
+ const isRequestingPermission = status === "requesting_permission";
+ const isUnsupported = status === "unsupported";
+ const hasRecording = status === "recorded" && recording;
+
+ return (
+
+
+
+
+ {isRequestingPermission ? (
+
+ ) : hasRecording ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {isRecording
+ ? t("editor.voice-recorder.recording")
+ : isRequestingPermission
+ ? t("editor.voice-recorder.requesting-permission")
+ : hasRecording
+ ? t("editor.voice-recorder.ready")
+ : isUnsupported
+ ? t("editor.voice-recorder.unsupported")
+ : error
+ ? t("editor.voice-recorder.error")
+ : t("editor.voice-recorder.title")}
+
+
+
+ {isRecording
+ ? t("editor.voice-recorder.recording-description", { duration: formatAudioTime(elapsedSeconds) })
+ : isRequestingPermission
+ ? t("editor.voice-recorder.requesting-permission-description")
+ : hasRecording
+ ? t("editor.voice-recorder.ready-description")
+ : isUnsupported
+ ? t("editor.voice-recorder.unsupported-description")
+ : error
+ ? error
+ : t("editor.voice-recorder.idle-description")}
+
+
+
+
+ {isRecording && (
+
+
+ {formatAudioTime(elapsedSeconds)}
+
+ )}
+
+
+ {hasRecording && (
+
+ )}
+
+
+ {hasRecording ? (
+ <>
+
+
+ {t("editor.voice-recorder.discard")}
+
+
+
+ {t("editor.voice-recorder.record-again")}
+
+
+
+ {t("editor.voice-recorder.keep")}
+
+ >
+ ) : isRecording ? (
+
+
+ {t("editor.voice-recorder.stop")}
+
+ ) : (
+ <>
+
+ {t("common.close")}
+
+ {!isUnsupported && (
+
+ {isRequestingPermission ? : }
+ {isRequestingPermission ? t("editor.voice-recorder.requesting") : t("editor.voice-recorder.start")}
+
+ )}
+ >
+ )}
+
+
+ );
+};
diff --git a/web/src/components/MemoEditor/components/index.ts b/web/src/components/MemoEditor/components/index.ts
index 26043d845..eed02ff12 100644
--- a/web/src/components/MemoEditor/components/index.ts
+++ b/web/src/components/MemoEditor/components/index.ts
@@ -5,3 +5,4 @@ export * from "./EditorMetadata";
export * from "./EditorToolbar";
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
export { TimestampPopover } from "./TimestampPopover";
+export * from "./VoiceRecorderPanel";
diff --git a/web/src/components/MemoEditor/hooks/index.ts b/web/src/components/MemoEditor/hooks/index.ts
index abd93e71e..a1ca003e8 100644
--- a/web/src/components/MemoEditor/hooks/index.ts
+++ b/web/src/components/MemoEditor/hooks/index.ts
@@ -8,3 +8,4 @@ export { useKeyboard } from "./useKeyboard";
export { useLinkMemo } from "./useLinkMemo";
export { useLocation } from "./useLocation";
export { useMemoInit } from "./useMemoInit";
+export { useVoiceRecorder } from "./useVoiceRecorder";
diff --git a/web/src/components/MemoEditor/hooks/useVoiceRecorder.ts b/web/src/components/MemoEditor/hooks/useVoiceRecorder.ts
new file mode 100644
index 000000000..eb21d4a67
--- /dev/null
+++ b/web/src/components/MemoEditor/hooks/useVoiceRecorder.ts
@@ -0,0 +1,192 @@
+import { useEffect, useRef } from "react";
+import type { LocalFile } from "../types/attachment";
+import { useBlobUrls } from "./useBlobUrls";
+
+const FALLBACK_AUDIO_MIME_TYPE = "audio/webm";
+
+interface VoiceRecorderActions {
+ setVoiceRecorderSupport: (value: boolean) => void;
+ setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => void;
+ setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") => void;
+ setVoiceRecorderElapsed: (value: number) => void;
+ setVoiceRecorderError: (value?: string) => void;
+ setVoiceRecording: (value?: { localFile: LocalFile; durationSeconds: number; mimeType: string }) => void;
+}
+
+const AUDIO_MIME_TYPE_CANDIDATES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"] as const;
+
+function getSupportedAudioMimeType(): string | undefined {
+ if (typeof window === "undefined" || typeof MediaRecorder === "undefined") {
+ return undefined;
+ }
+
+ for (const candidate of AUDIO_MIME_TYPE_CANDIDATES) {
+ if (MediaRecorder.isTypeSupported(candidate)) {
+ return candidate;
+ }
+ }
+
+ return undefined;
+}
+
+function getFileExtension(mimeType: string): string {
+ if (mimeType.includes("ogg")) return "ogg";
+ if (mimeType.includes("mp4")) return "m4a";
+ return "webm";
+}
+
+function createRecordedFile(blob: Blob, mimeType: string): File {
+ const extension = getFileExtension(mimeType);
+ const now = new Date();
+ const datePart = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0")].join("");
+ const timePart = [String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0")].join("");
+ return new File([blob], `voice-note-${datePart}-${timePart}.${extension}`, { type: mimeType });
+}
+
+export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
+ const mediaRecorderRef = useRef(null);
+ const mediaStreamRef = useRef(null);
+ const chunksRef = useRef([]);
+ const startedAtRef = useRef(null);
+ const elapsedTimerRef = useRef(null);
+ const recorderMimeTypeRef = useRef(FALLBACK_AUDIO_MIME_TYPE);
+ const { createBlobUrl } = useBlobUrls();
+
+ const cleanupTimer = () => {
+ if (elapsedTimerRef.current !== null) {
+ window.clearInterval(elapsedTimerRef.current);
+ elapsedTimerRef.current = null;
+ }
+ };
+
+ const cleanupStream = () => {
+ mediaStreamRef.current?.getTracks().forEach((track) => track.stop());
+ mediaStreamRef.current = null;
+ };
+
+ const resetRecorderRefs = () => {
+ cleanupTimer();
+ cleanupStream();
+ mediaRecorderRef.current = null;
+ chunksRef.current = [];
+ startedAtRef.current = null;
+ };
+
+ useEffect(() => {
+ const isSupported =
+ typeof window !== "undefined" &&
+ typeof navigator !== "undefined" &&
+ typeof navigator.mediaDevices?.getUserMedia === "function" &&
+ typeof MediaRecorder !== "undefined";
+
+ actions.setVoiceRecorderSupport(isSupported);
+ if (!isSupported) {
+ actions.setVoiceRecorderStatus("unsupported");
+ actions.setVoiceRecorderError("Voice recording is not supported in this browser.");
+ return;
+ }
+
+ actions.setVoiceRecorderStatus("idle");
+ actions.setVoiceRecorderError(undefined);
+
+ return () => {
+ resetRecorderRefs();
+ };
+ }, [actions]);
+
+ const startRecording = async () => {
+ if (
+ typeof navigator === "undefined" ||
+ typeof navigator.mediaDevices?.getUserMedia !== "function" ||
+ typeof MediaRecorder === "undefined"
+ ) {
+ actions.setVoiceRecorderSupport(false);
+ actions.setVoiceRecorderStatus("unsupported");
+ actions.setVoiceRecorderError("Voice recording is not supported in this browser.");
+ return;
+ }
+
+ actions.setVoiceRecorderError(undefined);
+ actions.setVoiceRecorderStatus("requesting_permission");
+ actions.setVoiceRecorderElapsed(0);
+ actions.setVoiceRecording(undefined);
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const mimeType = getSupportedAudioMimeType() ?? FALLBACK_AUDIO_MIME_TYPE;
+ const mediaRecorder = new MediaRecorder(stream, getSupportedAudioMimeType() ? { mimeType } : undefined);
+
+ recorderMimeTypeRef.current = mimeType;
+ mediaStreamRef.current = stream;
+ mediaRecorderRef.current = mediaRecorder;
+ chunksRef.current = [];
+
+ mediaRecorder.addEventListener("dataavailable", (event) => {
+ if (event.data.size > 0) {
+ chunksRef.current.push(event.data);
+ }
+ });
+
+ mediaRecorder.addEventListener("stop", () => {
+ const durationSeconds = startedAtRef.current ? Math.max(0, Math.round((Date.now() - startedAtRef.current) / 1000)) : 0;
+ const blob = new Blob(chunksRef.current, { type: recorderMimeTypeRef.current });
+ const file = createRecordedFile(blob, recorderMimeTypeRef.current);
+ const previewUrl = createBlobUrl(file);
+
+ actions.setVoiceRecording({
+ localFile: {
+ file,
+ previewUrl,
+ },
+ durationSeconds,
+ mimeType: recorderMimeTypeRef.current,
+ });
+ actions.setVoiceRecorderElapsed(durationSeconds);
+ actions.setVoiceRecorderStatus("recorded");
+ resetRecorderRefs();
+ });
+
+ mediaRecorder.start();
+ startedAtRef.current = Date.now();
+ actions.setVoiceRecorderPermission("granted");
+ actions.setVoiceRecorderStatus("recording");
+
+ elapsedTimerRef.current = window.setInterval(() => {
+ if (startedAtRef.current) {
+ actions.setVoiceRecorderElapsed(Math.max(0, Math.floor((Date.now() - startedAtRef.current) / 1000)));
+ }
+ }, 250);
+ } catch (error) {
+ const permissionDenied =
+ error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "PermissionDeniedError");
+
+ actions.setVoiceRecorderPermission(permissionDenied ? "denied" : "unknown");
+ actions.setVoiceRecorderStatus("error");
+ actions.setVoiceRecorderError(permissionDenied ? "Microphone permission was denied." : "Failed to start voice recording.");
+ resetRecorderRefs();
+ }
+ };
+
+ const stopRecording = () => {
+ if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "inactive") {
+ return;
+ }
+
+ cleanupTimer();
+ mediaRecorderRef.current.stop();
+ };
+
+ const resetRecording = () => {
+ resetRecorderRefs();
+ actions.setVoiceRecorderElapsed(0);
+ actions.setVoiceRecorderError(undefined);
+ actions.setVoiceRecording(undefined);
+ actions.setVoiceRecorderStatus("idle");
+ };
+
+ return {
+ startRecording,
+ stopRecording,
+ resetRecording,
+ };
+};
diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx
index bfbbc8775..38798c312 100644
--- a/web/src/components/MemoEditor/index.tsx
+++ b/web/src/components/MemoEditor/index.tsx
@@ -1,5 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
-import { useRef } from "react";
+import { useMemo, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser";
@@ -9,10 +9,18 @@ import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString } from "@/utils/memo";
-import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay, TimestampPopover } from "./components";
+import {
+ EditorContent,
+ EditorMetadata,
+ EditorToolbar,
+ FocusModeExitButton,
+ FocusModeOverlay,
+ TimestampPopover,
+ VoiceRecorderPanel,
+} from "./components";
import { FOCUS_MODE_STYLES } from "./constants";
import type { EditorRefActions } from "./Editor";
-import { useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks";
+import { useAutoSave, useFocusMode, useKeyboard, useMemoInit, useVoiceRecorder } from "./hooks";
import { cacheService, errorService, memoService, validationService } from "./services";
import { EditorProvider, useEditorContext } from "./state";
import type { MemoEditorProps } from "./types";
@@ -39,6 +47,7 @@ const MemoEditorImpl: React.FC = ({
const editorRef = useRef(null);
const { state, actions, dispatch } = useEditorContext();
const { userGeneralSetting } = useAuth();
+ const [isVoiceRecorderOpen, setIsVoiceRecorderOpen] = useState(false);
const memoName = memo?.name;
@@ -53,10 +62,74 @@ const MemoEditorImpl: React.FC = ({
// Focus mode management with body scroll lock
useFocusMode(state.ui.isFocusMode);
+ const voiceRecorderActions = useMemo(
+ () => ({
+ setVoiceRecorderSupport: (value: boolean) => dispatch(actions.setVoiceRecorderSupport(value)),
+ setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => dispatch(actions.setVoiceRecorderPermission(value)),
+ setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") =>
+ dispatch(actions.setVoiceRecorderStatus(value)),
+ setVoiceRecorderElapsed: (value: number) => dispatch(actions.setVoiceRecorderElapsed(value)),
+ setVoiceRecorderError: (value?: string) => dispatch(actions.setVoiceRecorderError(value)),
+ setVoiceRecording: (value?: typeof state.voiceRecorder.recording) => dispatch(actions.setVoiceRecording(value)),
+ }),
+ [actions, dispatch],
+ );
+
+ const voiceRecorder = useVoiceRecorder(voiceRecorderActions);
+
const handleToggleFocusMode = () => {
dispatch(actions.toggleFocusMode());
};
+ const handleStartVoiceRecording = async () => {
+ setIsVoiceRecorderOpen(true);
+ await voiceRecorder.startRecording();
+ };
+
+ const handleVoiceRecorderClick = () => {
+ setIsVoiceRecorderOpen(true);
+
+ if (
+ state.voiceRecorder.status === "recording" ||
+ state.voiceRecorder.status === "requesting_permission" ||
+ state.voiceRecorder.status === "recorded"
+ ) {
+ return;
+ }
+
+ void handleStartVoiceRecording();
+ };
+
+ const handleKeepVoiceRecording = () => {
+ const recording = state.voiceRecorder.recording;
+ if (!recording) {
+ return;
+ }
+
+ dispatch(actions.addLocalFile(recording.localFile));
+ voiceRecorder.resetRecording();
+ setIsVoiceRecorderOpen(false);
+ };
+
+ const handleDiscardVoiceRecording = () => {
+ voiceRecorder.resetRecording();
+ setIsVoiceRecorderOpen(false);
+ };
+
+ const handleCloseVoiceRecorder = () => {
+ if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") {
+ return;
+ }
+
+ voiceRecorder.resetRecording();
+ setIsVoiceRecorderOpen(false);
+ };
+
+ const handleRecordAgain = async () => {
+ voiceRecorder.resetRecording();
+ await handleStartVoiceRecording();
+ };
+
useKeyboard(editorRef, handleSave);
async function handleSave() {
@@ -147,10 +220,22 @@ const MemoEditorImpl: React.FC = ({
{/* Editor content grows to fill available space in focus mode */}
+ {isVoiceRecorderOpen && (
+ void handleStartVoiceRecording()}
+ onStop={voiceRecorder.stopRecording}
+ onKeep={handleKeepVoiceRecording}
+ onDiscard={handleDiscardVoiceRecording}
+ onRecordAgain={() => void handleRecordAgain()}
+ onClose={handleCloseVoiceRecorder}
+ />
+ )}
+
{/* Metadata and toolbar grouped together at bottom */}
-
+
>
diff --git a/web/src/components/MemoEditor/services/memoService.ts b/web/src/components/MemoEditor/services/memoService.ts
index 57fbad4e1..0230aec42 100644
--- a/web/src/components/MemoEditor/services/memoService.ts
+++ b/web/src/components/MemoEditor/services/memoService.ts
@@ -142,6 +142,14 @@ export const memoService = {
updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined,
},
localFiles: [],
+ voiceRecorder: {
+ isSupported: true,
+ permission: "unknown",
+ status: "idle",
+ elapsedSeconds: 0,
+ error: undefined,
+ recording: undefined,
+ },
};
},
};
diff --git a/web/src/components/MemoEditor/services/validationService.ts b/web/src/components/MemoEditor/services/validationService.ts
index 139348ab1..ac32c1504 100644
--- a/web/src/components/MemoEditor/services/validationService.ts
+++ b/web/src/components/MemoEditor/services/validationService.ts
@@ -22,6 +22,11 @@ export const validationService = {
return { valid: false, reason: "Wait for upload to complete" };
}
+ // Cannot save while voice recorder is active
+ if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") {
+ return { valid: false, reason: "Finish voice recording before saving" };
+ }
+
// Cannot save while already saving
if (state.ui.isLoading.saving) {
return { valid: false, reason: "Save in progress" };
diff --git a/web/src/components/MemoEditor/state/actions.ts b/web/src/components/MemoEditor/state/actions.ts
index 166a50489..3b0b1be90 100644
--- a/web/src/components/MemoEditor/state/actions.ts
+++ b/web/src/components/MemoEditor/state/actions.ts
@@ -1,7 +1,7 @@
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import type { LocalFile } from "../types/attachment";
-import type { EditorAction, EditorState, LoadingKey } from "./types";
+import type { EditorAction, EditorState, LoadingKey, VoiceRecorderPermission, VoiceRecorderStatus, VoiceRecordingPreview } from "./types";
export const editorActions = {
initMemo: (payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] }): EditorAction => ({
@@ -72,6 +72,36 @@ export const editorActions = {
payload: timestamps,
}),
+ setVoiceRecorderSupport: (value: boolean): EditorAction => ({
+ type: "SET_VOICE_RECORDER_SUPPORT",
+ payload: value,
+ }),
+
+ setVoiceRecorderPermission: (value: VoiceRecorderPermission): EditorAction => ({
+ type: "SET_VOICE_RECORDER_PERMISSION",
+ payload: value,
+ }),
+
+ setVoiceRecorderStatus: (value: VoiceRecorderStatus): EditorAction => ({
+ type: "SET_VOICE_RECORDER_STATUS",
+ payload: value,
+ }),
+
+ setVoiceRecorderElapsed: (value: number): EditorAction => ({
+ type: "SET_VOICE_RECORDER_ELAPSED",
+ payload: value,
+ }),
+
+ setVoiceRecorderError: (value?: string): EditorAction => ({
+ type: "SET_VOICE_RECORDER_ERROR",
+ payload: value,
+ }),
+
+ setVoiceRecording: (value?: VoiceRecordingPreview): EditorAction => ({
+ type: "SET_VOICE_RECORDING",
+ payload: value,
+ }),
+
reset: (): EditorAction => ({
type: "RESET",
}),
diff --git a/web/src/components/MemoEditor/state/reducer.ts b/web/src/components/MemoEditor/state/reducer.ts
index d2add61d6..2ad6ee49d 100644
--- a/web/src/components/MemoEditor/state/reducer.ts
+++ b/web/src/components/MemoEditor/state/reducer.ts
@@ -119,6 +119,61 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
},
};
+ case "SET_VOICE_RECORDER_SUPPORT":
+ return {
+ ...state,
+ voiceRecorder: {
+ ...state.voiceRecorder,
+ isSupported: action.payload,
+ status: action.payload ? state.voiceRecorder.status : "unsupported",
+ },
+ };
+
+ case "SET_VOICE_RECORDER_PERMISSION":
+ return {
+ ...state,
+ voiceRecorder: {
+ ...state.voiceRecorder,
+ permission: action.payload,
+ },
+ };
+
+ case "SET_VOICE_RECORDER_STATUS":
+ return {
+ ...state,
+ voiceRecorder: {
+ ...state.voiceRecorder,
+ status: action.payload,
+ },
+ };
+
+ case "SET_VOICE_RECORDER_ELAPSED":
+ return {
+ ...state,
+ voiceRecorder: {
+ ...state.voiceRecorder,
+ elapsedSeconds: action.payload,
+ },
+ };
+
+ case "SET_VOICE_RECORDER_ERROR":
+ return {
+ ...state,
+ voiceRecorder: {
+ ...state.voiceRecorder,
+ error: action.payload,
+ },
+ };
+
+ case "SET_VOICE_RECORDING":
+ return {
+ ...state,
+ voiceRecorder: {
+ ...state.voiceRecorder,
+ recording: action.payload,
+ },
+ };
+
case "RESET":
return {
...initialState,
diff --git a/web/src/components/MemoEditor/state/types.ts b/web/src/components/MemoEditor/state/types.ts
index f10bba1e0..bae16313f 100644
--- a/web/src/components/MemoEditor/state/types.ts
+++ b/web/src/components/MemoEditor/state/types.ts
@@ -4,6 +4,14 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import type { LocalFile } from "../types/attachment";
export type LoadingKey = "saving" | "uploading" | "loading";
+export type VoiceRecorderPermission = "unknown" | "granted" | "denied";
+export type VoiceRecorderStatus = "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported";
+
+export interface VoiceRecordingPreview {
+ localFile: LocalFile;
+ durationSeconds: number;
+ mimeType: string;
+}
export interface EditorState {
content: string;
@@ -27,6 +35,14 @@ export interface EditorState {
updateTime?: Date;
};
localFiles: LocalFile[];
+ voiceRecorder: {
+ isSupported: boolean;
+ permission: VoiceRecorderPermission;
+ status: VoiceRecorderStatus;
+ elapsedSeconds: number;
+ error?: string;
+ recording?: VoiceRecordingPreview;
+ };
}
export type EditorAction =
@@ -44,6 +60,12 @@ export type EditorAction =
| { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } }
| { type: "SET_COMPOSING"; payload: boolean }
| { type: "SET_TIMESTAMPS"; payload: Partial
}
+ | { type: "SET_VOICE_RECORDER_SUPPORT"; payload: boolean }
+ | { type: "SET_VOICE_RECORDER_PERMISSION"; payload: VoiceRecorderPermission }
+ | { type: "SET_VOICE_RECORDER_STATUS"; payload: VoiceRecorderStatus }
+ | { type: "SET_VOICE_RECORDER_ELAPSED"; payload: number }
+ | { type: "SET_VOICE_RECORDER_ERROR"; payload?: string }
+ | { type: "SET_VOICE_RECORDING"; payload?: VoiceRecordingPreview }
| { type: "RESET" };
export const initialState: EditorState = {
@@ -68,4 +90,12 @@ export const initialState: EditorState = {
updateTime: undefined,
},
localFiles: [],
+ voiceRecorder: {
+ isSupported: true,
+ permission: "unknown",
+ status: "idle",
+ elapsedSeconds: 0,
+ error: undefined,
+ recording: undefined,
+ },
};
diff --git a/web/src/components/MemoEditor/types/attachment.ts b/web/src/components/MemoEditor/types/attachment.ts
index 78dd984ce..c41849c33 100644
--- a/web/src/components/MemoEditor/types/attachment.ts
+++ b/web/src/components/MemoEditor/types/attachment.ts
@@ -1,7 +1,7 @@
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
-export type FileCategory = "image" | "video" | "document";
+export type FileCategory = "image" | "video" | "audio" | "document";
// Unified view model for rendering attachments and local files
export interface AttachmentItem {
@@ -24,6 +24,7 @@ export interface LocalFile {
function categorizeFile(mimeType: string): FileCategory {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video";
+ if (mimeType.startsWith("audio/")) return "audio";
return "document";
}
diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts
index e99519d1f..6069768b4 100644
--- a/web/src/components/MemoEditor/types/components.ts
+++ b/web/src/components/MemoEditor/types/components.ts
@@ -1,6 +1,7 @@
import type { Location, Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb";
import type { EditorRefActions } from "../Editor";
import type { Command } from "../Editor/commands";
+import type { EditorState } from "../state";
export interface MemoEditorProps {
className?: string;
@@ -22,12 +23,23 @@ export interface EditorToolbarProps {
onSave: () => void;
onCancel?: () => void;
memoName?: string;
+ onVoiceRecorderClick: () => void;
}
export interface EditorMetadataProps {
memoName?: string;
}
+export interface VoiceRecorderPanelProps {
+ voiceRecorder: EditorState["voiceRecorder"];
+ onStart: () => void;
+ onStop: () => void;
+ onKeep: () => void;
+ onDiscard: () => void;
+ onRecordAgain: () => void;
+ onClose: () => void;
+}
+
export interface FocusModeOverlayProps {
isActive: boolean;
onToggle: () => void;
@@ -45,6 +57,7 @@ export interface InsertMenuProps {
onLocationChange: (location?: Location) => void;
onToggleFocusMode?: () => void;
memoName?: string;
+ onVoiceRecorderClick?: () => void;
}
export interface TagSuggestionsProps {
diff --git a/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx b/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx
index ea2a416b6..037d3330d 100644
--- a/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx
+++ b/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx
@@ -1,11 +1,12 @@
import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "lucide-react";
import type { FC } from "react";
-import type { LocalFile } from "@/components/MemoEditor/types/attachment";
+import type { AttachmentItem, LocalFile } from "@/components/MemoEditor/types/attachment";
import { toAttachmentItems } from "@/components/MemoEditor/types/attachment";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
import SectionHeader from "../SectionHeader";
+import AudioAttachmentItem from "./AudioAttachmentItem";
interface AttachmentListEditorProps {
attachments: Attachment[];
@@ -15,87 +16,117 @@ interface AttachmentListEditorProps {
}
const AttachmentItemCard: FC<{
- item: ReturnType[0];
+ item: AttachmentItem;
onRemove?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
canMoveUp?: boolean;
canMoveDown?: boolean;
}> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => {
- const { category, filename, thumbnailUrl, mimeType, size } = item;
+ const { category, filename, thumbnailUrl, mimeType, size, sourceUrl } = item;
const fileTypeLabel = getFileTypeLabel(mimeType);
const fileSizeLabel = size ? formatFileSize(size) : undefined;
+ const displayName = category === "audio" && /^voice-(recording|note)-/i.test(filename) ? "Voice note" : filename;
+
+ if (category === "audio") {
+ return (
+
+
+
+
+ ) : undefined
+ }
+ />
+
+ );
+ }
return (
-
-
- {category === "image" && thumbnailUrl ? (
-
- ) : (
-
- )}
-
-
-
-
- {filename}
-
-
-
-
{fileTypeLabel}
- {fileSizeLabel && (
- <>
-
•
-
{fileSizeLabel}
- >
+
+
+
+ {category === "image" && thumbnailUrl ? (
+
+ ) : (
+
)}
-
-
- {onMoveUp && (
-
+
+ {displayName}
+
+
+
+ {fileTypeLabel}
+ {fileSizeLabel && (
+ <>
+ •
+ {fileSizeLabel}
+ >
)}
- title="Move up"
- aria-label="Move attachment up"
- >
-
-
- )}
+
+
- {onMoveDown && (
-
-
-
- )}
+
+ {onMoveUp && (
+
+
+
+ )}
- {onRemove && (
-
-
-
- )}
+ {onMoveDown && (
+
+
+
+ )}
+
+ {onRemove && (
+
+
+
+ )}
+
);
diff --git a/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx b/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx
index 4dc9f241e..ae22c945b 100644
--- a/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx
+++ b/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx
@@ -1,20 +1,21 @@
import { FileAudioIcon, PauseIcon, PlayIcon } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
+import { type ReactNode, useEffect, useRef, useState } from "react";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentUrl } from "@/utils/attachment";
+import { formatFileSize, getFileTypeLabel } from "@/utils/format";
import { formatAudioTime, getAttachmentMetadata } from "./attachmentViewHelpers";
const AUDIO_PLAYBACK_RATES = [1, 1.5, 2] as const;
interface AudioProgressBarProps {
- attachment: Attachment;
+ filename: string;
currentTime: number;
duration: number;
progressPercent: number;
onSeek: (value: string) => void;
}
-const AudioProgressBar = ({ attachment, currentTime, duration, progressPercent, onSeek }: AudioProgressBarProps) => (
+const AudioProgressBar = ({ filename, currentTime, duration, progressPercent, onSeek }: AudioProgressBarProps) => (
@@ -26,7 +27,7 @@ const AudioProgressBar = ({ attachment, currentTime, duration, progressPercent,
step={0.1}
value={Math.min(currentTime, duration || 0)}
onChange={(e) => onSeek(e.target.value)}
- aria-label={`Seek ${attachment.filename}`}
+ aria-label={`Seek ${filename}`}
className="relative z-10 h-4 w-full cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-default
[&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full
[&::-webkit-slider-runnable-track]:bg-transparent
@@ -45,14 +46,31 @@ const AudioProgressBar = ({ attachment, currentTime, duration, progressPercent,
);
-const AudioAttachmentItem = ({ attachment }: { attachment: Attachment }) => {
- const sourceUrl = getAttachmentUrl(attachment);
+interface AudioAttachmentItemProps {
+ attachment?: Attachment;
+ filename?: string;
+ displayName?: string;
+ sourceUrl?: string;
+ mimeType?: string;
+ size?: number;
+ actionSlot?: ReactNode;
+}
+
+const AudioAttachmentItem = ({ attachment, filename, displayName, sourceUrl, mimeType, size, actionSlot }: AudioAttachmentItemProps) => {
+ const resolvedFilename = attachment?.filename ?? filename ?? "audio";
+ const resolvedDisplayName = displayName ?? resolvedFilename;
+ const resolvedSourceUrl = attachment ? getAttachmentUrl(attachment) : (sourceUrl ?? "");
const audioRef = useRef
(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [playbackRate, setPlaybackRate] = useState<(typeof AUDIO_PLAYBACK_RATES)[number]>(1);
- const { fileTypeLabel, fileSizeLabel } = getAttachmentMetadata(attachment);
+ const { fileTypeLabel, fileSizeLabel } = attachment
+ ? getAttachmentMetadata(attachment)
+ : {
+ fileTypeLabel: getFileTypeLabel(mimeType ?? ""),
+ fileSizeLabel: size ? formatFileSize(size) : undefined,
+ };
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
useEffect(() => {
@@ -113,8 +131,8 @@ const AudioAttachmentItem = ({ attachment }: { attachment: Attachment }) => {
-
- {attachment.filename}
+
+ {resolvedDisplayName}
{fileTypeLabel}
@@ -128,11 +146,12 @@ const AudioAttachmentItem = ({ attachment }: { attachment: Attachment }) => {
+ {actionSlot}
{playbackRate}x
@@ -140,7 +159,7 @@ const AudioAttachmentItem = ({ attachment }: { attachment: Attachment }) => {
type="button"
onClick={togglePlayback}
className="inline-flex size-6.5 items-center justify-center rounded-md border border-border/45 bg-background/85 text-foreground transition-colors hover:bg-muted/45"
- aria-label={isPlaying ? `Pause ${attachment.filename}` : `Play ${attachment.filename}`}
+ aria-label={isPlaying ? `Pause ${resolvedDisplayName}` : `Play ${resolvedDisplayName}`}
>
{isPlaying ?
:
}
@@ -149,7 +168,7 @@ const AudioAttachmentItem = ({ attachment }: { attachment: Attachment }) => {
{
handleDuration(e.currentTarget.duration)}
diff --git a/web/src/components/MemoMetadata/Attachment/index.ts b/web/src/components/MemoMetadata/Attachment/index.ts
index c9895f1ec..3da447284 100644
--- a/web/src/components/MemoMetadata/Attachment/index.ts
+++ b/web/src/components/MemoMetadata/Attachment/index.ts
@@ -1,3 +1,4 @@
export { default as AttachmentCard } from "./AttachmentCard";
export { default as AttachmentListEditor } from "./AttachmentListEditor";
export { default as AttachmentListView } from "./AttachmentListView";
+export { default as AudioAttachmentItem } from "./AudioAttachmentItem";
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index 1f807758e..c8c58fc8e 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -124,7 +124,28 @@
"no-changes-detected": "No changes detected",
"save": "Save",
"saving": "Saving...",
- "slash-commands": "Type `/` for commands"
+ "slash-commands": "Type `/` for commands",
+ "voice-recorder": {
+ "discard": "Discard",
+ "error": "Microphone unavailable",
+ "error-description": "Try again after checking microphone access for this site.",
+ "idle-description": "Start recording to add a voice note as an audio attachment.",
+ "keep": "Keep recording",
+ "ready": "Recording ready",
+ "ready-description": "Preview the clip, then keep it as an audio attachment or discard it.",
+ "record-again": "Record again",
+ "recording": "Recording voice note",
+ "recording-description": "Capture a quick audio attachment. Current length: {{duration}}",
+ "requesting": "Requesting access...",
+ "requesting-permission": "Requesting microphone access",
+ "requesting-permission-description": "Allow microphone access in your browser to start recording.",
+ "start": "Start recording",
+ "stop": "Stop recording",
+ "title": "Voice recorder",
+ "trigger": "Voice note",
+ "unsupported": "Voice recording unsupported",
+ "unsupported-description": "This browser cannot record audio from the memo composer."
+ }
},
"inbox": {
"failed-to-load": "Failed to load inbox item",
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index 9bee79461..e6fb31e14 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -14,7 +14,8 @@ export function formatFileSize(bytes: number): string {
export function getFileTypeLabel(mimeType: string): string {
if (!mimeType) return "File";
- const [category, subtype] = mimeType.split("/");
+ const normalizedMimeType = mimeType.toLowerCase().split(";")[0].trim();
+ const [category = "", subtype = ""] = normalizedMimeType.split("/");
const specialCases: Record = {
"application/pdf": "PDF",
@@ -29,8 +30,8 @@ export function getFileTypeLabel(mimeType: string): string {
"application/javascript": "JS",
};
- if (specialCases[mimeType]) {
- return specialCases[mimeType];
+ if (specialCases[normalizedMimeType]) {
+ return specialCases[normalizedMimeType];
}
if (category === "image") {
@@ -51,7 +52,7 @@ export function getFileTypeLabel(mimeType: string): string {
if (category === "video") {
const videoTypes: Record = {
mp4: "MP4",
- webm: "WebM",
+ webm: "WEBM",
ogg: "OGG",
avi: "AVI",
mov: "MOV",
@@ -66,7 +67,7 @@ export function getFileTypeLabel(mimeType: string): string {
mpeg: "MP3",
wav: "WAV",
ogg: "OGG",
- webm: "WebM",
+ webm: "WEBM",
};
return audioTypes[subtype] || subtype.toUpperCase();
}
From 9676e725331e82ebc2473f0789db2aff45bd0d02 Mon Sep 17 00:00:00 2001
From: Serhat <49079271+onwp@users.noreply.github.com>
Date: Wed, 1 Apr 2026 14:25:18 +0300
Subject: [PATCH 4/4] i18n: complete Turkish (tr) translations to 100% coverage
(#5802)
Signed-off-by: Serhat <49079271+onwp@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
web/src/locales/tr.json | 104 +++++++++++++++++++++++++++++++++-------
1 file changed, 87 insertions(+), 17 deletions(-)
diff --git a/web/src/locales/tr.json b/web/src/locales/tr.json
index 32963adeb..8abf0eb63 100644
--- a/web/src/locales/tr.json
+++ b/web/src/locales/tr.json
@@ -10,7 +10,7 @@
"create-your-account": "Hesabınızı oluşturun",
"host-tip": "Site Yöneticisi olarak kayıt oluyorsunuz.",
"new-password": "Yeni şifre",
- "protected-memo-notice": "This memo is not public. Sign in to continue.",
+ "protected-memo-notice": "Bu not herkese açık değil. Devam etmek için giriş yapın.",
"repeat-new-password": "Yeni şifreyi tekrar girin",
"sign-in-tip": "Zaten hesabınız var mı?",
"sign-up-tip": "Henüz hesabınız yok mu?"
@@ -81,8 +81,8 @@
"preview": "Önizleme",
"profile": "Profil",
"properties": "Özellikler",
- "referenced-by": "Referanslayan",
- "referencing": "Referanslanan",
+ "referenced-by": "Referans Verilenler",
+ "referencing": "Referans Verenler",
"relations": "İlişkiler",
"remember-me": "Beni hatırla",
"rename": "Yeniden adlandır",
@@ -124,7 +124,28 @@
"no-changes-detected": "Değişiklik yok",
"save": "Kaydet",
"saving": "Kaydediliyor...",
- "slash-commands": "Komutlar için `/` yazın"
+ "slash-commands": "Komutlar için `/` yazın",
+ "voice-recorder": {
+ "discard": "Sil",
+ "error": "Mikrofon kullanılamıyor",
+ "error-description": "Bu site için mikrofon erişimini kontrol ettikten sonra tekrar deneyin.",
+ "idle-description": "Ses eki olarak bir sesli not eklemek için kayda başlayın.",
+ "keep": "Sakla",
+ "ready": "Kayıt hazır",
+ "ready-description": "Klibi önizleyin, ardından ses eki olarak saklayın veya silin.",
+ "record-again": "Tekrar kaydet",
+ "recording": "Sesli not kaydediliyor",
+ "recording-description": "Hızlı bir ses eki kaydedin. Geçerli süre: {{duration}}",
+ "requesting": "Erişim isteniyor...",
+ "requesting-permission": "Mikrofon erişimi isteniyor",
+ "requesting-permission-description": "Kayda başlamak için tarayıcınızda mikrofon erişimine izin verin.",
+ "start": "Kayda başla",
+ "stop": "Kaydı durdur",
+ "title": "Ses kaydedici",
+ "trigger": "Sesli not",
+ "unsupported": "Ses kaydı desteklenmiyor",
+ "unsupported-description": "Bu tarayıcı not düzenleyicisinden ses kaydı yapamaz."
+ }
},
"inbox": {
"failed-to-load": "Gelen kutusu öğesi yüklenemedi",
@@ -133,6 +154,11 @@
"no-unread": "Okunmamış bildirim yok",
"unread": "Okunmamış"
},
+ "live-update": {
+ "connected": "Canlı güncellemeler etkin",
+ "connecting": "Canlı güncellemelere bağlanılıyor...",
+ "disconnected": "Canlı güncellemeler kullanılamıyor"
+ },
"markdown": {
"checkbox": "Onay kutusu",
"code-block": "Kod bloğu",
@@ -140,8 +166,8 @@
},
"memo": {
"archived-at": "Arşivlenme tarihi",
- "click-to-hide-sensitive-content": "NSFW içeriği gizlemek için tıklayın",
- "click-to-show-sensitive-content": "NSFW içeriği göstermek için tıklayın",
+ "click-to-hide-sensitive-content": "Hassas içeriği gizlemek için tıklayın",
+ "click-to-show-sensitive-content": "Hassas içeriği göstermek için tıklayın",
"code": "Kod",
"comment": {
"self": "Yorumlar",
@@ -150,23 +176,50 @@
"copy-content": "İçeriği kopyala",
"copy-link": "Bağlantıyı Kopyala",
"count-memos-in-date": "{{date}} tarihinde {{count}} {{memos}}",
- "delete-confirm": "Bu notu silmek istediğinizden emin misiniz? BU İŞLEM GERİ ALINAMAZ",
+ "delete-confirm": "Bu notu silmek istediğinizden emin misiniz?",
"delete-confirm-description": "Bu işlem geri alınamaz. Ekler, bağlantılar ve referanslar da kaldırılacaktır.",
"direction": "Yön",
"direction-asc": "Artan",
"direction-desc": "Azalan",
"display-time": "Görüntüleme Zamanı",
"filters": {
- "has-code": "kodVar",
- "has-link": "bağlantıVar",
- "has-task-list": "görevListesiVar"
+ "has-code": "Kod içerir",
+ "has-link": "Bağlantı içerir",
+ "has-task-list": "Yapılacaklar listesi içerir",
+ "label": "Filtreler"
},
"links": "Bağlantılar",
"load-more": "Daha fazla yükle",
"no-archived-memos": "Arşivlenmiş not yok.",
"no-memos": "Not yok.",
"order-by": "Sıralama Ölçütü",
+ "outline": "Ana hatlar",
"search-placeholder": "Notlarda ara",
+ "share": {
+ "active-links": "Etkin paylaşım bağlantıları",
+ "copied": "Kopyalandı!",
+ "copy": "Bağlantıyı kopyala",
+ "create-failed": "Paylaşım bağlantısı oluşturulamadı",
+ "create-link": "Yeni bağlantı oluştur",
+ "creating": "Oluşturuluyor...",
+ "expiry-1-day": "1 gün",
+ "expiry-30-days": "30 gün",
+ "expiry-7-days": "7 gün",
+ "expiry-label": "Süre sonu",
+ "expiry-never": "Asla",
+ "expires-on": "{{date}} tarihinde sona erer",
+ "invalid-link": "Bu bağlantı geçersiz veya süresi dolmuş.",
+ "never-expires": "Süresi dolmaz",
+ "no-links": "Henüz paylaşım bağlantısı yok. Aşağıdan bir tane oluşturun.",
+ "open-panel": "Paylaşım bağlantılarını yönet",
+ "revoke": "İptal et",
+ "revoke-failed": "Bağlantı iptal edilemedi",
+ "revoked": "Paylaşım bağlantısı iptal edildi",
+ "section-label": "Paylaşım",
+ "share": "Paylaş",
+ "shared-by": "{{creator}} tarafından paylaşıldı",
+ "title": "Bu notu paylaş"
+ },
"show-less": "Daha az göster",
"show-more": "Daha fazla göster",
"to-do": "Yapılacaklar",
@@ -174,7 +227,7 @@
"visibility": {
"disabled": "Herkese açık notlar devre dışı",
"private": "Özel",
- "protected": "Çalışma Alanı",
+ "protected": "Korumalı",
"public": "Herkese Açık"
}
},
@@ -261,7 +314,8 @@
"restore-success": "{{username}} başarıyla geri yüklendi",
"user": "Kullanıcı",
"label": "Üye",
- "list-title": "Üye listesi"
+ "list-title": "Üye listesi",
+ "no-members-found": "Üye bulunamadı"
},
"my-account": {
"label": "Hesabım"
@@ -305,7 +359,7 @@
"accesskey": "Erişim anahtarı",
"accesskey-placeholder": "Erişim anahtarı / Erişim Kimliği",
"bucket": "Kova",
- "bucket-placeholder": "Bucket adı",
+ "bucket-placeholder": "Kova adı",
"create-a-service": "Servis oluştur",
"create-storage": "Depolama Oluştur",
"current-storage": "Mevcut nesne depolama",
@@ -383,7 +437,8 @@
},
"description": "Hesabınız için tüm erişim tokenlerinin listesi.",
"title": "Erişim Tokenleri",
- "token": "Token"
+ "token": "Token",
+ "no-tokens-found": "Erişim tokeni bulunamadı"
},
"account": {
"change-password": "Şifre değiştir",
@@ -410,12 +465,13 @@
},
"memo": {
"content-length-limit": "İçerik uzunluğu sınırı (Bayt)",
- "enable-blur-sensitive-content": "NSFW içeriği bulanıklaştırmayı etkinleştir",
+ "enable-blur-sensitive-content": "Hassas içeriği bulanıklaştırmayı etkinleştir",
"enable-memo-comments": "Not yorumlarını etkinleştir",
"enable-memo-location": "Not konumunu etkinleştir",
"reactions": "Tepkiler",
"title": "Not ile ilgili ayarlar",
- "label": "Not"
+ "label": "Not",
+ "reactions-required": "Tepki listesi boş olamaz"
},
"webhook": {
"create-dialog": {
@@ -425,7 +481,7 @@
"edit-webhook": "Webhook düzenle",
"payload-url": "Payload URL'si",
"title": "Başlık",
- "url-example-post-receive": "https://ornnek.com/postreceive"
+ "url-example-post-receive": "https://ornek.com/postreceive"
},
"delete-dialog": {
"delete-webhook-description": "Bu işlem geri alınamaz.",
@@ -436,6 +492,20 @@
"title": "Webhook'lar",
"url": "URL",
"label": "Webhook'lar"
+ },
+ "tags": {
+ "label": "Etiketler",
+ "title": "Etiket meta verileri",
+ "description": "Etiketlere sunucu genelinde isteğe bağlı görüntüleme renkleri atayın veya eşleşen not içeriğini bulanıklaştırın. Etiket adları sabitlenmiş regex desenleri olarak değerlendirilir.",
+ "background-color": "Arka plan rengi",
+ "blur-content": "İçeriği bulanıklaştır",
+ "no-tags-configured": "Yapılandırılmış etiket meta verisi yok.",
+ "tag-name": "Etiket adı",
+ "tag-name-placeholder": "ör. is veya proje/.*",
+ "tag-already-exists": "Etiket zaten mevcut.",
+ "tag-pattern-hint": "Etiket adı veya regex deseni (ör. proje/.* tüm proje/ etiketlerini eşleştirir)",
+ "invalid-regex": "Geçersiz veya güvenli olmayan regex deseni.",
+ "using-default-color": "Varsayılan renk kullanılıyor."
}
},
"tag": {