Unverified Commit 45b21530 authored by memoclaw's avatar memoclaw Committed by GitHub

feat: add blur_content attribute to tag metadata settings (#5767)

Co-authored-by: 's avatarmemoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: 's avatarCopilot <223556219+Copilot@users.noreply.github.com>
parent 9e040496
## Background & Context
Memos has a content-blur feature: when a memo's tag list contains the literal string `NSFW`
(case-insensitive), the memo body is rendered with a `blur-lg` CSS class and a click-to-reveal
overlay is shown. This was simplified in v0.26.x from a previous admin-configurable system
(which had an on/off toggle and a custom-tag-list) down to a single hardcoded tag name.
In the same release cycle, an `InstanceTagsSetting` system was introduced that lets admins attach
metadata (currently only `background_color`) to tag name patterns via a regex-keyed map. This
system has its own proto definitions, store layer, API service handlers, frontend context, utility
library, and settings UI — all independent of the blur feature.
A sponsor raised (orgs/usememos/discussions/5708) that the hardcoded tag name is inconvenient:
users who organised their content under a different tag (e.g. a non-English word, a project-
specific label, or simply a term they prefer) must re-tag all existing memos just to use the blur
feature. Community comments echo the same concern and additionally ask for the ability to disable
the blur globally.
## Issue Statement
The memo content-blur trigger is evaluated exclusively against the hardcoded string `"NSFW"`
(case-insensitive) in `MemoView.tsx`, with no connection to the `InstanceTagsSetting` system,
making it impossible for an administrator to designate any other tag name — or set of tag name
patterns — as a blur trigger, and preventing users from re-using existing tag taxonomies to
activate content blurring.
## Current State
**Blur detection — frontend**
| File | Lines | Behaviour |
|------|-------|-----------|
| `web/src/components/MemoView/MemoView.tsx` | 27–30 | `const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false;` — single hardcoded string comparison |
| `web/src/components/MemoView/MemoViewContext.tsx` | 16–19 | Context shape exposes `nsfw: boolean`, `showNSFWContent: boolean`, `toggleNsfwVisibility` |
| `web/src/components/MemoView/components/MemoBody.tsx` | 11–23, 37, 53 | Applies `blur-lg transition-all duration-200` when `nsfw && !showNSFWContent`; renders `NsfwOverlay` button using i18n key `memo.click-to-show-nsfw-content` |
| `web/src/components/MemoPreview/MemoPreview.tsx` | 24–27 | Stub context value: `nsfw: false`, `showNSFWContent: false` — blur never active in preview |
**Localisation strings that contain the "NSFW" term**
| File | Keys |
|------|------|
| `web/src/locales/en.json` (and ~30 other locale files) | `memo.click-to-hide-nsfw-content`, `memo.click-to-show-nsfw-content`, `settings.enable-blur-nsfw-content` |
Note: most non-English translations already use "sensitive content" rather than "NSFW" in these
keys; English is the outlier.
**Tag metadata system — proto**
| File | Lines | Content |
|------|-------|---------|
| `proto/api/v1/instance_service.proto` | 168–181 | `message TagMetadata { google.type.Color background_color = 1; }` nested inside `InstanceSetting`; `TagsSetting` is a `map<string, TagMetadata>` |
| `proto/store/instance_setting.proto` | 113–124 | `message InstanceTagMetadata { google.type.Color background_color = 1; }` inside `InstanceTagsSetting` |
**Tag metadata system — backend**
| File | Lines | Content |
|------|-------|---------|
| `store/instance_setting.go` | 166–192 | `GetInstanceTagsSetting()` retrieves and caches the tags map |
| `server/router/api/v1/instance_service.go` | 300–328 | `convertInstanceTagsSettingFromStore()` / `convertInstanceTagsSettingToStore()` convert between store and API representations, field-by-field |
| `server/router/api/v1/instance_service.go` | 387–409 | `validateInstanceTagsSetting()` validates each key as a regex pattern and the color value |
**Tag metadata system — frontend**
| File | Lines | Content |
|------|-------|---------|
| `web/src/lib/tag.ts` | 28–43 | `findTagMetadata(tag, tagsSetting)` — exact-match then regex-match lookup returning `TagMetadata \| undefined` |
| `web/src/components/MemoContent/Tag.tsx` | 23–38 | Calls `findTagMetadata()` to apply `background_color` to inline tag chips |
| `web/src/components/Settings/TagsSection.tsx` | 36–206 | Admin settings UI for managing the tag→metadata map; currently shows only a colour picker per tag |
| `web/src/contexts/InstanceContext.tsx` | 83–99 | `tagsSetting` selector and fetch during app initialisation |
## Non-Goals
- Redesigning or replacing the `InstanceTagsSetting` proto or store structure beyond adding one field.
- Providing a per-user (as opposed to per-instance) blur preference.
- Changing how background-color metadata is stored, validated, or rendered.
- Adding a global on/off toggle for blurring (separate from per-tag configuration).
- Modifying the blur visual effect (CSS class, animation, overlay button layout).
- Migrating or auto-converting any existing memos that were tagged with `NSFW`.
- Changing non-English locale strings that already use neutral terminology.
## Open Questions
1. Should the `blur_content` field in tag metadata be configurable per-tag only by admins, or also by individual users via user-level tag settings? (default: admin-only, matching the existing `InstanceTagsSetting` access model)
2. When a memo has multiple tags and more than one of them has `blur_content = true`, should the blur activate if _any_ matching tag has the flag set, or only if _all_ matching tags do? (default: any — OR semantics, consistent with the current single-tag check)
3. Should there be a migration that automatically sets `blur_content = true` for any existing `InstanceTagsSetting` entry whose key is `"NSFW"` (case-insensitive)? (default: no automatic migration; admins reconfigure manually)
4. What should the English-locale i18n key strings say, given that "NSFW" is to be avoided? (default: "Click to show sensitive content" / "Click to hide sensitive content")
## Scope
**M** — the change adds one `bool` field to two existing proto messages, threads it through two
existing conversion functions in the backend, replaces one hardcoded string comparison in
`MemoView.tsx` with a call to the already-present `findTagMetadata()` utility, adds a checkbox to
the existing `TagsSection.tsx` settings UI, and renames three i18n keys. All required patterns
(field addition, conversion, `findTagMetadata` lookup, settings UI checkbox) already exist in the
codebase.
## Execution Log
### T1: Add blur_content field to proto messages
**Status**: Completed
**Files Changed**: `proto/api/v1/instance_service.proto`, `proto/store/instance_setting.proto`
**Validation**: `buf lint` — PASS
**Path Corrections**: None
**Deviations**: None
### T2: Regenerate proto code
**Status**: Completed
**Files Changed**: `proto/gen/` (Go + OpenAPI), `web/src/types/proto/` (TypeScript)
**Validation**: `grep blur_content` in generated files — PASS (field present in Go, TS, OpenAPI)
**Path Corrections**: None
**Deviations**: None
### T3: Thread blur_content through backend conversions
**Status**: Completed
**Files Changed**: `server/router/api/v1/instance_service.go`
**Validation**: `go build ./...` — PASS
**Path Corrections**: None
**Deviations**: None
### T4: Replace hardcoded NSFW check with tag metadata lookup
**Status**: Completed
**Files Changed**: `web/src/components/MemoView/MemoView.tsx`, `web/src/components/MemoView/MemoViewContext.tsx`, `web/src/components/MemoView/components/MemoBody.tsx`, `web/src/components/MemoPreview/MemoPreview.tsx`
**Validation**: `pnpm lint` — PASS
**Path Corrections**: i18n key update (T6) was pulled forward to unblock TypeScript type checking, since the i18n key type is statically checked.
**Deviations**: None
### T5: Add blur checkbox to TagsSection settings UI
**Status**: Completed
**Files Changed**: `web/src/components/Settings/TagsSection.tsx`
**Validation**: `pnpm lint` — PASS
**Path Corrections**: None
**Deviations**: None
### T6: Update English i18n keys
**Status**: Completed
**Files Changed**: `web/src/locales/en.json`
**Validation**: `grep -c "nsfw\|NSFW" en.json` — returns 0, PASS
**Path Corrections**: Executed during T4/T5 to unblock type checking. Added `setting.tags.blur-content` key (not in original plan but required by T5's new checkbox column).
**Deviations**: None
## Completion Declaration
**All tasks completed successfully.**
Summary of changes:
- Added `bool blur_content = 2` to both API and store proto TagMetadata messages
- Regenerated Go, TypeScript, and OpenAPI code
- Threaded `blur_content` through `convertInstanceTagsSettingFromStore()` and `convertInstanceTagsSettingToStore()`
- Replaced hardcoded `tag.toUpperCase() === "NSFW"` with `findTagMetadata(tag, tagsSetting)?.blurContent` lookup
- Renamed context fields: `nsfw``blurred`, `showNSFWContent``showBlurredContent`, `toggleNsfwVisibility``toggleBlurVisibility`
- Renamed `NsfwOverlay``BlurOverlay` component
- Expanded TagsSection local state to track `{ color, blur }` per tag and added a "Blur content" checkbox column
- Updated English i18n: renamed NSFW keys to "sensitive content", removed unused key, added `blur-content` setting key
## Task List
T1: Add blur_content field to proto messages [S] — T2: Regenerate proto code [S] — T3: Thread blur_content through backend conversions [S] — T4: Replace hardcoded NSFW check with tag metadata lookup [M] — T5: Add blur checkbox to TagsSection settings UI [S] — T6: Update English i18n keys [S]
### T1: Add blur_content field to proto messages [S]
**Objective**: Add a `bool blur_content` field to both the API and store proto TagMetadata messages.
**Files**: `proto/api/v1/instance_service.proto`, `proto/store/instance_setting.proto`
**Implementation**:
- In `proto/api/v1/instance_service.proto` (~line 171), add `bool blur_content = 2;` to `message TagMetadata` after `background_color`
- In `proto/store/instance_setting.proto` (~line 115), add `bool blur_content = 2;` to `message InstanceTagMetadata` after `background_color`
**Validation**: `cd proto && buf lint` — no errors
### T2: Regenerate proto code [S]
**Objective**: Regenerate Go + TypeScript + OpenAPI from updated proto definitions.
**Files**: `proto/gen/` (generated), `web/src/types/proto/` (generated)
**Implementation**: Run `cd proto && buf generate`
**Dependencies**: T1
**Validation**: `grep -r "blur_content\|blurContent" proto/gen/ web/src/types/proto/ | head -10` — shows new field in generated Go and TS files
### T3: Thread blur_content through backend conversion functions [S]
**Objective**: Pass `blur_content` through the store↔API conversion functions so the field round-trips correctly.
**Files**: `server/router/api/v1/instance_service.go`
**Implementation**:
- In `convertInstanceTagsSettingFromStore()` (~line 306): add `BlurContent: metadata.GetBlurContent()` to the `InstanceSetting_TagMetadata` struct literal
- In `convertInstanceTagsSettingToStore()` (~line 321): add `BlurContent: metadata.GetBlurContent()` to the `InstanceTagMetadata` struct literal
**Dependencies**: T2
**Validation**: `cd /Users/steven/Projects/usememos/memos && go build ./...` — compiles without errors
### T4: Replace hardcoded NSFW check with tag metadata lookup [M]
**Objective**: Replace the hardcoded `tag.toUpperCase() === "NSFW"` check with a lookup against `InstanceTagsSetting` via the existing `findTagMetadata()` utility, so any tag with `blur_content: true` triggers the blur.
**Size**: M (3 files, moderate logic)
**Files**:
- Modify: `web/src/components/MemoView/MemoView.tsx`
- Modify: `web/src/components/MemoView/MemoViewContext.tsx`
- Modify: `web/src/components/MemoView/components/MemoBody.tsx`
- Modify: `web/src/components/MemoPreview/MemoPreview.tsx`
**Implementation**:
1. In `MemoView.tsx`:
- Import `useInstance` from `@/contexts/InstanceContext` and `findTagMetadata` from `@/lib/tag`
- Replace `const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false;` with a check that iterates `memoData.tags` and uses `findTagMetadata(tag, tagsSetting)?.blurContent` — OR semantics (any match triggers blur)
- Rename state/variables: `showNSFWContent``showBlurredContent`, `nsfw``blurred`, `toggleNsfwVisibility``toggleBlurVisibility`
2. In `MemoViewContext.tsx`:
- Rename interface fields: `nsfw``blurred`, `showNSFWContent``showBlurredContent`, `toggleNsfwVisibility``toggleBlurVisibility`
3. In `MemoBody.tsx`:
- Update destructured context fields to use new names (`blurred`, `showBlurredContent`, `toggleBlurVisibility`)
- Rename `NsfwOverlay` component to `BlurOverlay`
- Change i18n key from `memo.click-to-show-nsfw-content` to `memo.click-to-show-sensitive-content`
4. In `MemoPreview.tsx`:
- Update stub context to use new field names (`blurred`, `showBlurredContent`, `toggleBlurVisibility`)
**Boundaries**: Do NOT change blur CSS classes, animation, or overlay layout
**Dependencies**: T2
**Validation**: `cd web && pnpm lint` — no type or lint errors
### T5: Add blur checkbox to TagsSection settings UI [S]
**Objective**: Add a "Blur content" checkbox column to the tag settings table so admins can toggle `blur_content` per tag pattern.
**Files**: `web/src/components/Settings/TagsSection.tsx`
**Implementation**:
- Expand `localTags` state from `Record<string, string>` (hex only) to `Record<string, { color: string; blur: boolean }>` to track both fields
- Update `useEffect` sync, `originalHexMap` comparison, `handleColorChange`, `handleRemoveTag`, `handleAddTag` to work with the new shape
- Add a new `[newTagBlur, setNewTagBlur]` state for the add-tag row (default `false`)
- In `handleSave`, pass `blurContent` when creating `InstanceSetting_TagMetadata`
- Add a new column to `SettingTable` between "Background color" and "Actions": header `t("setting.tags.blur-content")`, renders a checkbox bound to `localTags[row.name].blur`
- Add the i18n key `setting.tags.blur-content` to `en.json` with value `"Blur content"`
**Dependencies**: T2
**Validation**: `cd web && pnpm lint` — no type or lint errors
### T6: Update English i18n keys [S]
**Objective**: Rename NSFW-specific i18n keys in `en.json` to use neutral "sensitive content" terminology.
**Files**: `web/src/locales/en.json`
**Implementation**:
- Change key `memo.click-to-show-nsfw-content``memo.click-to-show-sensitive-content` with value `"Click to show sensitive content"`
- Change key `memo.click-to-hide-nsfw-content``memo.click-to-hide-sensitive-content` with value `"Click to hide sensitive content"` (dead key but renamed for consistency)
- The key `settings.enable-blur-nsfw-content` is unused in code — remove it
**Dependencies**: T4 (key rename must match code references)
**Validation**: `grep -c "nsfw" web/src/locales/en.json` — returns `0`
## Out-of-Scope Tasks
- Updating non-English locale files (per non-goals: "Changing non-English locale strings that already use neutral terminology")
- Adding automatic migration for existing NSFW tag entries
- Per-user blur preferences
- Global on/off toggle for blurring
- Modifying blur visual effect (CSS, animation, overlay layout)
......@@ -169,6 +169,8 @@ message InstanceSetting {
message TagMetadata {
// Background color for the tag label.
google.type.Color background_color = 1;
// Whether memos with this tag should have their content blurred.
bool blur_content = 2;
}
// Tag metadata configuration.
......
......@@ -761,8 +761,10 @@ type InstanceSetting_TagMetadata struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Background color for the tag label.
BackgroundColor *color.Color `protobuf:"bytes,1,opt,name=background_color,json=backgroundColor,proto3" json:"background_color,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// Whether memos with this tag should have their content blurred.
BlurContent bool `protobuf:"varint,2,opt,name=blur_content,json=blurContent,proto3" json:"blur_content,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *InstanceSetting_TagMetadata) Reset() {
......@@ -802,9 +804,20 @@ func (x *InstanceSetting_TagMetadata) GetBackgroundColor() *color.Color {
return nil
}
func (x *InstanceSetting_TagMetadata) GetBlurContent() bool {
if x != nil {
return x.BlurContent
}
return false
}
// Tag metadata configuration.
type InstanceSetting_TagsSetting struct {
state protoimpl.MessageState `protogen:"open.v1"`
state protoimpl.MessageState `protogen:"open.v1"`
// Map of tag name pattern to tag metadata.
// Each key is treated as an anchored regular expression (^pattern$),
// so a single entry like "project/.*" matches all tags under that prefix.
// Exact tag names are also valid (they are trivially valid regex patterns).
Tags map[string]*InstanceSetting_TagMetadata `protobuf:"bytes,1,rep,name=tags,proto3" json:"tags,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
......@@ -1166,7 +1179,7 @@ const file_api_v1_instance_service_proto_rawDesc = "" +
"\x04demo\x18\x03 \x01(\bR\x04demo\x12!\n" +
"\finstance_url\x18\x06 \x01(\tR\vinstanceUrl\x12(\n" +
"\x05admin\x18\a \x01(\v2\x12.memos.api.v1.UserR\x05admin\"\x1b\n" +
"\x19GetInstanceProfileRequest\"\xe0\x15\n" +
"\x19GetInstanceProfileRequest\"\x83\x16\n" +
"\x0fInstanceSetting\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12W\n" +
"\x0fgeneral_setting\x18\x02 \x01(\v2,.memos.api.v1.InstanceSetting.GeneralSettingH\x00R\x0egeneralSetting\x12W\n" +
......@@ -1208,9 +1221,10 @@ const file_api_v1_instance_service_proto_rawDesc = "" +
"\x18display_with_update_time\x18\x02 \x01(\bR\x15displayWithUpdateTime\x120\n" +
"\x14content_length_limit\x18\x03 \x01(\x05R\x12contentLengthLimit\x127\n" +
"\x18enable_double_click_edit\x18\x04 \x01(\bR\x15enableDoubleClickEdit\x12\x1c\n" +
"\treactions\x18\a \x03(\tR\treactions\x1aL\n" +
"\treactions\x18\a \x03(\tR\treactions\x1ao\n" +
"\vTagMetadata\x12=\n" +
"\x10background_color\x18\x01 \x01(\v2\x12.google.type.ColorR\x0fbackgroundColor\x1a\xba\x01\n" +
"\x10background_color\x18\x01 \x01(\v2\x12.google.type.ColorR\x0fbackgroundColor\x12!\n" +
"\fblur_content\x18\x02 \x01(\bR\vblurContent\x1a\xba\x01\n" +
"\vTagsSetting\x12G\n" +
"\x04tags\x18\x01 \x03(\v23.memos.api.v1.InstanceSetting.TagsSetting.TagsEntryR\x04tags\x1ab\n" +
"\tTagsEntry\x12\x10\n" +
......
......@@ -2399,6 +2399,9 @@ components:
allOf:
- $ref: '#/components/schemas/Color'
description: Background color for the tag label.
blurContent:
type: boolean
description: Whether memos with this tag should have their content blurred.
description: Metadata for a tag.
InstanceSetting_TagsSetting:
type: object
......@@ -2407,6 +2410,11 @@ components:
type: object
additionalProperties:
$ref: '#/components/schemas/InstanceSetting_TagMetadata'
description: |-
Map of tag name pattern to tag metadata.
Each key is treated as an anchored regular expression (^pattern$),
so a single entry like "project/.*" matches all tags under that prefix.
Exact tag names are also valid (they are trivially valid regex patterns).
description: Tag metadata configuration.
ListAllUserStatsResponse:
type: object
......
......@@ -756,8 +756,10 @@ type InstanceTagMetadata struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Background color for the tag label.
BackgroundColor *color.Color `protobuf:"bytes,1,opt,name=background_color,json=backgroundColor,proto3" json:"background_color,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// Whether memos with this tag should have their content blurred.
BlurContent bool `protobuf:"varint,2,opt,name=blur_content,json=blurContent,proto3" json:"blur_content,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *InstanceTagMetadata) Reset() {
......@@ -797,8 +799,19 @@ func (x *InstanceTagMetadata) GetBackgroundColor() *color.Color {
return nil
}
func (x *InstanceTagMetadata) GetBlurContent() bool {
if x != nil {
return x.BlurContent
}
return false
}
type InstanceTagsSetting struct {
state protoimpl.MessageState `protogen:"open.v1"`
state protoimpl.MessageState `protogen:"open.v1"`
// Map of tag name pattern to tag metadata.
// Each key is treated as an anchored regular expression (^pattern$),
// so a single entry like "project/.*" matches all tags under that prefix.
// Exact tag names are also valid (they are trivially valid regex patterns).
Tags map[string]*InstanceTagMetadata `protobuf:"bytes,1,rep,name=tags,proto3" json:"tags,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
......@@ -1053,9 +1066,10 @@ const file_store_instance_setting_proto_rawDesc = "" +
"\x18display_with_update_time\x18\x02 \x01(\bR\x15displayWithUpdateTime\x120\n" +
"\x14content_length_limit\x18\x03 \x01(\x05R\x12contentLengthLimit\x127\n" +
"\x18enable_double_click_edit\x18\x04 \x01(\bR\x15enableDoubleClickEdit\x12\x1c\n" +
"\treactions\x18\a \x03(\tR\treactions\"T\n" +
"\treactions\x18\a \x03(\tR\treactions\"w\n" +
"\x13InstanceTagMetadata\x12=\n" +
"\x10background_color\x18\x01 \x01(\v2\x12.google.type.ColorR\x0fbackgroundColor\"\xb0\x01\n" +
"\x10background_color\x18\x01 \x01(\v2\x12.google.type.ColorR\x0fbackgroundColor\x12!\n" +
"\fblur_content\x18\x02 \x01(\bR\vblurContent\"\xb0\x01\n" +
"\x13InstanceTagsSetting\x12>\n" +
"\x04tags\x18\x01 \x03(\v2*.memos.store.InstanceTagsSetting.TagsEntryR\x04tags\x1aY\n" +
"\tTagsEntry\x12\x10\n" +
......
......@@ -113,6 +113,8 @@ message InstanceMemoRelatedSetting {
message InstanceTagMetadata {
// Background color for the tag label.
google.type.Color background_color = 1;
// Whether memos with this tag should have their content blurred.
bool blur_content = 2;
}
message InstanceTagsSetting {
......
......@@ -305,6 +305,7 @@ func convertInstanceTagsSettingFromStore(setting *storepb.InstanceTagsSetting) *
for tag, metadata := range setting.Tags {
tags[tag] = &v1pb.InstanceSetting_TagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
BlurContent: metadata.GetBlurContent(),
}
}
return &v1pb.InstanceSetting_TagsSetting{
......@@ -320,6 +321,7 @@ func convertInstanceTagsSettingToStore(setting *v1pb.InstanceSetting_TagsSetting
for tag, metadata := range setting.Tags {
tags[tag] = &storepb.InstanceTagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
BlurContent: metadata.GetBlurContent(),
}
}
return &storepb.InstanceTagsSetting{
......
......@@ -21,10 +21,10 @@ const STUB_CONTEXT: MemoViewContextValue = {
parentPage: "/",
isArchived: false,
readonly: true,
showNSFWContent: false,
nsfw: false,
showBlurredContent: false,
blurred: false,
openEditor: () => {},
toggleNsfwVisibility: () => {},
toggleBlurVisibility: () => {},
openPreview: () => {},
};
......
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { useInstance } from "@/contexts/InstanceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useUser } from "@/hooks/useUserQueries";
import { findTagMetadata } from "@/lib/tag";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import { isSuperUser } from "@/utils/user";
......@@ -19,15 +21,16 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
const [showEditor, setShowEditor] = useState(false);
const currentUser = useCurrentUser();
const { tagsSetting } = useInstance();
const creator = useUser(memoData.creator).data;
const isArchived = memoData.state === State.ARCHIVED;
const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser);
const parentPage = parentPageProp || "/";
// NSFW content management: always blur content tagged with NSFW (case-insensitive)
const [showNSFWContent, setShowNSFWContent] = useState(false);
const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false;
const toggleNsfwVisibility = useCallback(() => setShowNSFWContent((prev) => !prev), []);
// Blur content when any tag has blur_content enabled in the instance tag settings.
const [showBlurredContent, setShowBlurredContent] = useState(false);
const blurred = memoData.tags?.some((tag) => findTagMetadata(tag, tagsSetting)?.blurContent) ?? false;
const toggleBlurVisibility = useCallback(() => setShowBlurredContent((prev) => !prev), []);
const { previewState, openPreview, setPreviewOpen } = useImagePreview();
......@@ -46,10 +49,10 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
parentPage,
isArchived,
readonly,
showNSFWContent,
nsfw,
showBlurredContent,
blurred,
openEditor,
toggleNsfwVisibility,
toggleBlurVisibility,
openPreview,
}),
[
......@@ -59,10 +62,10 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
parentPage,
isArchived,
readonly,
showNSFWContent,
nsfw,
showBlurredContent,
blurred,
openEditor,
toggleNsfwVisibility,
toggleBlurVisibility,
openPreview,
],
);
......
......@@ -13,10 +13,10 @@ export interface MemoViewContextValue {
parentPage: string;
isArchived: boolean;
readonly: boolean;
showNSFWContent: boolean;
nsfw: boolean;
showBlurredContent: boolean;
blurred: boolean;
openEditor: () => void;
toggleNsfwVisibility: () => void;
toggleBlurVisibility: () => void;
openPreview: (urls: string | string[], index?: number) => void;
}
......
......@@ -8,7 +8,7 @@ import { useMemoHandlers } from "../hooks";
import { useMemoViewContext } from "../MemoViewContext";
import type { MemoBodyProps } from "../types";
const NsfwOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => {
const BlurOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => {
const t = useTranslate();
return (
<div className="absolute inset-0 z-10 pt-4 flex items-center justify-center" onClick={onClick}>
......@@ -16,14 +16,14 @@ const NsfwOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => {
type="button"
className="rounded-lg border border-border bg-card px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-accent hover:bg-accent hover:text-foreground"
>
{t("memo.click-to-show-nsfw-content")}
{t("memo.click-to-show-sensitive-content")}
</button>
</div>
);
};
const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
const { memo, parentPage, showNSFWContent, nsfw, readonly, openEditor, openPreview, toggleNsfwVisibility } = useMemoViewContext();
const { memo, parentPage, showBlurredContent, blurred, readonly, openEditor, openPreview, toggleBlurVisibility } = useMemoViewContext();
const { handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({ readonly, openEditor, openPreview });
......@@ -34,7 +34,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
<div
className={cn(
"w-full flex flex-col justify-start items-start gap-2",
nsfw && !showNSFWContent && "blur-lg transition-all duration-200",
blurred && !showBlurredContent && "blur-lg transition-all duration-200",
)}
>
<MemoContent
......@@ -50,7 +50,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
<MemoReactionListView memo={memo} reactions={memo.reactions} />
</div>
{nsfw && !showNSFWContent && <NsfwOverlay onClick={toggleNsfwVisibility} />}
{blurred && !showBlurredContent && <BlurOverlay onClick={toggleBlurVisibility} />}
</>
);
};
......
......@@ -33,23 +33,39 @@ const hexToColor = (hex: string) =>
blue: parseInt(hex.slice(5, 7), 16) / 255,
});
interface LocalTagMeta {
color: string;
blur: boolean;
}
const TagsSection = () => {
const t = useTranslate();
const { tagsSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
const { data: tagCounts = {} } = useTagCounts(false);
// Local state: map of tagName → hex color string for editing.
const [localTags, setLocalTags] = useState<Record<string, string>>(() =>
Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, tagColorToHex(meta.backgroundColor)])),
// Local state: map of tagName → { color, blur } for editing.
const [localTags, setLocalTags] = useState<Record<string, LocalTagMeta>>(() =>
Object.fromEntries(
Object.entries(originalSetting.tags).map(([name, meta]) => [
name,
{ color: tagColorToHex(meta.backgroundColor), blur: meta.blurContent },
]),
),
);
const [newTagName, setNewTagName] = useState("");
const [newTagColor, setNewTagColor] = useState("#ffffff");
const [newTagBlur, setNewTagBlur] = useState(false);
// Sync local state when the fetched setting arrives (the fetch is async and
// completes after mount, so localTags would be empty without this sync).
useEffect(() => {
setLocalTags(
Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, tagColorToHex(meta.backgroundColor)])),
Object.fromEntries(
Object.entries(originalSetting.tags).map(([name, meta]) => [
name,
{ color: tagColorToHex(meta.backgroundColor), blur: meta.blurContent },
]),
),
);
}, [originalSetting.tags]);
......@@ -68,14 +84,24 @@ const TagsSection = () => {
[localTags],
);
const originalHexMap = useMemo(
() => Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, tagColorToHex(meta.backgroundColor)])),
const originalMetaMap = useMemo(
() =>
Object.fromEntries(
Object.entries(originalSetting.tags).map(([name, meta]) => [
name,
{ color: tagColorToHex(meta.backgroundColor), blur: meta.blurContent },
]),
),
[originalSetting.tags],
);
const hasChanges = !isEqual(localTags, originalHexMap);
const hasChanges = !isEqual(localTags, originalMetaMap);
const handleColorChange = (tagName: string, hex: string) => {
setLocalTags((prev) => ({ ...prev, [tagName]: hex }));
setLocalTags((prev) => ({ ...prev, [tagName]: { ...prev[tagName], color: hex } }));
};
const handleBlurChange = (tagName: string, blur: boolean) => {
setLocalTags((prev) => ({ ...prev, [tagName]: { ...prev[tagName], blur } }));
};
const handleRemoveTag = (tagName: string) => {
......@@ -97,17 +123,18 @@ const TagsSection = () => {
toast.error(t("setting.tags.invalid-regex"));
return;
}
setLocalTags((prev) => ({ ...prev, [name]: newTagColor }));
setLocalTags((prev) => ({ ...prev, [name]: { color: newTagColor, blur: newTagBlur } }));
setNewTagName("");
setNewTagColor("#ffffff");
setNewTagBlur(false);
};
const handleSave = async () => {
try {
const tags = Object.fromEntries(
Object.entries(localTags).map(([name, hex]) => [
Object.entries(localTags).map(([name, meta]) => [
name,
create(InstanceSetting_TagMetadataSchema, { backgroundColor: hexToColor(hex) }),
create(InstanceSetting_TagMetadataSchema, { backgroundColor: hexToColor(meta.color), blurContent: meta.blur }),
]),
);
await updateSetting(
......@@ -144,12 +171,24 @@ const TagsSection = () => {
<input
type="color"
className="w-8 h-8 cursor-pointer rounded border border-border bg-transparent p-0.5"
value={localTags[row.name]}
value={localTags[row.name].color}
onChange={(e) => handleColorChange(row.name, e.target.value)}
/>
</div>
),
},
{
key: "blur",
header: t("setting.tags.blur-content"),
render: (_, row: { name: string }) => (
<input
type="checkbox"
className="w-4 h-4 cursor-pointer"
checked={localTags[row.name].blur}
onChange={(e) => handleBlurChange(row.name, e.target.checked)}
/>
),
},
{
key: "actions",
header: "",
......@@ -188,6 +227,15 @@ const TagsSection = () => {
value={newTagColor}
onChange={(e) => setNewTagColor(e.target.value)}
/>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
<input
type="checkbox"
className="w-4 h-4 cursor-pointer"
checked={newTagBlur}
onChange={(e) => setNewTagBlur(e.target.checked)}
/>
{t("setting.tags.blur-content")}
</label>
<Button variant="outline" onClick={handleAddTag} disabled={!newTagName.trim()}>
<PlusIcon className="w-4 h-4 mr-1.5" />
{t("common.add")}
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "تاريخ الأرشفة",
"click-to-hide-nsfw-content": "انقر لإخفاء المحتوى الحساس",
"click-to-show-nsfw-content": "انقر لإظهار المحتوى الحساس",
"click-to-hide-sensitive-content": "انقر لإخفاء المحتوى الحساس",
"click-to-show-sensitive-content": "انقر لإظهار المحتوى الحساس",
"code": "كود",
"comment": {
"self": "التعليقات",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "حد طول المحتوى (بايت)",
"enable-blur-nsfw-content": "تمكين طمس المحتوى الحساس (NSFW)",
"enable-blur-sensitive-content": "تمكين طمس المحتوى الحساس (NSFW)",
"enable-memo-comments": "تمكين تعليقات المذكرة",
"enable-memo-location": "تمكين موقع المذكرة",
"reactions": "تفاعلات",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Arxivat el",
"click-to-hide-nsfw-content": "Fes clic per ocultar contingut sensible",
"click-to-show-nsfw-content": "Fes clic per mostrar contingut sensible",
"click-to-hide-sensitive-content": "Fes clic per ocultar contingut sensible",
"click-to-show-sensitive-content": "Fes clic per mostrar contingut sensible",
"code": "Codi",
"comment": {
"self": "Comentaris",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Límit de longitud del contingut (Bytes)",
"enable-blur-nsfw-content": "Habilita el difuminat de contingut sensible (NSFW)",
"enable-blur-sensitive-content": "Habilita el difuminat de contingut sensible (NSFW)",
"enable-memo-comments": "Habilita els comentaris a les notes",
"enable-memo-location": "Habilita la ubicació de la nota",
"reactions": "Reaccions",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Archivováno na",
"click-to-hide-nsfw-content": "Klikněte pro skrytí citlivého obsahu",
"click-to-show-nsfw-content": "Klikněte pro zobrazení citlivého obsahu",
"click-to-hide-sensitive-content": "Klikněte pro skrytí citlivého obsahu",
"click-to-show-sensitive-content": "Klikněte pro zobrazení citlivého obsahu",
"code": "Kód",
"comment": {
"self": "Komentáře",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Omezení velikosti obsahu (bajty)",
"enable-blur-nsfw-content": "Povolit rozostření citlivého obsahu",
"enable-blur-sensitive-content": "Povolit rozostření citlivého obsahu",
"enable-memo-comments": "Povolit komentáře k poznámkám",
"enable-memo-location": "Povolit umístění poznámek",
"reactions": "Reakce",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Archiviert am",
"click-to-hide-nsfw-content": "Klicken, um sensible Inhalte auszublenden",
"click-to-show-nsfw-content": "Klicken, um sensible Inhalte anzuzeigen",
"click-to-hide-sensitive-content": "Klicken, um sensible Inhalte auszublenden",
"click-to-show-sensitive-content": "Klicken, um sensible Inhalte anzuzeigen",
"code": "Code",
"comment": {
"self": "Kommentare",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Limitierung der Inhaltslänge (Byte)",
"enable-blur-nsfw-content": "Unschärfe für sensible Inhalte (NSFW) aktivieren",
"enable-blur-sensitive-content": "Unschärfe für sensible Inhalte (NSFW) aktivieren",
"enable-memo-comments": "Kommentare für Notizen aktivieren",
"enable-memo-location": "Notiz-Standort aktivieren",
"reactions": "Reaktionen",
......
......@@ -145,8 +145,8 @@
},
"memo": {
"archived-at": "Archived at",
"click-to-hide-nsfw-content": "Click to hide NSFW content",
"click-to-show-nsfw-content": "Click to show NSFW content",
"click-to-hide-sensitive-content": "Click to hide sensitive content",
"click-to-show-sensitive-content": "Click to show sensitive content",
"code": "Code",
"comment": {
"self": "Comments",
......@@ -342,7 +342,7 @@
},
"memo": {
"content-length-limit": "Content length limit (Byte)",
"enable-blur-nsfw-content": "Enable sensitive content (NSFW) blurring",
"enable-blur-sensitive-content": "Enable sensitive content blurring",
"enable-memo-comments": "Enable memo comments",
"enable-memo-location": "Enable memo location",
"label": "Memo",
......@@ -476,6 +476,7 @@
"title": "Tag metadata",
"description": "Assign display colors to tags instance-wide. Tag names are treated as anchored regex patterns.",
"background-color": "Background color",
"blur-content": "Blur content",
"no-tags-configured": "No tag metadata configured.",
"tag-name": "Tag name",
"tag-name-placeholder": "e.g. work or project/.*",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Archivado en",
"click-to-hide-nsfw-content": "Haz clic para ocultar contenido sensible",
"click-to-show-nsfw-content": "Haz clic para mostrar contenido sensible",
"click-to-hide-sensitive-content": "Haz clic para ocultar contenido sensible",
"click-to-show-sensitive-content": "Haz clic para mostrar contenido sensible",
"code": "Código",
"comment": {
"self": "Comentarios",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Límite de longitud de contenido (Bytes)",
"enable-blur-nsfw-content": "Habilitar difuminado de contenido sensible (NSFW)",
"enable-blur-sensitive-content": "Habilitar difuminado de contenido sensible (NSFW)",
"enable-memo-comments": "Habilitar comentarios en los memos",
"enable-memo-location": "Habilitar ubicación del memo",
"reactions": "Reacciones",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "آرشیو شده در",
"click-to-hide-nsfw-content": "برای مخفی کردن محتوای حساس کلیک کنید",
"click-to-show-nsfw-content": "برای نمایش محتوای حساس کلیک کنید",
"click-to-hide-sensitive-content": "برای مخفی کردن محتوای حساس کلیک کنید",
"click-to-show-sensitive-content": "برای نمایش محتوای حساس کلیک کنید",
"code": "کد",
"comment": {
"self": "نظرات",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "محدودیت طول محتوا (بایت)",
"enable-blur-nsfw-content": "فعال‌سازی تار کردن محتوای حساس (NSFW)",
"enable-blur-sensitive-content": "فعال‌سازی تار کردن محتوای حساس (NSFW)",
"enable-memo-comments": "فعال‌سازی نظرات یادداشت",
"enable-memo-location": "فعال‌سازی موقعیت یادداشت",
"reactions": "واکنش‌ها",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Archivé le",
"click-to-hide-nsfw-content": "Cliquez pour masquer le contenu sensible",
"click-to-show-nsfw-content": "Cliquez pour afficher le contenu sensible",
"click-to-hide-sensitive-content": "Cliquez pour masquer le contenu sensible",
"click-to-show-sensitive-content": "Cliquez pour afficher le contenu sensible",
"code": "Code",
"comment": {
"self": "Commentaires",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Limite de longueur du contenu (octets)",
"enable-blur-nsfw-content": "Activer le flou pour le contenu sensible (NSFW)",
"enable-blur-sensitive-content": "Activer le flou pour le contenu sensible (NSFW)",
"enable-memo-comments": "Activer les commentaires sur les notes",
"enable-memo-location": "Activer la localisation des notes",
"reactions": "Réactions",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Arquivada o",
"click-to-hide-nsfw-content": "Preme para ocultar contido NSFW",
"click-to-show-nsfw-content": "Preme para mostrar contido NSFW",
"click-to-hide-sensitive-content": "Preme para ocultar contido NSFW",
"click-to-show-sensitive-content": "Preme para mostrar contido NSFW",
"code": "Código",
"comment": {
"self": "Comentarios",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Límite de lonxitude do contido (Byte)",
"enable-blur-nsfw-content": "Activar esvaecemento do contido sensible (NSFW)",
"enable-blur-sensitive-content": "Activar esvaecemento do contido sensible (NSFW)",
"enable-memo-comments": "Activar comentarios nas notas",
"enable-memo-location": "Activar localización nas notas",
"reactions": "Reaccións",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "संग्रहीत किया गया",
"click-to-hide-nsfw-content": "संवेदनशील सामग्री छुपाने के लिए क्लिक करें",
"click-to-show-nsfw-content": "संवेदनशील सामग्री दिखाने के लिए क्लिक करें",
"click-to-hide-sensitive-content": "संवेदनशील सामग्री छुपाने के लिए क्लिक करें",
"click-to-show-sensitive-content": "संवेदनशील सामग्री दिखाने के लिए क्लिक करें",
"code": "कोड",
"comment": {
"self": "टिप्पणियाँ",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "सामग्री की अधिकतम लंबाई (बाइट)",
"enable-blur-nsfw-content": "संवेदनशील (NSFW) सामग्री धुंधला करें सक्षम करें",
"enable-blur-sensitive-content": "संवेदनशील (NSFW) सामग्री धुंधला करें सक्षम करें",
"enable-memo-comments": "मेमो टिप्पणियाँ सक्षम करें",
"enable-memo-location": "मेमो स्थान सक्षम करें",
"reactions": "प्रतिक्रियाएँ",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Arhivirano u",
"click-to-hide-nsfw-content": "Klikni za skrivanje osjetljivog sadržaja",
"click-to-show-nsfw-content": "Klikni za prikaz osjetljivog sadržaja",
"click-to-hide-sensitive-content": "Klikni za skrivanje osjetljivog sadržaja",
"click-to-show-sensitive-content": "Klikni za prikaz osjetljivog sadržaja",
"code": "Kod",
"comment": {
"self": "Komentari",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Ograničenje duljine sadržaja (Bajt)",
"enable-blur-nsfw-content": "Omogući zamućenje osjetljivog sadržaja (NSFW)",
"enable-blur-sensitive-content": "Omogući zamućenje osjetljivog sadržaja (NSFW)",
"enable-memo-comments": "Omogući komentare na memoima",
"enable-memo-location": "Omogući lokaciju memoa",
"reactions": "Reakcije",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Archiválva:",
"click-to-hide-nsfw-content": "Kattints a kényes tartalom elrejtéséhez",
"click-to-show-nsfw-content": "Kattints a kényes tartalom megjelenítéséhez",
"click-to-hide-sensitive-content": "Kattints a kényes tartalom elrejtéséhez",
"click-to-show-sensitive-content": "Kattints a kényes tartalom megjelenítéséhez",
"code": "Kód",
"comment": {
"self": "Hozzászólások",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Tartalom hosszának korlátja (bájt)",
"enable-blur-nsfw-content": "Érzékeny (NSFW) tartalom elhomályosításának engedélyezése",
"enable-blur-sensitive-content": "Érzékeny (NSFW) tartalom elhomályosításának engedélyezése",
"enable-memo-comments": "Jegyzet hozzászólások engedélyezése",
"enable-memo-location": "Jegyzet helyének engedélyezése",
"reactions": "Reakciók",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Diarsipkan pada",
"click-to-hide-nsfw-content": "Klik untuk menyembunyikan konten NSFW",
"click-to-show-nsfw-content": "Klik untuk menampilkan konten NSFW",
"click-to-hide-sensitive-content": "Klik untuk menyembunyikan konten NSFW",
"click-to-show-sensitive-content": "Klik untuk menampilkan konten NSFW",
"code": "Kode",
"comment": {
"self": "Komentar",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Batas panjang konten (Byte)",
"enable-blur-nsfw-content": "Aktifkan pengaburan konten sensitif (NSFW)",
"enable-blur-sensitive-content": "Aktifkan pengaburan konten sensitif (NSFW)",
"enable-memo-comments": "Aktifkan komentar memo",
"enable-memo-location": "Aktifkan lokasi memo",
"reactions": "Reaksi",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Archiviato il",
"click-to-hide-nsfw-content": "Clicca per nascondere contenuti sensibili",
"click-to-show-nsfw-content": "Clicca per mostrare contenuti sensibili",
"click-to-hide-sensitive-content": "Clicca per nascondere contenuti sensibili",
"click-to-show-sensitive-content": "Clicca per mostrare contenuti sensibili",
"code": "Codice",
"comment": {
"self": "Commenti",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Massima lunghezza contenuto (byte)",
"enable-blur-nsfw-content": "Abilita sfocatura contenuti sensibili (NSFW)",
"enable-blur-sensitive-content": "Abilita sfocatura contenuti sensibili (NSFW)",
"enable-memo-comments": "Abilita commenti memo",
"enable-memo-location": "Abilita posizione memo",
"reactions": "Reazioni",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "アーカイブ:",
"click-to-hide-nsfw-content": "クリックしてセンシティブ内容を隠す",
"click-to-show-nsfw-content": "クリックしてセンシティブ内容を表示",
"click-to-hide-sensitive-content": "クリックしてセンシティブ内容を隠す",
"click-to-show-sensitive-content": "クリックしてセンシティブ内容を表示",
"code": "コード",
"comment": {
"self": "コメント",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "内容の最大長(バイト)",
"enable-blur-nsfw-content": "センシティブ(NSFW)内容のぼかしを有効化",
"enable-blur-sensitive-content": "センシティブ(NSFW)内容のぼかしを有効化",
"enable-memo-comments": "メモコメントを有効化",
"enable-memo-location": "メモの位置情報を有効化",
"reactions": "リアクション",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "დაარქივებულია",
"click-to-hide-nsfw-content": "დააწკაპუნეთ NSFW კონტენტის დასამალად",
"click-to-show-nsfw-content": "დააწკაპუნეთ NSFW კონტენტის საჩვენებლად",
"click-to-hide-sensitive-content": "დააწკაპუნეთ NSFW კონტენტის დასამალად",
"click-to-show-sensitive-content": "დააწკაპუნეთ NSFW კონტენტის საჩვენებლად",
"code": "კოდი",
"comment": {
"self": "კომენტარები",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "კონტენტის სიგრძის ლიმიტი (ბაიტი)",
"enable-blur-nsfw-content": "NSFW კონტენტის დაბუნდოვანების ჩართვა",
"enable-blur-sensitive-content": "NSFW კონტენტის დაბუნდოვანების ჩართვა",
"enable-memo-comments": "მემოზე კომენტარების ჩართვა",
"enable-memo-location": "მემოს მდებარეობის ჩართვა",
"reactions": "რეაქციები",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "보관된 날짜",
"click-to-hide-nsfw-content": "민감한(성인) 콘텐츠 숨기기",
"click-to-show-nsfw-content": "민감한(성인) 콘텐츠 보기",
"click-to-hide-sensitive-content": "민감한(성인) 콘텐츠 숨기기",
"click-to-show-sensitive-content": "민감한(성인) 콘텐츠 보기",
"code": "코드",
"comment": {
"self": "댓글",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "내용 길이 제한 (바이트)",
"enable-blur-nsfw-content": "민감한(성인) 콘텐츠 블러 처리 활성화",
"enable-blur-sensitive-content": "민감한(성인) 콘텐츠 블러 처리 활성화",
"enable-memo-comments": "메모 댓글 활성화",
"enable-memo-location": "메모 위치 활성화",
"reactions": "반응",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "येथे संग्रहित",
"click-to-hide-nsfw-content": "संवेदनशील सामग्री लपवण्यासाठी क्लिक करा",
"click-to-show-nsfw-content": "संवेदनशील सामग्री दाखवण्यासाठी क्लिक करा",
"click-to-hide-sensitive-content": "संवेदनशील सामग्री लपवण्यासाठी क्लिक करा",
"click-to-show-sensitive-content": "संवेदनशील सामग्री दाखवण्यासाठी क्लिक करा",
"code": "कोड",
"comment": {
"self": "टिप्पण्या",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "सामग्रीची कमाल लांबी (बाइट)",
"enable-blur-nsfw-content": "संवेदनशील (NSFW) सामग्री ब्लर करा सक्षम करा",
"enable-blur-sensitive-content": "संवेदनशील (NSFW) सामग्री ब्लर करा सक्षम करा",
"enable-memo-comments": "मेमो टिप्पण्या सक्षम करा",
"enable-memo-location": "मेमो स्थान सक्षम करा",
"reactions": "प्रतिक्रिया",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Arkivert",
"click-to-hide-nsfw-content": "Klikk for å skjule NSFW-innhold",
"click-to-show-nsfw-content": "Klikk for å vise NSFW-innhold",
"click-to-hide-sensitive-content": "Klikk for å skjule NSFW-innhold",
"click-to-show-sensitive-content": "Klikk for å vise NSFW-innhold",
"code": "Kode",
"comment": {
"self": "Kommentarer",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Maksimal innholdslengde (Byte)",
"enable-blur-nsfw-content": "Slå på sløring av NSFW-innhold (legg til NSFW-tagger nedenfor)",
"enable-blur-sensitive-content": "Slå på sløring av NSFW-innhold (legg til NSFW-tagger nedenfor)",
"enable-memo-comments": "Slå på kommentarer for memoer",
"enable-memo-location": "Slå på lokasjon for memoer",
"reactions": "Reaksjoner",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Gearchiveerd op",
"click-to-hide-nsfw-content": "Klik om NSFW-inhoud te verbergen",
"click-to-show-nsfw-content": "Klik om NSFW-inhoud te tonen",
"click-to-hide-sensitive-content": "Klik om NSFW-inhoud te verbergen",
"click-to-show-sensitive-content": "Klik om NSFW-inhoud te tonen",
"code": "Code",
"comment": {
"self": "Opmerkingen",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Maximale inhoudslengte (Byte)",
"enable-blur-nsfw-content": "NSFW-inhoud vervagen inschakelen",
"enable-blur-sensitive-content": "NSFW-inhoud vervagen inschakelen",
"enable-memo-comments": "Memo-opmerkingen inschakelen",
"enable-memo-location": "Memo-locatie inschakelen",
"reactions": "Reacties",
......
......@@ -141,8 +141,8 @@
},
"memo": {
"archived-at": "Zarchiwizowano w dniu",
"click-to-hide-nsfw-content": "Kliknij, aby ukryć treści NSFW",
"click-to-show-nsfw-content": "Kliknij, aby pokazać treści NSFW",
"click-to-hide-sensitive-content": "Kliknij, aby ukryć treści NSFW",
"click-to-show-sensitive-content": "Kliknij, aby pokazać treści NSFW",
"code": "Kod",
"comment": {
"self": "Komentarze",
......@@ -411,7 +411,7 @@
},
"memo": {
"content-length-limit": "Limit długości treści (Bajty)",
"enable-blur-nsfw-content": "Włącz rozmycie treści NSFW",
"enable-blur-sensitive-content": "Włącz rozmycie treści NSFW",
"enable-memo-comments": "Włącz komentarze do notatek",
"enable-memo-location": "Włącz lokalizację notatek",
"reactions": "Reakcje",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Arquivado em",
"click-to-hide-nsfw-content": "Ocultar conteúdo impróprio",
"click-to-show-nsfw-content": "Mostrar conteúdo impróprio",
"click-to-hide-sensitive-content": "Ocultar conteúdo impróprio",
"click-to-show-sensitive-content": "Mostrar conteúdo impróprio",
"code": "Código",
"comment": {
"self": "Comentários",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Limite de tamanho do conteúdo (Bytes)",
"enable-blur-nsfw-content": "Desfocar conteúdo impróprio (adicione as tags abaixo)",
"enable-blur-sensitive-content": "Desfocar conteúdo impróprio (adicione as tags abaixo)",
"enable-memo-comments": "Comentários nos memos",
"enable-memo-location": "Marcador de localização",
"reactions": "Reações",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Arquivado em",
"click-to-hide-nsfw-content": "Clique para ocultar conteúdo NSFW",
"click-to-show-nsfw-content": "Clique para mostrar conteúdo NSFW",
"click-to-hide-sensitive-content": "Clique para ocultar conteúdo NSFW",
"click-to-show-sensitive-content": "Clique para mostrar conteúdo NSFW",
"code": "Código",
"comment": {
"self": "Comentários",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Limite de comprimento do conteúdo (Bytes)",
"enable-blur-nsfw-content": "Ativar desfoque de conteúdo sensível (NSFW)",
"enable-blur-sensitive-content": "Ativar desfoque de conteúdo sensível (NSFW)",
"enable-memo-comments": "Ativar comentários em memos",
"enable-memo-location": "Ativar localização em memos",
"reactions": "Reações",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "В архиве",
"click-to-hide-nsfw-content": "Нажмите, чтобы скрыть контент 18+",
"click-to-show-nsfw-content": "Нажмите, чтобы посмотреть",
"click-to-hide-sensitive-content": "Нажмите, чтобы скрыть контент 18+",
"click-to-show-sensitive-content": "Нажмите, чтобы посмотреть",
"code": "Код",
"comment": {
"self": "Комментарии",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Макс. длина заметки (байт)",
"enable-blur-nsfw-content": "\"Размывать\" заметки с тегами",
"enable-blur-sensitive-content": "\"Размывать\" заметки с тегами",
"enable-memo-comments": "Комментарии",
"enable-memo-location": "Геометки",
"reactions": "Реакции",
......
......@@ -141,8 +141,8 @@
},
"memo": {
"archived-at": "Arhivirano ob",
"click-to-hide-nsfw-content": "Kliknite za skrivanje NSFW vsebine",
"click-to-show-nsfw-content": "Kliknite za prikaz NSFW vsebine",
"click-to-hide-sensitive-content": "Kliknite za skrivanje NSFW vsebine",
"click-to-show-sensitive-content": "Kliknite za prikaz NSFW vsebine",
"code": "Koda",
"comment": {
"self": "Komentarji",
......@@ -411,7 +411,7 @@
},
"memo": {
"content-length-limit": "Omejitev dolžine vsebine (bajt)",
"enable-blur-nsfw-content": "Omogoči zameglitev občutljive vsebine (NSFW)",
"enable-blur-sensitive-content": "Omogoči zameglitev občutljive vsebine (NSFW)",
"enable-memo-comments": "Omogoči komentarje na beležkah",
"enable-memo-location": "Omogoči lokacijo beležk",
"reactions": "Odzivi",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Arkiverad",
"click-to-hide-nsfw-content": "Klicka för att dölja känsligt innehåll (NSFW)",
"click-to-show-nsfw-content": "Klicka för att visa känsligt innehåll (NSFW)",
"click-to-hide-sensitive-content": "Klicka för att dölja känsligt innehåll (NSFW)",
"click-to-show-sensitive-content": "Klicka för att visa känsligt innehåll (NSFW)",
"code": "Kod",
"comment": {
"self": "Kommentarer",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Innehållslängdsgräns (Byte)",
"enable-blur-nsfw-content": "Aktivera suddighet för känsligt innehåll (NSFW)",
"enable-blur-sensitive-content": "Aktivera suddighet för känsligt innehåll (NSFW)",
"enable-memo-comments": "Aktivera kommentarer på anteckningar",
"enable-memo-location": "Aktivera plats för anteckning",
"reactions": "Reaktioner",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "เก็บถาวรไว้ที่",
"click-to-hide-nsfw-content": "คลิกเพื่อซ่อนเนื้อหาไม่เหมาะสม (NSFW)",
"click-to-show-nsfw-content": "คลิกเพื่อแสดงเนื้อหาไม่เหมาะสม (NSFW)",
"click-to-hide-sensitive-content": "คลิกเพื่อซ่อนเนื้อหาไม่เหมาะสม (NSFW)",
"click-to-show-sensitive-content": "คลิกเพื่อแสดงเนื้อหาไม่เหมาะสม (NSFW)",
"code": "โค้ด",
"comment": {
"self": "ความคิดเห็น",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "จำกัดความยาวเนื้อหา (ไบต์)",
"enable-blur-nsfw-content": "เปิดใช้งานการเบลอเนื้อหาไม่เหมาะสม (NSFW)",
"enable-blur-sensitive-content": "เปิดใช้งานการเบลอเนื้อหาไม่เหมาะสม (NSFW)",
"enable-memo-comments": "เปิดใช้งานความคิดเห็นในบันทึก",
"enable-memo-location": "เปิดใช้งานตำแหน่งในบันทึก",
"reactions": "ปฏิกิริยา",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Arşivlenme tarihi",
"click-to-hide-nsfw-content": "NSFW içeriği gizlemek için tıklayın",
"click-to-show-nsfw-content": "NSFW içeriği göstermek için tıklayın",
"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",
"code": "Kod",
"comment": {
"self": "Yorumlar",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "İçerik uzunluğu sınırı (Bayt)",
"enable-blur-nsfw-content": "NSFW içeriği bulanıklaştırmayı etkinleştir",
"enable-blur-sensitive-content": "NSFW 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",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Архівовано о",
"click-to-hide-nsfw-content": "Натисніть, щоб приховати NSFW-контент",
"click-to-show-nsfw-content": "Натисніть, щоб показати NSFW-контент",
"click-to-hide-sensitive-content": "Натисніть, щоб приховати NSFW-контент",
"click-to-show-sensitive-content": "Натисніть, щоб показати NSFW-контент",
"code": "Код",
"comment": {
"self": "Коментарі",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Обмеження довжини вмісту (байт)",
"enable-blur-nsfw-content": "Увімкнути розмиття чутливого контенту (NSFW)",
"enable-blur-sensitive-content": "Увімкнути розмиття чутливого контенту (NSFW)",
"enable-memo-comments": "Увімкнути коментарі до нотаток",
"enable-memo-location": "Увімкнути місцезнаходження нотаток",
"reactions": "Реакції",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "Đã lưu trữ lúc",
"click-to-hide-nsfw-content": "Nhấp để ẩn nội dung nhạy cảm",
"click-to-show-nsfw-content": "Nhấp để hiện nội dung nhạy cảm",
"click-to-hide-sensitive-content": "Nhấp để ẩn nội dung nhạy cảm",
"click-to-show-sensitive-content": "Nhấp để hiện nội dung nhạy cảm",
"code": "Mã",
"comment": {
"self": "Bình luận",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "Giới hạn độ dài nội dung (Byte)",
"enable-blur-nsfw-content": "Bật làm mờ nội dung nhạy cảm (NSFW)",
"enable-blur-sensitive-content": "Bật làm mờ nội dung nhạy cảm (NSFW)",
"enable-memo-comments": "Bật bình luận ghi chú",
"enable-memo-location": "Bật vị trí ghi chú",
"reactions": "Phản ứng",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "归档于",
"click-to-hide-nsfw-content": "点击隐藏 NSFW 内容",
"click-to-show-nsfw-content": "点击显示 NSFW 内容",
"click-to-hide-sensitive-content": "点击隐藏 NSFW 内容",
"click-to-show-sensitive-content": "点击显示 NSFW 内容",
"code": "代码",
"comment": {
"self": "评论",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "内容长度限制(字节)",
"enable-blur-nsfw-content": "启用 NSFW 内容模糊处理(在下方添加 NSFW 标签)",
"enable-blur-sensitive-content": "启用 NSFW 内容模糊处理(在下方添加 NSFW 标签)",
"enable-memo-comments": "启用备忘录评论",
"enable-memo-location": "启用备忘录定位",
"reactions": "表态",
......
......@@ -140,8 +140,8 @@
},
"memo": {
"archived-at": "封存於",
"click-to-hide-nsfw-content": "點擊隱藏 NSFW 內容",
"click-to-show-nsfw-content": "點擊顯示 NSFW 內容",
"click-to-hide-sensitive-content": "點擊隱藏 NSFW 內容",
"click-to-show-sensitive-content": "點擊顯示 NSFW 內容",
"code": "程式碼",
"comment": {
"self": "評論",
......@@ -410,7 +410,7 @@
},
"memo": {
"content-length-limit": "內容長度限制(位元組)",
"enable-blur-nsfw-content": "啟用 NSFW 內容模糊化(在下方添加 NSFW 標籤)",
"enable-blur-sensitive-content": "啟用 NSFW 內容模糊化(在下方添加 NSFW 標籤)",
"enable-memo-comments": "啟用備忘錄評論",
"enable-memo-location": "啟用備忘錄定位",
"reactions": "表情回應",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment