feat(design): the Copy rule — names, verbs, dates and units (E28 S3g)

The owner's picks (all the recommended options) become DESIGN.md's Copy
chapter and a sweep of both languages (742 values, no key drift):

- One name per feature: the rail's label, which may shorten it (Alerts
  -> Notifications, Fiókok -> Felhasználók, Download Center -> Downloads,
  Channel manager -> Channels); one "sync from YouTube" verb.
- Four verbs, four meanings (Delete / Remove / Dismiss / Discard, and
  their Hungarian pairs), checked against what each action does.
- Sentence case, the app's places capitalised; "…" on a command that
  asks first (a popover or form title got its own key without it);
  one-sentence messages without a stop or a tick, "Couldn't …"; curly
  quotes.
- Dates only from lib/format.ts, in the UI language: formatDateTime
  names the month and stops at the minute (it printed "9/27/2026,
  8:30:14 AM"), formatDateTimeSec for the log, the audit and the
  recorder; ui/RelativeTime pairs "3d ago" with the exact minute on
  hover. local/date-format refuses toLocale*String and Intl date
  formatters elsewhere.
- Units from Intl, short (formatUnit): runtimes, totals, the Scheduler
  countdown, the stats range, the player's tap skip. "2h 13m" was
  English in both languages.
- src/i18n/copyRule.test.ts holds the machine-readable part (quotes,
  "...", ticks, bare unit letters).
This commit is contained in:
2026-09-27 05:46:17 +02:00
parent 2c65edfffd
commit bd6aaa1e43
100 changed files with 1193 additions and 964 deletions
+102 -78
View File
@@ -497,11 +497,34 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
placed with `ModalFooter` from any depth of the body. A section's own buttons (a sub-form inside a
manager, a list row's Delete) stay in the body; a manager with no commit row has no footer.
## Copy
## Copy — _Decided_ (S3g)
- Every user-facing string is in both Hungarian and English with zero key drift, and a user-facing error is
a structured `{code, reason}` translated on the client.
- A label says what happens ("Delete playlist"), not "OK". A destructive confirm names the object.
a structured `{code, reason}` translated on the client. Hungarian speaks to the user as "te".
- **One name per feature.** The nav rail's label is the feature's name; page titles, menus, shortcuts,
settings tabs and onboarding use the same words. The rail may shorten them ("Config" for
"Configuration", "Listák" for "Lejátszási listák"), never swap in another word. [Nielsen #4]
- **One verb per meaning.** Delete / Törlés: gone for good. Remove / Eltávolítás: out of a list, the
thing itself survives. Dismiss / Elrejtés: a notice leaves the view. Discard / Elvetés: unsaved edits
are thrown away. A confirm uses its action's verb. [Nielsen #4]
- **Sentence case.** Capitals only for brand names (YouTube, Plex, Google) and the app's places used as
places ("Hide from Feed", "Megnézés a Könyvtárban"). [Material, HIG]
- **"…" when the command asks first.** A label ends in "…" when it opens a dialog, form or picker before
anything happens ("Edit tags…"); not on a submenu (its chevron says it) or an immediate action. Progress
text ("Loading…") keeps its own. [HIG, Windows]
- **Messages.** A toast or error of one sentence has no full stop and no "✓": "Couldn't sync
subscriptions" / "Nem sikerült szinkronizálni". Two sentences keep their stops. Help text is prose.
- **Quotes** around a name are curly: “abc” in English, „abc” in Hungarian.
- **Dates**, in the UI language, only from `lib/format.ts` (lint: `local/date-format`): recency in a list
is relative ("3d ago" / "3 napja") through `ui/RelativeTime`, which carries the exact minute on hover;
a date is "Sep 27, 2026" / "2026. szept. 27."; a moment "Sep 27, 2026, 08:30 AM" /
"2026. szept. 27. 08:30"; seconds only in the server log, the audit and the recorder.
- **Units.** A figure with a unit (a length, a countdown, a range of days) is the browser's short form
(`Intl`), through `formatUnit` / `formatRuntime`: "90 min", "2 hr 13 min" / "90 p", "2 ó 13 p"; a fixed
label holding one is written in that same form ("Under 90 min"). Never a bare letter ("90m", "24h",
"7d"; test-held). In a sentence, and in a list of time spans named in words ("24 hours", "1 week"), the
unit is a word. A clock-style length on a picture stays "2:34:11".
- A label says what happens ("Delete playlist"), not "OK"; a destructive confirm names the object.
## Anti-patterns
@@ -514,78 +537,79 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
## Decision log
| Date | Decision | Chosen | Over | Why |
| ---------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
| Date | Decision | Chosen | Over | Why |
| ---------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
+33
View File
@@ -82,6 +82,37 @@ const focusSafeDisabled = {
};
},
};
// E28 S3g (docs/DESIGN.md "Copy"): the app shows a date in three forms plus the relative one, all
// built in lib/format.ts in the UI language. A bare `toLocaleString()` formats in the BROWSER's locale
// and as numbers with seconds; seven call sites had drifted into five shapes. Outside lib/format.ts
// a `toLocale*String` call or an `Intl.DateTimeFormat` / `Intl.RelativeTimeFormat` is refused (a
// number goes through formatNumber, which the same rule covers).
const dateFormat = {
meta: {
type: "problem",
messages: {
date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatDateTimeSec, relativeTime, formatNumber), in the UI language.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
// lib/format.ts is where the forms live; a test computes its expectations; the Plex player's
// on-screen clock (lib/plexPlayerPrefs.ts) is a clock face with its own 12/24h switch, not a date.
if (/\/src\/lib\/(format|plexPlayerPrefs)\.ts$|\.test\.tsx?$/.test(path)) return {};
return {
"CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"(
node,
) {
context.report({ node, messageId: "date" });
},
"MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat)$/]"(
node,
) {
context.report({ node, messageId: "date" });
},
};
},
};
// E28 S3b: one primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had
// drifted into as many looks, heights and hover styles; they were swept onto ui/Button, IconButton,
// Pressable, Chip and DragHandle, and this keeps the next one out. The raw element lives only in the
@@ -351,6 +382,7 @@ const localRules = {
"jsx-comment-text": jsxCommentText,
"focus-safe-disabled": focusSafeDisabled,
"button-primitive": buttonPrimitive,
"date-format": dateFormat,
"input-primitive": inputPrimitive,
"surface-role": surfaceRole,
"literal-a11y-text": literalA11yText,
@@ -445,6 +477,7 @@ export default tseslint.config(
"local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error",
"local/button-primitive": "error",
"local/date-format": "error",
"local/input-primitive": "error",
"local/surface-role": "error",
"local/literal-a11y-text": "error",
+3 -1
View File
@@ -154,7 +154,9 @@ function MembershipPanel({
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
would otherwise reach the feed card that hosts the trigger and start playback. */}
<div onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
<div className="text-xs text-muted px-1.5 pb-1.5">
{t("playlists.addToPlaylistTitle")}
</div>
<div
ref={fade.ref}
style={fade.style}
+3 -3
View File
@@ -11,7 +11,7 @@ import { Copy, Radio, Search } from "lucide-react";
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format";
import { formatDateTimeSec } from "../lib/format";
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
import {
@@ -406,7 +406,7 @@ export default function AdminLogs() {
level={row.level}
event={row.event ?? "—"}
msg={row.msg}
title={formatDateTime(row.ts)}
title={formatDateTimeSec(row.ts)}
open={open === row.id}
onToggle={() => setOpen(open === row.id ? null : row.id)}
>
@@ -475,7 +475,7 @@ function RowDetail({
<div className="flex flex-col gap-2">
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 font-sans text-xs">
<dt>{t("adminLogs.fields.when")}</dt>
<dd className="tabular-nums">{formatDateTime(row.ts)}</dd>
<dd className="tabular-nums">{formatDateTimeSec(row.ts)}</dd>
<dt>{t("adminLogs.fields.source")}</dt>
<dd className="font-mono">
{row.source} · {row.logger}
+2 -7
View File
@@ -33,7 +33,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { notifyActionError } from "../lib/actionErrors";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
import { channelYouTubeUrl, formatDate, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { Button } from "./ui/Button";
@@ -285,12 +285,7 @@ export default function ChannelPage({
const name = ch?.title ?? initialName ?? channelId;
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
const joined = ch?.published_at
? new Date(ch.published_at).toLocaleDateString(i18n.language, {
year: "numeric",
month: "short",
})
: null;
const joined = ch?.published_at ? formatDate(ch.published_at, i18n.language) : null;
// Handle + stats on one compact meta line under the name (saves the separate stats row).
const metaParts = [
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
+5 -4
View File
@@ -8,12 +8,12 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { useScrollFade } from "../lib/useScrollFade";
import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate";
import { Textarea } from "./ui/Input";
import { RelativeTime } from "./ui/RelativeTime";
// The message list + composer for one conversation, shared by the full Messages page and the
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
@@ -194,9 +194,10 @@ export default function ChatThread({
}`}
>
{text}
<div className={`text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}>
{relativeTime(m.created_at)}
</div>
<RelativeTime
at={m.created_at}
className={`block text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}
/>
</div>
</div>
);
+2 -4
View File
@@ -11,6 +11,7 @@ import {
selectDateChip,
type DatePreset,
} from "../lib/dateFilter";
import { formatDate } from "../lib/format";
import { Button } from "./ui/Button";
import { SelectTrigger } from "./ui/SelectTrigger";
import { Input } from "./ui/Input";
@@ -57,10 +58,7 @@ export default function DateFilterSelect({
const key = deriveDateKey(filters, customOpen, presets);
const fmt = (s: string) => {
const d = parseDay(s);
return d
? new Intl.DateTimeFormat(i18n.language, { month: "short", day: "numeric" }).format(d)
: s;
return parseDay(s) ? formatDate(s, i18n.language) : s;
};
const rangeSummary = () => {
const { dateFrom: f, dateTo: to } = filters;
+3 -5
View File
@@ -16,7 +16,7 @@ import {
useStepCount,
useStepPrompt,
} from "../lib/debugRecorder";
import { formatDuration } from "../lib/format";
import { formatDateTimeSec, formatDuration } from "../lib/format";
import { useLayer } from "../lib/layerStack";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
@@ -372,7 +372,7 @@ const chipTone = (s: DebugSession): string => {
/** The last sessions, newest first — reopening one opens its composer, with what was written. */
function SessionHistory() {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions });
const items = q.data?.items.filter((s) => s.ended_at) ?? [];
if (q.isError && !q.data)
@@ -394,9 +394,7 @@ function SessionHistory() {
key={s.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="tabular-nums">
{new Date(s.started_at).toLocaleString(i18n.language)}
</span>
<span className="tabular-nums">{formatDateTimeSec(s.started_at)}</span>
<span className="min-w-0 flex-1 truncate text-muted">
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
</span>
@@ -122,9 +122,7 @@ describe("DiagnosticsSettings", () => {
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
expect(screen.getByTestId("diagnostics-status").textContent).toContain(
"settings.diagnostics.hoursMinutes:24h0m",
);
expect(screen.getByTestId("diagnostics-status").textContent).toContain("24 hr 0 min");
} finally {
vi.useRealTimers();
}
@@ -11,6 +11,7 @@ import SegmentedToggle from "./ui/SegmentedToggle";
import { Section, SettingRow } from "./ui/form";
import MyLogViewer from "./MyLogViewer";
import { RecorderSettings } from "./DebugRecorder";
import { formatUnit } from "../lib/format";
const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
@@ -171,8 +172,8 @@ export default function DiagnosticsSettings() {
level: levelName(current),
time:
remaining.h > 0
? t("settings.diagnostics.hoursMinutes", remaining)
: t("settings.diagnostics.minutes", remaining),
? `${formatUnit(remaining.h, "hour")} ${formatUnit(remaining.m, "minute")}`
: formatUnit(remaining.m, "minute"),
})}
</span>
</span>
+3 -3
View File
@@ -5,7 +5,7 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api";
import { closeComposer } from "../lib/debugRecorder";
import { formatBytes } from "../lib/format";
import { formatBytes, formatDateTimeSec, formatUnit } from "../lib/format";
import { notify } from "../lib/notifications";
import { useMe } from "../lib/useMe";
import {
@@ -303,8 +303,8 @@ function ComposerBody({
<div ref={root} className="flex flex-col gap-4">
<p className="text-sm text-muted">
{t("recorder.composer.meta", {
start: started.toLocaleString(i18n.language),
minutes,
start: formatDateTimeSec(started.toISOString()),
minutes: formatUnit(minutes, "minute"),
level: t(`settings.diagnostics.levels.${session.level}`),
})}
</p>
+5 -4
View File
@@ -8,7 +8,6 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { useHistorySubview } from "../lib/history";
import { openChat } from "../lib/chatDock";
@@ -22,6 +21,7 @@ import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
import { RelativeTime } from "./ui/RelativeTime";
type View =
| "list"
@@ -177,9 +177,10 @@ function ConversationList({
>
{c.partner.name}
</span>
<span className="ml-auto text-2xs text-muted shrink-0">
{relativeTime(c.last_message.created_at)}
</span>
<RelativeTime
at={c.last_message.created_at}
className="ml-auto text-2xs text-muted shrink-0"
/>
</div>
<div className="flex items-center gap-2">
<span
@@ -36,7 +36,7 @@ import {
type VideoHiddenMeta,
type VideoWatchedMeta,
} from "../lib/notifications";
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
import { channelYouTubeUrl } from "../lib/format";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LEVEL_STYLE } from "./ui/toastLevels";
@@ -57,6 +57,7 @@ import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
import { HOVER_REVEAL } from "./ui/reveal";
import { useConfirm } from "./confirmContext";
import { RelativeTime } from "./ui/RelativeTime";
// The single notification center. "System" = durable, server-backed notifications (inbox
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
@@ -545,7 +546,7 @@ function NotificationRow({
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-medium text-sm">{title}</span>
<span className="text-2xs text-muted shrink-0">{relativeTime(n.created_at)}</span>
<RelativeTime at={n.created_at} className="text-2xs text-muted shrink-0" />
</div>
{body && <div className="text-sm text-muted mt-0.5">{body}</div>}
{videos.length > 0 && (
@@ -634,7 +635,7 @@ function ClientActivityRow({
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-2xs text-muted">{relativeFromMs(n.ts)}</span>
<RelativeTime at={n.ts} className="text-2xs text-muted" />
{needsAction && (
<span className="text-3xs uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")}
@@ -637,7 +637,7 @@ describe("Playlists · the header", () => {
it("sums what is known, and says nothing about a runtime nothing sums to", async () => {
mount({ 1: [vid("A", 0), untimed("B")] });
expect(await meta()).toBe("playlists.itemCount · 50m");
expect(await meta()).toBe("playlists.itemCount · 50 min");
cleanup();
mount({ 1: [untimed("C")] });
@@ -14,7 +14,8 @@ vi.mock("react-i18next", async (importOriginal) => {
return {
...actual,
useTranslation: () => ({
t: (key: string, o?: { n?: number }) => (o?.n != null ? `${key}:${o.n}` : key),
t: (key: string, o?: { n?: number; d?: string }) =>
o?.d != null ? `${key}:${o.d}` : o?.n != null ? `${key}:${o.n}` : key,
i18n: { language: "en" },
}),
};
@@ -223,11 +224,11 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
pressPicture("touch", 800);
expect(bandHidden()).toBe(false); // …which the second takes back
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1510]));
expect(screen.getByText("plex.player.tapForward:10")).toBeTruthy();
expect(screen.getByText("plex.player.tapForward:10 sec")).toBeTruthy();
pressPicture("touch", 800);
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1520]));
expect(screen.getByText("plex.player.tapForward:20")).toBeTruthy();
expect(screen.getByText("plex.player.tapForward:20 sec")).toBeTruthy();
pressPicture("touch", 50); // the other side, a fresh first tap: a toggle, no skip
expect(lastStart(session)).toEqual(["A", 1520]);
@@ -249,7 +250,7 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
pressPicture("touch", 100);
pressPicture("touch", 100);
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1490]));
expect(screen.getByText("plex.player.tapBack:10")).toBeTruthy();
expect(screen.getByText("plex.player.tapBack:10 sec")).toBeTruthy();
});
});
+2 -1
View File
@@ -84,6 +84,7 @@ import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Slider } from "./ui/NativeControls";
import { formatUnit } from "../lib/format";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -865,7 +866,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
seekTo(playhead(absRef, loadStartRef) + zone * TAP_SKIP_S);
flashOsd(
t(zone < 0 ? "plex.player.tapBack" : "plex.player.tapForward", {
n: skips * TAP_SKIP_S,
d: formatUnit(skips * TAP_SKIP_S, "second"),
}),
);
return;
@@ -77,7 +77,7 @@ async function pickFromMenu(open: () => void, label: string) {
describe("PlexPlaylistView · the header's meta line", () => {
it("sums the known runtimes and counts an unknown one as nothing", async () => {
expect(await meta([movie("1", 5400), movie("2"), movie("3", 1800)])).toBe(
"plex.browse.items · 2h 0m",
"plex.browse.items · 2 hr 0 min",
);
});
+9 -5
View File
@@ -22,7 +22,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useConfirm } from "./confirmContext";
import { formatNumber, relativeTime } from "../lib/format";
import { formatDateTime, formatNumber, formatUnit, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
import { notifyActionError } from "../lib/actionErrors";
@@ -48,11 +48,12 @@ function secsUntil(iso: string | null): number | null {
function fmtCountdown(s: number): string {
if (s <= 0) return i18n.t("time.eta.now");
if (s < 60) return `${s}s`;
// The countdown speaks the UI language's units (DESIGN Copy), like every other duration.
if (s < 60) return formatUnit(s, "second");
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
if (m < 60) return `${formatUnit(m, "minute")} ${formatUnit(s % 60, "second")}`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
return `${formatUnit(h, "hour")} ${formatUnit(m % 60, "minute")}`;
}
const DOT_CLASS: Record<string, string> = {
@@ -243,7 +244,10 @@ function JobRow({
</Tooltip>
)}
</div>
<div className="text-2xs text-muted truncate">
<div
className="text-2xs text-muted truncate"
title={(!job.running && formatDateTime(job.last_finished)) || undefined}
>
{job.running
? t("scheduler.runningNow")
: job.last_finished
+4 -8
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { withActiveAccount } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
import { formatDateTime } from "../lib/format";
import { formatDate, formatDateTime } from "../lib/format";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
@@ -519,7 +519,7 @@ function SignInMethods({ me }: { me: Me }) {
// and does a one-time "Plex is master" import. Admin-only (rides the server admin token) and only
// shown when the Plex module is enabled. Two-way push/pull arrive in later phases.
function PlexWatchSync() {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const qc = useQueryClient();
const link = useQuery({ queryKey: qk.plexWatchLink(), queryFn: () => api.plexWatchLink() });
const enabled = link.data?.sync_enabled ?? false;
@@ -562,9 +562,7 @@ function PlexWatchSync() {
// Reflect the toggle's target immediately (optimistic) so the switch responds on click rather than
// only flipping once the multi-second import returns — otherwise it reads as "nothing happened".
const shown = toggle.isPending ? Boolean(toggle.variables) : enabled;
const last = link.data?.last_watch_sync_at
? new Date(link.data.last_watch_sync_at).toLocaleString(i18n.language)
: null;
const last = link.data?.last_watch_sync_at ? formatDateTime(link.data.last_watch_sync_at) : null;
return (
<Section title={t("settings.plexSync.title")}>
@@ -670,9 +668,7 @@ export function DownloadAuth() {
) : (
<div className="divide-y divide-border">
{hosts.map((h) => {
const when = h.updated_at
? new Date(h.updated_at).toLocaleDateString(i18n.language)
: "";
const when = h.updated_at ? formatDate(h.updated_at, i18n.language) : "";
const open = openHost === h.host_key;
return (
<div key={h.host_key} className="py-2" data-testid={`cookie-host-${h.host_key}`}>
+3 -3
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, formatNumber, quotaActionLabel } from "../lib/format";
import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { useWizard } from "./wizardContext";
@@ -259,7 +259,7 @@ function SystemStats() {
className="flex items-center gap-1.5"
options={RANGES.map((d) => ({
value: String(d),
label: t("stats.rangeDays", { count: d }),
label: t("stats.rangeDays", { d: formatUnit(d, "day") }),
}))}
value={String(days)}
onChange={(v) => setDays(Number(v))}
@@ -286,7 +286,7 @@ function SystemStats() {
<div className={`${SURFACE.card} p-3 mb-4`}>
<div className="text-xs text-muted mb-2">
{t("stats.dailyTotal", {
count: data.range_days,
d: formatUnit(data.range_days, "day"),
units: formatNumber(grandTotal),
})}
</div>
@@ -143,13 +143,13 @@ describe("the card on a shelf", () => {
const title = titleOf(card).closest(".font-medium")!;
expect(title.className).toContain("truncate");
expect(title.className).not.toContain("line-clamp-2");
expect(card.querySelector("span[title]")).toBeNull();
expect(card.querySelector("time[title]")).toBeNull();
expect(screen.getByRole("link", { name: "LEMMiNO" })).toBeTruthy();
});
it("off a shelf keeps two title lines and the age", () => {
const card = renderSmall(false);
expect(titleOf(card).closest(".font-medium")!.className).toContain("line-clamp-2");
expect(card.querySelector("span[title]")).not.toBeNull();
expect(card.querySelector("time[title]")).not.toBeNull();
});
});
+2 -11
View File
@@ -35,6 +35,7 @@ import {
} from "./ui/mediaCardStyles";
import { MediaCard, MediaProgress } from "./ui/MediaCard";
import { MediaResumeButtons } from "./MediaResumeButtons";
import { RelativeTime } from "./ui/RelativeTime";
/** The "…" that opens the video's action list, and whether its menu is the open one. */
type More = { props: Record<string, unknown>; open: boolean };
@@ -700,17 +701,7 @@ function VideoCard({
{/* The small card keeps the relative time and moves the date into its tooltip: at a
shelf's width the full pair wrapped to a third meta line, and the tallest card sets
the whole shelf's height (every card fills its slot). */}
{spec.dense ? (
<span
title={
video.published_at ? formatDate(video.published_at, i18n.language) : undefined
}
>
{relativeTime(video.published_at)}
</span>
) : (
published
)}
{spec.dense ? <RelativeTime at={video.published_at} /> : published}
</div>
</>
)
+2 -9
View File
@@ -1,8 +1,7 @@
import type { TFunction } from "i18next";
import { relativeTime, formatDate } from "../lib/format";
import i18n from "../i18n";
import type { AuditRow } from "../lib/api";
import type { Column } from "./ui/DataTable";
import { RelativeTime } from "./ui/RelativeTime";
function actionLabel(t: TFunction, action: string): string {
// Canonical key → localized label; falls back to the raw key for any unmapped action.
@@ -44,13 +43,7 @@ export function auditColumns(
sortValue: (r) => r.created_at ?? "",
cardPrimary: true,
render: (r) => (
<span
className="text-muted tabular-nums"
title={r.created_at ? formatDate(r.created_at, i18n.language) : ""}
>
{/* `|| "—"` not a null check: relativeTime is empty for a missing OR unreadable date. */}
{relativeTime(r.created_at) || "—"}
</span>
<RelativeTime at={r.created_at} seconds empty="—" className="text-muted tabular-nums" />
),
},
{
+3 -2
View File
@@ -1,7 +1,7 @@
import type { ComponentProps, ReactNode } from "react";
import type { TFunction } from "i18next";
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
import { formatCountOrDash, formatTotalHours } from "../lib/format";
import type { Column } from "./ui/DataTable";
import { PriorityStepper } from "./ui/PriorityStepper";
import ChannelLink from "./ChannelLink";
@@ -9,6 +9,7 @@ import AboutCell from "./AboutCell";
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
import { StoredCell } from "./channelCells";
import { isFullySynced } from "./channelSync";
import { RelativeTime } from "./ui/RelativeTime";
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
@@ -148,7 +149,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
width: "6rem",
sortable: true,
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
render: (c) => <span className="text-muted">{relativeTime(c.last_video_at) || "—"}</span>,
render: (c) => <RelativeTime at={c.last_video_at} empty="—" className="text-muted" />,
},
{
key: "length",
+1 -1
View File
@@ -111,7 +111,7 @@ function NewCollectionPanel({
return (
<div className={`${SURFACE.panel} mb-6 p-4`}>
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3>
<h3 className="text-sm font-semibold">{t("plex.collection.newCollectionTitle")}</h3>
<IconButton size="md" onClick={onClose} label={t("common.close")}>
<X />
</IconButton>
@@ -85,7 +85,7 @@ describe("PlexPosterCard a11y", () => {
);
expect(screen.getByText("2023")).toBeTruthy();
expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1)
expect(screen.getByText("1h 53m")).toBeTruthy();
expect(screen.getByText("1 hr 53 min")).toBeTruthy();
expect(screen.getByRole("button", { name: movie().title }).getAttribute("title")).toBeNull();
});
+1 -1
View File
@@ -43,7 +43,7 @@ domain-free.
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy") |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
//
// docs/DESIGN.md "Copy": a relative time in a list carries the exact moment on hover.
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { formatDateTime, formatDateTimeSec, relativeTime } from "../../lib/format";
import { RelativeTime } from "./RelativeTime";
afterEach(cleanup);
const THREE_DAYS_AGO = new Date(Date.now() - 3 * 86400_000).toISOString();
describe("RelativeTime", () => {
it("says how long ago, and names the exact minute on hover", () => {
const { container } = render(<RelativeTime at={THREE_DAYS_AGO} />);
const el = container.querySelector("time")!;
expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO));
expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO));
expect(el.getAttribute("dateTime")).toBe(THREE_DAYS_AGO);
});
it("takes epoch millis, and the log form with seconds", () => {
const ms = Date.parse(THREE_DAYS_AGO);
const { container } = render(<RelativeTime at={ms} seconds />);
expect(container.querySelector("time")!.getAttribute("title")).toBe(
formatDateTimeSec(THREE_DAYS_AGO),
);
});
it("shows the placeholder for a missing or unreadable moment, never a bare title", () => {
const { container } = render(
<>
<RelativeTime at={null} empty="—" />
<RelativeTime at="not a date" />
</>,
);
expect(container.textContent).toBe("—");
expect(container.querySelector("time")).toBeNull();
});
});
@@ -0,0 +1,32 @@
import { formatDateTime, formatDateTimeSec, relativeFromMs } from "../../lib/format";
// docs/DESIGN.md "Copy": a relative time ("3d ago") says how recent something is in a list, and the
// exact moment is one hover away — so the pair travels together here instead of every list
// remembering (or forgetting) its own `title`. `seconds` is the log form (the audit). An unreadable
// or missing moment renders `empty` ("—" in a table cell) or nothing.
export function RelativeTime({
at,
seconds = false,
empty = "",
className,
}: {
/** An ISO timestamp, or epoch millis (a client-side notification). */
at: string | number | null | undefined;
seconds?: boolean;
empty?: string;
className?: string;
}) {
const ms = typeof at === "number" ? at : at ? new Date(at).getTime() : NaN;
const text = Number.isFinite(ms) ? relativeFromMs(ms) : "";
if (!text) return empty ? <span className={className}>{empty}</span> : null;
const iso = new Date(ms).toISOString();
return (
<time
dateTime={iso}
title={seconds ? formatDateTimeSec(iso) : formatDateTime(iso)}
className={className}
>
{text}
</time>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
// docs/DESIGN.md "Copy" (E28 S3g): the parts of the copy rule a machine can read, held over every
// value in both languages. The rest (one name per feature, one verb per meaning, "…" when a command
// asks first) needs a reader and is checked by the DESIGN conformance step.
const files = import.meta.glob("./locales/*/*.json", { eager: true, import: "default" }) as Record<
string,
unknown
>;
function values(): [string, string][] {
const out: [string, string][] = [];
const walk = (o: unknown, path: string) => {
if (typeof o === "string") out.push([path, o]);
else if (o && typeof o === "object")
for (const [k, v] of Object.entries(o)) walk(v, path ? `${path}.${k}` : k);
};
for (const [file, json] of Object.entries(files))
walk(json, file.replace("./locales/", "").replace(".json", ":"));
return out;
}
const offenders = (test: (v: string) => boolean) =>
values()
.filter(([, v]) => test(v))
.map(([k, v]) => `${k} = ${v}`);
describe("the copy rule", () => {
it("quotes a name with curly quotes, never straight ones", () => {
expect(offenders((v) => v.includes('"'))).toEqual([]);
});
it("writes an ellipsis as one character", () => {
expect(offenders((v) => v.includes("..."))).toEqual([]);
});
it("ends no message with a tick", () => {
expect(offenders((v) => v.includes("✓"))).toEqual([]);
});
it("spells no unit as a bare letter after a number ('90m', '24h', '{{count}}d', '10 s')", () => {
expect(offenders((v) => /(\d|\}\})\s?(m|h|d|s)(?![\p{L}\d/])/u.test(v))).toEqual([]);
});
});
+1 -1
View File
@@ -11,5 +11,5 @@
"fixes": "Fixes",
"olderVersions_one": "{{count}} older version",
"olderVersions_other": "{{count}} older versions",
"notesError": "The release notes could not be loaded."
"notesError": "Couldn't load the release notes"
}
+3 -3
View File
@@ -26,9 +26,9 @@
"showRequest": "Only this request's lines",
"showJob": "Only this job's lines",
"empty": "No lines match the filters.",
"stale": "The last refresh failed; what you see may be out of date.",
"olderFailed": "Couldn't load the older lines.",
"countsFailed": "The filter counts didn't load.",
"stale": "Couldn't refresh — what you see may be out of date",
"olderFailed": "Couldn't load the older lines",
"countsFailed": "Couldn't load the filter counts",
"count_one": "{{count}} line loaded · newest first",
"count_other": "{{count}} lines loaded · newest first",
"older": "Load older",
+1 -1
View File
@@ -7,7 +7,7 @@
"clearTitle": "Clear the audit log?",
"clearConfirm": "Permanently delete all audit entries? This can't be undone. (One entry recording that you cleared it is kept.)",
"cleared": "Cleared {{count}} audit entries",
"clearFailed": "Couldn't clear the audit log.",
"clearFailed": "Couldn't clear the audit log",
"truncated": "Showing the {{returned}} most recent of {{total}} entries.",
"cols": {
"when": "When",
@@ -22,7 +22,7 @@
"job_run": "Scheduler job run",
"audit_clear": "Audit log cleared",
"download_credential_set": "Download cookies set",
"download_credential_delete": "Download cookies removed",
"download_credential_delete": "Download cookies deleted",
"invite_delete": "Whitelist entry deleted",
"invite_send": "Invitation sent",
"issue_create": "Bug report sent"
+1 -1
View File
@@ -36,5 +36,5 @@
"fullHistoryProgress": "Fetching history… {{stored}} / {{total}}",
"fullHistoryLoadingHint": "Fetching the full back-catalogue — videos appear as they're found; a very large channel's tail is finished in the background.",
"fullHistoryQueued": "Fetching this channel's full history — new videos will appear as they're found.",
"fullHistoryFailed": "Couldn't start the full-history fetch."
"fullHistoryFailed": "Couldn't start the full-history fetch"
}
+11 -11
View File
@@ -1,6 +1,6 @@
{
"actionsLabel": "Catalog actions",
"syncSubscriptions": "Read subscriptions from YouTube",
"syncSubscriptions": "Sync subscriptions from YouTube",
"syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.",
"backfillEverything": "Backfill everything",
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while.",
@@ -31,8 +31,8 @@
"needWriteHint": "Enable playlist editing in Settings to subscribe on YouTube.",
"subscribedTitle": "Subscribed on YouTube",
"subscribedBody": "You're now following {{name}} — its new uploads will start arriving in your Feed.",
"subscribeFailed": "Subscribe failed",
"confirmSubscribe": "Subscribe to \"{{name}}\" on YouTube? This changes your real YouTube account and spends a little API quota.",
"subscribeFailed": "Couldn't subscribe",
"confirmSubscribe": "Subscribe to “{{name}}” on YouTube? This changes your real YouTube account and spends a little API quota.",
"cols": {
"totalVideos": "Videos",
"inPlaylists": "In playlists",
@@ -54,8 +54,8 @@
"noneYet": "No tags yet — create one below.",
"create": "Create tag",
"createPlaceholder": "New tag…",
"createFailed": "Could not create the tag.",
"updateFailed": "Could not update the tag.",
"createFailed": "Couldn't create the tag",
"updateFailed": "Couldn't update the tag",
"noTag": "No tag",
"moreCount_one": "{{count}} more tag",
"moreCount_other": "{{count}} more tags",
@@ -102,7 +102,7 @@
},
"row": {
"openOnYouTube": "Open on YouTube",
"editTags": "Edit tags",
"editTags": "Edit tags…",
"filterFeedByTag": "Filter the Feed by “{{name}}”",
"priorityHint": "Your ranking for this channel. Sort the Feed by “Channel priority” to bring higher-priority channels to the top.",
"fullHint": "Full history fetched.",
@@ -126,20 +126,20 @@
},
"notify": {
"synced": "Synced {{count}} subscriptions",
"syncFailed": "Subscription sync failed",
"syncFailed": "Couldn't sync subscriptions",
"unsubscribed": "Unsubscribed from {{name}} on YouTube",
"unsubscribeFailed": "Unsubscribe failed",
"unsubscribeFailed": "Couldn't unsubscribe",
"fullHistoryRequested": "Full history requested for {{count}} channels",
"fullHistoryFailed": "Couldn't request full history",
"needYouTube": "Connect your YouTube account to do this.",
"needYouTube": "Connect your YouTube account to do this",
"connect": "Connect",
"filteredByTag": "Feed filtered by tag: {{name}}",
"resetDone": "{{name}} reset — re-fetching now",
"resetFailed": "Couldn't reset this channel",
"hidden": "“{{name}}” hidden from your Feed"
},
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your Feed, hide it instead.",
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota.",
"confirmUnsubscribe": "Unsubscribe from “{{name}}” on YouTube? This changes your real YouTube account. To just remove it from your Feed, hide it instead.",
"confirmReset": "Re-fetch “{{name}}” from scratch? This re-runs its backfill (recent + full history) and spends some API quota.",
"searchPlaceholder": "Search channels…",
"blocked": {
"title": "Blocked channels",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"privacyPolicy": "Privacy Policy",
"termsOfService": "Terms of Service",
"privacyPolicy": "Privacy policy",
"termsOfService": "Terms of service",
"sourceCode": "Source code",
"close": "Close",
"cancel": "Cancel",
@@ -10,7 +10,7 @@
"undo": "Undo",
"redo": "Redo",
"loading": "Loading…",
"somethingWrong": "Something went wrong.",
"somethingWrong": "Something went wrong",
"itemCount_one": "{{formattedCount}} item",
"itemCount_other": "{{formattedCount}} items",
"accessRequestsTitle": "Access requests",
@@ -20,7 +20,7 @@
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
"backToTop": "Back to top",
"retry": "Retry",
"loadError": "Couldn't load this — check your connection and try again.",
"loadError": "Couldn't load this — check your connection and try again",
"seeAll": "See all",
"scrollLeft": "Scroll left",
"scrollRight": "Scroll right",
+8 -8
View File
@@ -54,7 +54,7 @@
},
"search_source": {
"label": "Live search source",
"hint": "Where live YouTube search results come from. \"Website\" reads YouTube's own search endpoint and spends no API quota (recommended). \"Official API\" uses search.list (100 units/page) — switch to it if the website source ever breaks or is blocked."
"hint": "Where live YouTube search results come from. “Website” reads YouTube's own search endpoint and spends no API quota (recommended). “Official API” uses search.list (100 units/page) — switch to it if the website source ever breaks or is blocked."
},
"backfill_recent_max_videos": {
"label": "Recent backfill — max videos",
@@ -134,7 +134,7 @@
},
"download_upload_max_bytes": {
"label": "Upload size cap per file (bytes)",
"hint": "Largest single file a user may upload to the download center. At least 1, so the cap is always on; the user's total storage is still bounded by their quota. Default 500 MiB."
"hint": "Largest single file a user may upload to Downloads. At least 1, so the cap is always on; the user's total storage is still bounded by their quota. Default 500 MiB."
},
"download_retention_days": {
"label": "Retention (days)",
@@ -146,7 +146,7 @@
},
"download_layout": {
"label": "On-disk layout",
"hint": "\"Plex\" = a Channel/Season YYYY/… tree with .nfo + poster (Plex-indexable); \"Flat\" = a single folder."
"hint": "“Plex” = a Channel/Season YYYY/… tree with .nfo + poster (Plex-indexable); “Flat” = a single folder."
},
"download_worker_concurrency": {
"label": "Worker concurrency",
@@ -166,7 +166,7 @@
},
"log_retention_days": {
"label": "Log retention (days)",
"hint": "How long stored log events are kept before the log cleanup job deletes them. They include account-linked request history, so there is no \"keep forever\". Default 7."
"hint": "How long stored log events are kept before the log cleanup job deletes them. They include account-linked request history, so there is no “keep forever”. Default 7."
},
"plex_enabled": {
"label": "Enable Plex",
@@ -292,22 +292,22 @@
"testEmail": "Send test email",
"testEmailHint": "Send a test email to your own address using the settings above, to confirm they work.",
"testSent": "Test email sent to {{to}}",
"testFailed": "Test email failed — check the settings",
"testFailed": "Couldn't send the test email — check the settings",
"plexTest": "Test connection",
"plexTestHint": "Verify the Plex server URL + token and load the library list. Save any edits first.",
"testDirty": "Save your changes before testing them.",
"plexTestOk": "Connected to {{name}}",
"plexTestFailed": "Plex connection failed — check the URL and token",
"plexTestFailed": "Couldn't connect to Plex — check the URL and token",
"plexConnected": "Connected to {{name}} {{version}}",
"plexMediaOk": "Media mount OK — a sample file was found on disk.",
"plexMediaMissing": "Media not readable at {{path}} — check that the Plex media is bind-mounted into the container and that the path map is correct. Playback will fail until this is fixed.",
"plexPickLibraries": "Libraries to expose:",
"plexPickHint": "Unchecking all is the same as exposing every library. Remember to Save.",
"plexMovies": "Movies",
"plexShows": "TV Shows",
"plexShows": "TV shows",
"emailOff": "Email sending is not configured — Siftlode silently skips every message: verification links, password resets and access-request notices. Fill in the fields above, then send a test.",
"issuesTest": "Check the connection",
"issuesTestHint": "Checks that the repository answers, is private and takes issues. Save your changes first.",
"issuesTestOk": "Works: {{repo}} (private)",
"issuesTestFailed": "The issue tracker check failed"
"issuesTestFailed": "Couldn't check the issue tracker"
}
+32 -32
View File
@@ -13,14 +13,14 @@
"submit": "Add to downloads",
"added": "Added to downloads",
"view": "View",
"manage": "Manage formats"
"manage": "Manage formats…"
},
"page": {
"addUrl": "Paste a video link (YouTube, Facebook, …) or a YouTube video id…",
"add": "Add",
"paste": "Paste link from clipboard",
"upload": "Upload",
"manage": "Manage formats"
"manage": "Manage formats…"
},
"upload": {
"title": "Upload a file",
@@ -33,7 +33,7 @@
"uploading": "Uploading… {{pct}}%",
"uploadProgress": "Upload progress",
"added": "File uploaded",
"failed": "The upload failed."
"failed": "Couldn't upload the file"
},
"tabs": {
"queue": "Queue",
@@ -65,15 +65,15 @@
"pause": "Pause",
"resume": "Resume",
"cancel": "Cancel",
"delete": "Remove",
"delete": "Delete",
"retry": "Retry",
"download": "Save to my device",
"play": "Play",
"rename": "Rename",
"editDetails": "Edit details",
"editDetails": "Edit details…",
"share": "Share (copy link)",
"shareManage": "Manage sharing",
"edit": "Edit / trim",
"shareManage": "Manage sharing…",
"edit": "Edit / trim…",
"redownload": "Re-download (rebuild file)",
"removeShared": "Remove from my list"
},
@@ -88,7 +88,7 @@
},
"usage": {
"title": "Your storage",
"items": "{{used}} / {{max}} items",
"items": "{{used}} / {{max}} downloads",
"unlimited": "Unlimited"
},
"source": {
@@ -108,7 +108,7 @@
"share": {
"title": "Share download",
"done": "Shared with {{email}}",
"failed": "Couldn’t share.",
"failed": "Couldn’t share",
"userSection": "Share with a user",
"userHint": "They’ll find it under “Shared with me”.",
"pickPlaceholder": "Search a user…",
@@ -127,7 +127,7 @@
"days": "{{n}} days",
"password": "Password (optional)",
"copy": "Copy link",
"copyFailed": "Couldn’t copy.",
"copyFailed": "Couldn’t copy",
"revoke": "Revoke",
"revokeConfirm": "This link will stop working immediately.",
"views": "{{n}} views",
@@ -138,15 +138,15 @@
"changePassword": "Change password",
"newPassword": "New password",
"savePassword": "Save",
"removePassword": "Remove",
"removePassword": "Delete",
"removePasswordConfirm": "This makes the link public — anyone with the URL can watch it without a password."
},
"confirm": {
"cancelTitle": "Cancel download?",
"cancelBody": "This stops the download and removes it from your queue.",
"cancelAction": "Stop download",
"deleteTitle": "Remove download?",
"deleteBody": "This removes it from your list. The file may stay cached for other users until it expires.",
"deleteTitle": "Delete download?",
"deleteBody": "This deletes it from your downloads. The file may stay cached for other users until it expires.",
"removeSharedTitle": "Remove from your list?",
"removeSharedBody": "This removes it from your shared list only — it won't delete the owner's file.",
"redownloadTitle": "Re-download this file?",
@@ -198,28 +198,28 @@
"errors": {
"quota": {
"max_bytes": "You've used your download storage quota ({{size}} GB). Free up space first.",
"max_jobs": "You've reached your download limit ({{limit}} items). Remove some first.",
"generic": "Download quota exceeded."
"max_jobs": "You've reached your download limit ({{limit}} downloads). Delete some first.",
"generic": "Download quota exceeded"
},
"edit": {
"empty_edit": "Choose a trim range or crop area first.",
"source_not_ready": "The source download isn't ready to edit.",
"generic": "That edit couldn't be created."
"empty_edit": "Choose a trim range or crop area first",
"source_not_ready": "The source download isn't ready to edit",
"generic": "Couldn't create that edit"
},
"upload": {
"tooLarge": "That file is too large (max {{size}}).",
"empty": "That file is empty."
"tooLarge": "That file is too large (max {{size}})",
"empty": "That file is empty"
},
"urlBlocked": {
"scheme": "Only http:// and https:// links can be downloaded.",
"no_host": "That link doesn't contain a website address.",
"scheme": "Only http:// and https:// links can be downloaded",
"no_host": "That link doesn't contain a website address",
"unresolvable": "That address couldn't be looked up. Check the link and try again.",
"private": "That address is on the server's own network, so it can't be downloaded from.",
"generic": "That address can't be downloaded from.",
"malformed": "That link isn't a valid web address."
"private": "That address is on the server's own network, so it can't be downloaded from",
"generic": "That address can't be downloaded from",
"malformed": "That link isn't a valid web address"
},
"fail": {
"title": "Download failed",
"title": "Couldn't download",
"detailsLink": "details",
"detailsToggle": "Technical details",
"cat": {
@@ -248,7 +248,7 @@
"body": "This video is DRM-protected and cannot be downloaded."
},
"postprocess": {
"short": "Processing failed",
"short": "Couldn't process the video",
"body": "The video downloaded but a processing step (merge or convert) failed. A retry often fixes this."
},
"source_missing": {
@@ -260,8 +260,8 @@
"body": "A network error interrupted the download. A retry usually fixes this."
},
"unknown": {
"short": "Download failed",
"body": "Something went wrong while downloading this item. The technical details below may help."
"short": "Couldn't download",
"body": "Something went wrong with this download. The technical details below may help."
},
"quota_exceeded": {
"short": "Storage full",
@@ -300,9 +300,9 @@
}
},
"cookies": {
"empty": "Paste your cookies first.",
"format": "That doesn't look like a cookies.txt file (Netscape format).",
"generic": "Those cookies couldn't be saved."
"empty": "Paste your cookies first",
"format": "That doesn't look like a cookies.txt file (Netscape format)",
"generic": "Couldn't save those cookies"
}
}
}
+2 -2
View File
@@ -12,7 +12,7 @@
},
"fast": {
"label": "Fast cut",
"hint": "Instant, but cuts snap to keyframes (±1–2s)."
"hint": "Instant, but cuts snap to keyframes (±1–2 sec)."
},
"nameLabel": "Clip name (optional)",
"willReencode": "Will re-encode",
@@ -21,7 +21,7 @@
"createN": "Create {{count}} clips",
"created_one": "{{count}} clip added to your downloads",
"created_other": "{{count}} clips added to your downloads",
"failed": "Couldn’t create the clip.",
"failed": "Couldn’t create the clip",
"partlyCreated": "Only {{done}} of {{total}} clips were added — the rest are still selected, so creating again adds just those.",
"splitHere": "Split here",
"output": {
+137 -137
View File
@@ -1,57 +1,57 @@
{
"title": "Couldn't complete that",
"generic": "The server couldn't carry out that action. Please try again.",
"demo": "This isn't available in the demo account.",
"demo": "This isn't available in the demo account",
"validation": "Some of the values you entered weren't valid. Please check them and try again.",
"tooLarge": "That was too large for the server to accept.",
"tooLarge": "That was too large for the server to accept",
"server": "Server error",
"syncCooldown": "You've synced a few times just now — give it a few minutes. The YouTube budget is shared with everyone on this instance, and background syncing keeps running either way.",
"notFound": {
"generic": "This item doesn't exist any more.",
"view": "This view doesn't exist any more.",
"tag": "This tag doesn't exist any more.",
"pin": "This isn't on your comfort shelf any more.",
"playlist": "This playlist doesn't exist any more.",
"move_target": "The playlist you're moving it to doesn't exist any more.",
"video": "This video isn't in the library any more.",
"invite": "This invite doesn't exist any more.",
"user": "This user doesn't exist any more.",
"channel": "This channel isn't in the library any more.",
"subscription": "You're not subscribed to this channel any more.",
"recipient": "There's no user with that email address.",
"job": "This job doesn't exist any more.",
"download": "This download doesn't exist any more.",
"debug_session": "This debug session doesn't exist any more.",
"screenshot": "This screenshot doesn't exist any more.",
"link": "This share link doesn't exist any more.",
"notification": "This notification doesn't exist any more.",
"account": "That account no longer exists.",
"collection": "This collection doesn't exist any more.",
"config_key": "There's no such setting.",
"file": "The file is no longer available.",
"filmstrip": "There's no filmstrip for this.",
"host": "This host isn't on the list any more.",
"image": "There's no image for this.",
"item": "This item doesn't exist any more.",
"library": "This library doesn't exist any more.",
"plex_item": "This Plex item isn't in the library any more.",
"poster": "There's no poster for this.",
"profile": "This download profile doesn't exist any more.",
"season": "This season isn't in the library any more.",
"show": "This show isn't in the library any more."
"generic": "This item doesn't exist any more",
"view": "This view doesn't exist any more",
"tag": "This tag doesn't exist any more",
"pin": "This isn't on your comfort shelf any more",
"playlist": "This playlist doesn't exist any more",
"move_target": "The playlist you're moving it to doesn't exist any more",
"video": "This video isn't in the library any more",
"invite": "This invite doesn't exist any more",
"user": "This user doesn't exist any more",
"channel": "This channel isn't in the library any more",
"subscription": "You're not subscribed to this channel any more",
"recipient": "There's no user with that email address",
"job": "This job doesn't exist any more",
"download": "This download doesn't exist any more",
"debug_session": "This debug session doesn't exist any more",
"screenshot": "This screenshot doesn't exist any more",
"link": "This share link doesn't exist any more",
"notification": "This notification doesn't exist any more",
"account": "That account no longer exists",
"collection": "This collection doesn't exist any more",
"config_key": "There's no such setting",
"file": "The file is no longer available",
"filmstrip": "There's no filmstrip for this",
"host": "This host isn't on the list any more",
"image": "There's no image for this",
"item": "This item doesn't exist any more",
"library": "This library doesn't exist any more",
"plex_item": "This Plex item isn't in the library any more",
"poster": "There's no poster for this",
"profile": "This download profile doesn't exist any more",
"season": "This season isn't in the library any more",
"show": "This show isn't in the library any more"
},
"admin": {
"not_allowed": "Their address isn't approved any more. Add it to the whitelist first.",
"not_applicable": "This account has nothing left to confirm.",
"not_approved": "Only an approved address can be invited.",
"not_applicable": "This account has nothing left to confirm",
"not_approved": "Only an approved address can be invited",
"email_off": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email / SMTP.",
"send_failed": "The mail couldn't be sent: the mail server was unreachable, or it refused the login or the message. Check Configuration → Email / SMTP, then try again.",
"demo": "The demo account is shared, so it can't get a set-password link."
"demo": "The demo account is shared, so it can't get a set-password link"
},
"reason": {
"unreachable": "The server couldn't be reached. Try again.",
"forbidden": "You don't have permission to do that.",
"rateLimited": "Too many requests at once — wait a moment and try again.",
"forbidden": "You don't have permission to do that",
"rateLimited": "Too many requests at once — wait a moment and try again",
"unexpected": "Something went wrong in the app. Try again."
},
"writeFailed": {
@@ -70,12 +70,12 @@
"delete": "Couldn't delete the download",
"redownload": "Couldn't start the download again"
},
"removeShared": "Couldn't remove the shared item",
"removeShared": "Couldn't remove the shared download",
"downloadDetails": "Couldn't save the download's details",
"enqueueDownload": "Couldn't queue the download",
"downloadProfileSave": "Couldn't save the download profile",
"downloadProfileDelete": "Couldn't delete the download profile",
"downloadCookiesRemove": "Couldn't remove those cookies",
"downloadCookiesRemove": "Couldn't delete those cookies",
"notifRead": "Couldn't mark the notification as read",
"notifReadAll": "Couldn't mark the notifications as read",
"notifDismiss": "Couldn't dismiss the notification",
@@ -106,8 +106,8 @@
"body": "Couldn't reach the server — it may be restarting. This notice will clear once the connection is back."
},
"boundary": {
"title": "Something went wrong.",
"subtitle": "The app hit an unexpected error.",
"title": "Something went wrong",
"subtitle": "The app hit an unexpected error",
"reload": "Reload",
"notifTitle": "Something broke",
"notifMessage": "Unexpected error"
@@ -118,111 +118,111 @@
"unavailable": "YouTube didn't accept that just now. Please try again."
},
"refused": {
"account_no_access": "That account no longer has access.",
"account_signed_out": "That account was signed out elsewhere — sign in again.",
"account_suspended": "That account is suspended.",
"address_has_account": "That address already has an account — delete the account instead.",
"admin_first": "Create the admin account first.",
"admin_only": "Only an admin can do that.",
"already_set_up": "This instance is already set up.",
"already_subscribed": "You're already subscribed to this channel.",
"channel_blocked": "You've blocked this channel.",
"collection_fields_required": "A title and an item are both needed.",
"collection_pending_sync": "The collection changed in Plex, but refreshing the local copy failed — it catches up on the next sync.",
"collection_read_only": "This collection is read-only.",
"collection_smart": "Smart and auto-list collections can't be made editable.",
"demo_activate": "The demo account can't be activated.",
"demo_delete": "The demo account can't be deleted.",
"demo_role": "The demo account's role can't be changed.",
"demo_suspend": "The demo account can't be suspended.",
"download_not_ready": "This download isn't ready.",
"edit_unfinished": "Only a finished download can be edited.",
"filters_not_object": "The view's filters are malformed.",
"account_no_access": "That account no longer has access",
"account_signed_out": "That account was signed out elsewhere — sign in again",
"account_suspended": "That account is suspended",
"address_has_account": "That address already has an account — delete the account instead",
"admin_first": "Create the admin account first",
"admin_only": "Only an admin can do that",
"already_set_up": "This instance is already set up",
"already_subscribed": "You're already subscribed to this channel",
"channel_blocked": "You've blocked this channel",
"collection_fields_required": "A title and an item are both needed",
"collection_pending_sync": "The collection changed in Plex, but refreshing the local copy failed — it catches up on the next sync",
"collection_read_only": "This collection is read-only",
"collection_smart": "Smart and auto-list collections can't be made editable",
"demo_activate": "The demo account can't be activated",
"demo_delete": "The demo account can't be deleted",
"demo_role": "The demo account's role can't be changed",
"demo_suspend": "The demo account can't be suspended",
"download_not_ready": "This download isn't ready",
"edit_unfinished": "Only a finished download can be edited",
"filters_not_object": "The view's filters are malformed",
"google_no_userinfo": "Google didn't send back the account details. Try signing in again.",
"image_fetch_failed": "The image couldn't be loaded.",
"image_host": "Images from that address aren't loaded.",
"invalid_cursor": "The list position is out of date — reload the page.",
"invalid_email": "Enter a valid email address.",
"invalid_interval": "The interval must be between {{min}} and {{max}} minutes.",
"invalid_link": "That doesn't look like a valid video link.",
"invalid_role": "Unknown role.",
"invalid_status": "Unknown status.",
"invite_decided": "This request has already been decided.",
"job_running": "This job is already running.",
"last_active_admin": "This is the only active admin — make someone else admin first.",
"last_admin": "This is the only admin — make someone else admin first.",
"link_gone": "This link is no longer available.",
"link_required": "A video link or id is required.",
"media_missing": "The media file isn't on disk.",
"message_empty": "The message is empty.",
"message_rate": "Too many messages — slow down a moment.",
"message_self": "You can't message yourself.",
"message_too_long": "The message is too long.",
"messaging_set_up": "Secure messaging is already set up.",
"name_required": "A name is required.",
"no_encryption_key": "Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
"no_messaging": "That member hasn't set up messaging yet.",
"no_profile": "There's no download profile to use.",
"no_subscription_id": "There's no YouTube subscription on record yet — sync your subscriptions first.",
"not_authenticated": "You're not signed in.",
"not_ready": "Not available yet.",
"not_redownloadable": "This item can't be downloaded again.",
"not_your_account": "That's not an account you've signed into here.",
"only_admin_delete": "You're the only admin — make someone else admin before deleting your account.",
"own_delete": "Delete your own account in Settings → Account.",
"own_role": "You can't change your own role.",
"own_suspend": "You can't suspend your own account.",
"plex_disabled": "The Plex module is turned off.",
"plex_not_configured": "Plex isn't set up — add the server address and token in Configuration → Plex.",
"image_fetch_failed": "Couldn't load the image",
"image_host": "Images from that address aren't loaded",
"invalid_cursor": "The list position is out of date — reload the page",
"invalid_email": "Enter a valid email address",
"invalid_interval": "The interval must be between {{min}} and {{max}} minutes",
"invalid_link": "That doesn't look like a valid video link",
"invalid_role": "Unknown role",
"invalid_status": "Unknown status",
"invite_decided": "This request has already been decided",
"job_running": "This job is already running",
"last_active_admin": "This is the only active admin — make someone else admin first",
"last_admin": "This is the only admin — make someone else admin first",
"link_gone": "This link is no longer available",
"link_required": "A video link or id is required",
"media_missing": "The media file isn't on disk",
"message_empty": "The message is empty",
"message_rate": "Too many messages — slow down a moment",
"message_self": "You can't message yourself",
"message_too_long": "The message is too long",
"messaging_set_up": "Secure messaging is already set up",
"name_required": "A name is required",
"no_encryption_key": "Set TOKEN_ENCRYPTION_KEY to store secrets in the database",
"no_messaging": "That member hasn't set up messaging yet",
"no_profile": "There's no download profile to use",
"no_subscription_id": "There's no YouTube subscription on record yet — sync your subscriptions first",
"not_authenticated": "You're not signed in",
"not_ready": "Not available yet",
"not_redownloadable": "This download can't be downloaded again",
"not_your_account": "That's not an account you've signed into here",
"only_admin_delete": "You're the only admin — make someone else admin before deleting your account",
"own_delete": "Delete your own account in Settings → Account",
"own_role": "You can't change your own role",
"own_suspend": "You can't suspend your own account",
"plex_disabled": "The Plex module is turned off",
"plex_not_configured": "Plex isn't set up — add the server address and token in Configuration → Plex",
"plex_unreachable": "The Plex server didn't answer. Check the address and the token, then try again.",
"plex_write_failed": "Plex didn't accept the change. Try again.",
"profile_name_required": "A profile name is required.",
"quota_reserve": "Today's shared YouTube budget is down to its reserve — try again later.",
"profile_name_required": "A profile name is required",
"quota_reserve": "Today's shared YouTube budget is down to its reserve — try again later",
"quota_spent": "Today's shared YouTube budget is spent. Try again tomorrow.",
"recipient_required": "A recipient email is required.",
"registration_closed": "Registration is currently closed.",
"recipient_required": "A recipient email is required",
"registration_closed": "Registration is currently closed",
"search_daily_limit": "You've reached today's YouTube search limit ({{limit}}). Try again tomorrow.",
"search_term_required": "Enter something to search for.",
"session_gone": "The playback session ended.",
"setting_not_a_choice": "Pick one of: {{choices}}.",
"setting_not_a_number": "Enter a whole number.",
"setting_too_high": "The value can be at most {{max}}.",
"setting_too_low": "The value must be at least {{min}}.",
"setup_token": "The setup token is missing or wrong.",
"share_own": "That's already your download.",
"share_password_required": "This link needs a password.",
"share_unfinished": "Only a finished download can be shared.",
"smtp_failed": "The email couldn't be sent — check the SMTP settings.",
"smtp_off": "SMTP isn't configured.",
"spec_not_object": "The profile's settings are malformed.",
"still_downloading": "This download is still in progress.",
"tag_name_taken": "You already have a tag named “{{name}}”.",
"title_required": "A title is required.",
"search_term_required": "Enter something to search for",
"session_gone": "The playback session ended",
"setting_not_a_choice": "Pick one of: {{choices}}",
"setting_not_a_number": "Enter a whole number",
"setting_too_high": "The value can be at most {{max}}",
"setting_too_low": "The value must be at least {{min}}",
"setup_token": "The setup token is missing or wrong",
"share_own": "That's already your download",
"share_password_required": "This link needs a password",
"share_unfinished": "Only a finished download can be shared",
"smtp_failed": "The email couldn't be sent — check the SMTP settings",
"smtp_off": "SMTP isn't configured",
"spec_not_object": "The profile's settings are malformed",
"still_downloading": "This download is still in progress",
"tag_name_taken": "You already have a tag named “{{name}}”",
"title_required": "A title is required",
"too_many_attempts": "Too many attempts. Try again shortly.",
"unknown_setting": "Unknown setting: {{key}}",
"value_missing": "A value is required.",
"video_gone": "This video is no longer available.",
"view_name_taken": "You already have a view named “{{name}}”.",
"watch_sync_off": "Turn on Plex watch-sync first.",
"weak_password": "The password must be at least {{min}} characters.",
"wrong_password": "The current password is incorrect.",
"wrong_share_password": "Wrong password.",
"youtube_editing_off": "Turn on YouTube editing in Settings first.",
"youtube_read_scope": "Connect your YouTube account (read access) first.",
"value_missing": "A value is required",
"video_gone": "This video is no longer available",
"view_name_taken": "You already have a view named “{{name}}”",
"watch_sync_off": "Turn on Plex watch-sync first",
"weak_password": "The password must be at least {{min}} characters",
"wrong_password": "The current password is incorrect",
"wrong_share_password": "Wrong password",
"youtube_editing_off": "Turn on YouTube editing in Settings first",
"youtube_read_scope": "Connect your YouTube account (read access) first",
"debug_session_conflict": "A recording just started in another tab. Reload the page.",
"issue_already_sent": "This report was already sent; its draft can no longer change.",
"issue_title_required": "Give the report a title before you send it.",
"screenshot_already_sent": "The report was already sent; this session takes no more images.",
"screenshot_too_many": "A session can have at most {{limit}} screenshots.",
"screenshot_empty": "The image is empty.",
"screenshot_not_an_image": "This is not a PNG, JPEG or WebP image.",
"issue_already_sent": "This report was already sent; its draft can no longer change",
"issue_title_required": "Give the report a title before you send it",
"screenshot_already_sent": "The report was already sent; this session takes no more images",
"screenshot_too_many": "A session can have at most {{limit}} screenshots",
"screenshot_empty": "The image is empty",
"screenshot_not_an_image": "This is not a PNG, JPEG or WebP image",
"issues_not_configured": "No issue tracker is set up. Download the report as a zip instead.",
"issues_unreachable": "The issue tracker isn't answering. Try again later.",
"issues_bad_token": "The issue tracker refused the token, or it lacks the permission.",
"issues_repo_not_found": "The configured issue repository was not found.",
"issues_bad_token": "The issue tracker refused the token, or it lacks the permission",
"issues_repo_not_found": "The configured issue repository was not found",
"issues_public_repo": "The issue repository is public. Because of the log, reports go only to a private one.",
"issues_disabled": "The issue repository has its issue tracker switched off.",
"issues_rejected": "The issue tracker refused the request.",
"issues_disabled": "The issue repository has its issue tracker switched off",
"issues_rejected": "The issue tracker refused the request",
"issues_rate_limited": "You can send at most {{limit}} reports an hour. Try again later.",
"debug_session_rate_limited": "You can start at most {{limit}} new recordings an hour. Try again later.",
"export_rate_limited": "Too many downloads in a row. Wait a minute.",
+7 -7
View File
@@ -12,16 +12,16 @@
"maxAge_other": "Last {{count}} days"
},
"loading": "Loading Feed…",
"loadError": "Couldn't load the Feed.",
"loadError": "Couldn't load the Feed",
"emptyTitle": "Your Feed is empty",
"emptyBody": "Connect your YouTube account to import your subscriptions and build your Feed.",
"setUp": "Set up my Feed",
"browseShared": "Or just browse the shared library",
"browseShared": "Or just browse the shared Library",
"demoEmptyTitle": "Nothing here in the demo",
"demoEmptyBody": "The demo account has no subscriptions of its own — but the whole shared library is yours to explore, filter and sort.",
"browseLibrary": "Browse the library",
"demoEmptyBody": "The demo account has no subscriptions of its own — but the whole shared Library is yours to explore, filter and sort.",
"browseLibrary": "Browse the Library",
"noMatches": "No videos match these filters.",
"channelScopeHint": "This channel isn't in your Feed — it may be hidden. Its videos are in the shared library.",
"channelScopeHint": "This channel isn't in your Feed — it may be hidden. Its videos are in the shared Library.",
"viewInLibrary": "View in Library",
"sortLabel": "Sort",
"viewLabel": "View",
@@ -70,7 +70,7 @@
"back": "Back to Feed",
"searching": "Searching YouTube…",
"noResults": "No YouTube videos found.",
"error": "YouTube search failed.",
"error": "Couldn't search YouTube",
"loadMore": "Load more (uses quota)",
"loadMoreFree": "Load more",
"show": "Show",
@@ -78,6 +78,6 @@
"clearNow": "Clear now",
"cleared_one": "Cleared {{count}} result",
"cleared_other": "Cleared {{count}} results",
"showHint": "How many results to fetch at a time — Load more pulls another batch of this size."
"showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size."
}
}
+6 -6
View File
@@ -5,7 +5,7 @@
"searchYoutubeTip": "Search live on YouTube for this term (uses shared API quota). You can also press Enter.",
"go": "Go",
"goTip": "Search (or press Enter)",
"channelManager": "Channel manager",
"channelManager": "Channels",
"usageStats": "Usage & stats",
"scheduler": "Scheduler",
"configuration": "Configuration",
@@ -15,7 +15,7 @@
"my": "Mine",
"myTip": "Only videos from your own subscriptions",
"all": "Library",
"allTip": "Every video in the shared catalog"
"allTip": "Every video in the shared Library"
},
"account": {
"admin": "Admin",
@@ -51,14 +51,14 @@
"resume": "Resume sync"
},
"banner": {
"updated": "Updated to v{{version}} — see what's changed.",
"updated": "Updated to v{{version}} — see what's changed",
"releaseNotes": "Release notes",
"dismiss": "Dismiss"
},
"verify": {
"confirmed": "Email address confirmed.",
"confirmedNamed": "Email address confirmed — {{email}}.",
"invalid": "That verification link is no longer valid — it has expired or was already used.",
"confirmed": "Email address confirmed",
"confirmedNamed": "Email address confirmed — {{email}}",
"invalid": "That verification link is no longer valid — it has expired or was already used",
"unreachable": "Couldn't reach the server to confirm that link, so it hasn't been used up. Try opening it again in a moment.",
"resetSignedOut": "Password-reset links open in a signed-out window. Sign out here, or open the link in a private window, and it will pick up where it left off.",
"reset": "The password this account registered with was cleared for security; set a new one before signing in."
+7 -7
View File
@@ -1,8 +1,8 @@
{
"navLabel": "Home",
"title": "What should I watch?",
"error": "Could not load your suggestions.",
"actionFailed": "Could not save that.",
"error": "Couldn't load your suggestions",
"actionFailed": "Couldn't save that",
"budget": {
"ask": "How much time do you have?",
"short": "10 minutes",
@@ -38,7 +38,7 @@
"moveLeft": "Left",
"moveRight": "Right",
"moveLast": "To the end",
"moveFailed": "Could not reorder the shelf.",
"moveFailed": "Couldn't reorder the shelf",
"moveSaving": "Saving the new order…",
"openPlaylist": "Open the playlist"
},
@@ -46,9 +46,9 @@
"menu": "Comfort shelf",
"add": "Pin to comfort shelf",
"remove": "Remove from shelf",
"added": "{{title}} is on your comfort shelf.",
"removed": "{{title}} is off your comfort shelf.",
"addFailed": "Could not pin it to the shelf.",
"removeFailed": "Could not remove it from the shelf."
"added": "{{title}} is on your comfort shelf",
"removed": "{{title}} is off your comfort shelf",
"addFailed": "Couldn't pin it to the shelf",
"removeFailed": "Couldn't remove it from the shelf"
}
}
+4 -4
View File
@@ -8,13 +8,13 @@
"markRead": "Mark read",
"dismiss": "Dismiss",
"rowMenu": "Notification actions",
"menu": "Notification center actions",
"menu": "Notifications actions",
"sectionSystem": "System",
"sectionActivity": "Activity",
"andMore": "and {{count}} more",
"openScheduler": "Open Scheduler",
"openDownloads": "Open Downloads",
"revertFailed": "Couldn't undo that — please try again.",
"revertFailed": "Couldn't undo that — please try again",
"downloadDone": {
"title": "Download ready"
},
@@ -28,11 +28,11 @@
},
"jobDone": {
"titleOk": "{{job}} finished",
"titleError": "{{job}} failed"
"titleError": "Couldn't run {{job}}"
},
"emailUndelivered": {
"title": "Email is not going out",
"notConfigured": "SMTP is not configured, so nothing this instance sends is being delivered — verification links, password resets and access requests alike. One of them was “{{subject}}”. Set it up under Configuration → Email / SMTP.",
"sendFailed": "Sending failed, so messages are not being delivered. One of them was “{{subject}}”. Check Configuration → Email / SMTP and send a test; the server log has the reason."
"sendFailed": "Couldn't send email, so messages are not being delivered. One of them was “{{subject}}”. Check Configuration → Email / SMTP and send a test; the server log has the reason."
}
}
+4 -4
View File
@@ -5,7 +5,7 @@
"minimize": "Minimize",
"close": "Close",
"conversations": "Conversations",
"new": "New message",
"new": "New message…",
"newTitle": "New message",
"empty": "No conversations yet. Start one with the New message button.",
"searchPeople": "Search people…",
@@ -27,9 +27,9 @@
"unlock": "Unlock",
"passphrase": "Passphrase",
"passphraseConfirm": "Confirm passphrase",
"passTooShort": "Use at least 6 characters.",
"passMismatch": "The passphrases don't match.",
"wrongPass": "Wrong passphrase.",
"passTooShort": "Use at least 6 characters",
"passMismatch": "The passphrases don't match",
"wrongPass": "Wrong passphrase",
"setupFailed": "Couldn't set up secure messaging. Please try again.",
"forgotPass": "Forgot your passphrase?",
"resetTitle": "Reset secure messaging",
+1 -1
View File
@@ -12,7 +12,7 @@
"channels": "Channels",
"playlists": "Playlists",
"plex": "Plex",
"notifications": "Alerts",
"notifications": "Notifications",
"messages": "Messages",
"downloads": "Downloads",
"stats": "Stats",
@@ -4,6 +4,6 @@
"findInFeed": "Find in Feed",
"unhide": "Unhide",
"unwatch": "Unwatch",
"openInManager": "Channel manager",
"openInManager": "Open in Channels",
"openOnYouTube": "Open on YouTube"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"close": "Close",
"consent": "Google will show a <0>\"Google hasn't verified this app\"</0> screen — that's expected, because Siftlode hasn't gone through Google's full review. It's safe to continue: click <1>Advanced</1> → <2>Go to Siftlode</2>. You can revoke access anytime from your <3>Google Account</3>.",
"consent": "Google will show a <0>“Google hasn't verified this app”</0> screen — that's expected, because Siftlode hasn't gone through Google's full review. It's safe to continue: click <1>Advanced</1> → <2>Go to Siftlode</2>. You can revoke access anytime from your <3>Google Account</3>.",
"read": {
"title": "Connect your YouTube subscriptions",
"body": "You're signed in. To build your Feed, Siftlode needs <0>read-only</0> access to the channels you're subscribed to on YouTube. It never posts, deletes, or changes anything with this.",
@@ -13,7 +13,7 @@
},
"write": {
"title": "You're connected",
"importFailed": "Your YouTube account is connected, but importing subscriptions failed — you can retry from Settings → Sync. ",
"importFailed": "Your YouTube account is connected, but we couldn't import your subscriptions — you can retry from Settings → Sync. ",
"feedReady": "Your Feed is ready{{channels}}. Optionally, let Siftlode ",
"feedReadyChannels": " ({{count}} channels)",
"rationale": "<0>unsubscribe</0> from channels on YouTube for you — this needs an extra write permission. Skip it to stay read-only; you can always enable it later in Settings.",
+30 -29
View File
@@ -19,7 +19,8 @@
"delete": "Delete",
"rename": "Rename",
"confirmDelete": "Delete the playlist “{{name}}”? This can't be undone.",
"addToPlaylist": "Add to playlist",
"addToPlaylist": "Add to playlist…",
"addToPlaylistTitle": "Add to playlist",
"exportToYoutube": "Export to YouTube",
"pushToYoutube": "Sync to YouTube",
"unsynced": "Unsynced",
@@ -29,8 +30,8 @@
"revertTitle": "Discard local changes?",
"revertMsg": "This reloads the playlist from YouTube and discards your unsynced local changes (order, additions, removals, rename). Continue?",
"revertConfirm": "Discard & reload",
"revertDone": "“{{name}}” reloaded from YouTube ✓",
"revertFailed": "Couldn't reload from YouTube.",
"revertDone": "“{{name}}” synced from YouTube",
"revertFailed": "Couldn't sync from YouTube",
"pushTitle": "Sync to YouTube",
"pushConfirm": "Sync",
"pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)",
@@ -50,19 +51,19 @@
"mergeQuota": " (~{{units}} quota units; {{left}} left today.)",
"mergeQuotaFree": " (~0 quota units.)",
"pushNoQuota": "Not enough YouTube quota left today ({{left}}) for this sync (~{{units}} needed). Try again tomorrow.",
"pushUpToDate": "“{{name}}” is already in sync with YouTube.",
"pushDone": "“{{name}}” synced to YouTube ✓",
"pushPartial": "Synced, but {{count}} item(s) were skipped.",
"pushFailed": "Couldn't sync to YouTube.",
"pushUpToDate": "“{{name}}” is already in sync with YouTube",
"pushDone": "“{{name}}” synced to YouTube",
"pushPartial": "Synced, but {{count}} video(s) were skipped",
"pushFailed": "Couldn't sync to YouTube",
"deleteHereOnly": "Delete here only",
"deleteYtFailed": "Couldn't delete on YouTube; removed here only.",
"deleteYtFailed": "Couldn't delete on YouTube; deleted here only",
"sortLabel": "Sort",
"dirAsc": "Ascending",
"dirDesc": "Descending",
"groupByChannel": "Group by channel",
"railSortCustom": "Custom order",
"railSortName": "Name",
"railSortCount": "Item count",
"railSortCount": "Video count",
"railSortDuration": "Total length",
"railSortRecent": "Recently changed",
"dirtyFirst": "Unsynced first",
@@ -72,27 +73,27 @@
"rowMenu": "Video actions",
"moveTo": "Move to another list",
"moveToRecent": "Move to “{{name}}”",
"moveNoTargets": "There is no other playlist to move it to yet.",
"movedToast": "“{{title}}” moved to “{{name}}” ✓",
"moveFailed": "Couldn't move the video.",
"moveNoTargets": "There is no other playlist to move it to yet",
"movedToast": "“{{title}}” moved to “{{name}}”",
"moveFailed": "Couldn't move the video",
"removedToast": "“{{title}}” removed from the playlist",
"undoRemoveFailed": "Couldn't put the video back.",
"removeFailed": "Couldn't remove the video.",
"undoRemoveFailed": "Couldn't put the video back",
"removeFailed": "Couldn't remove the video",
"errors": {
"name_required": "Give the playlist a name.",
"youtube_read_scope": "Connect YouTube (read access) to sync your playlists.",
"not_on_youtube": "This playlist isn't on YouTube.",
"watch_later_not_pushable": "The Watch later list can't be sent to YouTube.",
"watch_later_undeletable": "The Watch later list can't be deleted.",
"youtube_editing_off": "Enable YouTube editing in Settings first.",
"quota_exceeded": "Not enough YouTube quota left today for this sync."
"name_required": "Give the playlist a name",
"youtube_read_scope": "Connect YouTube (read access) to sync your playlists",
"not_on_youtube": "This playlist isn't on YouTube",
"watch_later_not_pushable": "The Watch later list can't be sent to YouTube",
"watch_later_undeletable": "The Watch later list can't be deleted",
"youtube_editing_off": "Enable YouTube editing in Settings first",
"quota_exceeded": "Not enough YouTube quota left today for this sync"
},
"moveErrors": {
"not_in_playlist": "That video isn't in this playlist any more — it changed somewhere else.",
"same_playlist": "That video is already in this playlist."
"not_in_playlist": "That video isn't in this playlist any more — it changed somewhere else",
"same_playlist": "That video is already in this playlist"
},
"railMenu": "Playlist actions",
"builtinLocked": "The built-in {{name}} list can’t be renamed or deleted.",
"builtinLocked": "The built-in “{{name}}” list can’t be renamed or deleted.",
"removeItem": "Remove",
"removeItemOf": "Remove “{{title}}” from the playlist",
"dragHandleOf": "Reorder “{{title}}”",
@@ -102,10 +103,10 @@
"saveOrder": "Save this order",
"filteredCount": "{{count}} shown",
"noneMatchFilter": "No videos match the filter.",
"loadFailed": "Couldn't load this playlist.",
"reorderFailed": "Couldn't save the new order.",
"renameFailed": "Couldn't rename the playlist.",
"deleteFailed": "Couldn't delete the playlist.",
"loadFailed": "Couldn't load this playlist",
"reorderFailed": "Couldn't save the new order",
"renameFailed": "Couldn't rename the playlist",
"deleteFailed": "Couldn't delete the playlist",
"confirmDeleteLinked": "Delete the playlist “{{name}}”? It is synced with YouTube — choose whether to delete it there as well. This can't be undone.",
"dragBlockedBuiltin": "This list's order is fixed — it can't be reordered by hand.",
"deleteBoth": "Delete here and on YouTube",
@@ -114,7 +115,7 @@
"viewDiffers": "Temporary view ({{view}}) — the playlist's saved order is unchanged. Save it to keep this order.",
"viewMatches": "This view ({{view}}) already matches the playlist's saved order, so there is nothing to save.",
"viewFiltered": "Filtered view — {{shown}} of {{total}} shown. The order can't be saved while a filter is on.",
"addFailed": "Couldn't add it to the playlist.",
"addFailed": "Couldn't add it to the playlist",
"limits": {
"hint": "Missing a list?",
"why": "Why?",
+34 -33
View File
@@ -5,11 +5,11 @@
"playAll": "Play all",
"shuffle": "Shuffle",
"rename": "Rename",
"addItem": "Add title",
"addItem": "Add title…",
"delete": "Delete",
"enableEdit": "Enable editing",
"removeItem": "Remove from collection",
"removeConfirm": "Remove \"{{title}}\" from this collection? This affects all users on Plex.",
"removeConfirm": "Remove “{{title}}” from this collection? This affects all users on Plex.",
"nameMaybeTaken": "A collection with this name may already exist in another library — you can still create it here.",
"readOnly": "Read-only",
"empty": "This collection has no items.",
@@ -20,16 +20,17 @@
"addNoResults": "No matches.",
"add": "Add",
"added": "Added",
"deleteConfirm": "Delete the collection \"{{title}}\"? This removes it from Plex too.",
"editFailed": "Collection edit failed",
"playFailed": "Couldn't build the play queue — try again.",
"playEmpty": "Nothing to play in this collection yet.",
"newCollection": "New collection",
"deleteConfirm": "Delete the collection “{{title}}”? This removes it from Plex too.",
"editFailed": "Couldn't edit the collection",
"playFailed": "Couldn't build the play queue — try again",
"playEmpty": "Nothing to play in this collection yet",
"newCollection": "New collection…",
"newCollectionTitle": "New collection",
"namePlaceholder": "Collection name",
"nameTaken": "A collection with this name already exists.",
"nameTaken": "A collection with this name already exists",
"newHint": "Pick the first title to add — a collection needs at least one.",
"createWith": "Create",
"deleteFailed": "Couldn't delete the collection."
"deleteFailed": "Couldn't delete the collection"
},
"searchPlaceholder": "Search Plex…",
"searchField": {
@@ -78,13 +79,13 @@
"duration": "Length",
"durationUnit": "min",
"durationOpt": {
"short": "Under 90m",
"mid": "90–120m",
"long": "Over 2h"
"short": "Under 90 min",
"mid": "90–120 min",
"long": "Over 2 hr"
},
"added": "Added to Plex",
"addedOpt": {
"24h": "24h",
"24h": "24 hours",
"1w": "1 week",
"1m": "1 month",
"6m": "6 months",
@@ -128,7 +129,7 @@
"markNextWatched": "Mark S{{season}}·E{{episode}} watched",
"markNextUnwatched": "Mark S{{season}}·E{{episode}} unwatched",
"nextUp": "Next: S{{season}}·E{{episode}}",
"watchedFailed": "Couldn't update the watched state — please try again.",
"watchedFailed": "Couldn't update the watched state — please try again",
"seasons": "{{count}} seasons",
"unified": {
"titles": "Titles",
@@ -157,9 +158,9 @@
"none": "No editable collections yet. Create one above.",
"count": "{{count}} items",
"delete": "Delete",
"deleteConfirm": "Delete the collection \"{{title}}\" from Plex? This affects all users.",
"deleteConfirm": "Delete the collection “{{title}}” from Plex? This affects all users.",
"note": "Changes are written to Plex and shared across all clients and users.",
"others": "Manage {{count}} existing collections…",
"others": "Manage {{count}} existing collections",
"makeEditable": "Manage"
},
"player": {
@@ -174,8 +175,8 @@
"restart": "Restart from beginning",
"prev": "Previous episode",
"next": "Next episode",
"tapBack": "−{{n}} s",
"tapForward": "+{{n}} s",
"tapBack": "−{{d}}",
"tapForward": "+{{d}}",
"mute": "Mute (m)",
"volume": "Volume",
"fullscreen": "Fullscreen (f)",
@@ -222,9 +223,9 @@
"subStyle": "Subtitles",
"subPreparing": "Preparing subtitles: {{label}}…",
"subUnavailable": {
"bitmap": "This subtitle is image-based and can't be shown as text.",
"missing": "This subtitle track isn't available.",
"failed": "This subtitle couldn't be loaded."
"bitmap": "This subtitle is image-based and can't be shown as text",
"missing": "This subtitle track isn't available",
"failed": "Couldn't load this subtitle"
},
"subSize": "Text size",
"subPos": "Position",
@@ -274,7 +275,7 @@
"audience": "Audience",
"keywords": "Keywords",
"crew": "Crew",
"filterValue": "Filter by \"{{name}}\"",
"filterValue": "Filter by “{{name}}”",
"facts": {
"title": "Details",
"release": "Released",
@@ -294,23 +295,23 @@
"transcode_sw": "Transcoded in software (heaviest)"
},
"playlistAdd": {
"manage": "Add to playlist",
"manage": "Add to playlist…",
"title": "Add to playlist — {{title}}",
"newPlaceholder": "New playlist name…",
"create": "Create",
"none": "No playlists yet. Create one above.",
"count": "{{count}} items",
"groupProgress": "{{in}} / {{size}}",
"addFailed": "Couldn't add it to the playlist.",
"removeFailed": "Couldn't remove it from the playlist.",
"createFailed": "Couldn't create the playlist."
"addFailed": "Couldn't add it to the playlist",
"removeFailed": "Couldn't remove it from the playlist",
"createFailed": "Couldn't create the playlist"
},
"playlist": {
"section": "Playlists",
"pickerPlaceholder": "Find or create a playlist…",
"createNamed": "Create \"{{name}}\"",
"createNamed": "Create “{{name}}”",
"none": "No playlists yet.",
"deleteConfirm": "Delete the playlist \"{{title}}\"?",
"deleteConfirm": "Delete the playlist “{{title}}”?",
"rename": "Rename",
"playAll": "Play all",
"delete": "Delete playlist",
@@ -326,10 +327,10 @@
"addShow": "Add whole show to a playlist",
"addSeason": "Add season to a playlist",
"addEpisode": "Add episode to a playlist",
"removeFailed": "Couldn't remove it from the playlist.",
"renameFailed": "Couldn't rename the playlist.",
"deleteFailed": "Couldn't delete the playlist.",
"reorderFailed": "Couldn't save the new order.",
"removeFailed": "Couldn't remove it from the playlist",
"renameFailed": "Couldn't rename the playlist",
"deleteFailed": "Couldn't delete the playlist",
"reorderFailed": "Couldn't save the new order",
"reorderItem": "Move “{{title}}”",
"rowMenu": "Item actions",
"menu": "Playlist actions",
@@ -337,7 +338,7 @@
"playSeason": "Play the season",
"removeFromPlaylist": "Remove from the playlist",
"removedToast": "“{{title}}” removed from the playlist",
"undoRemoveFailed": "Couldn't put it back in the playlist."
"undoRemoveFailed": "Couldn't put it back in the playlist"
},
"people": {
"count_one": "{{count}} title",
@@ -1,7 +1,7 @@
{
"subscriptions_pull": "Read subscriptions",
"subscriptions_pull": "Sync subscriptions",
"subscriptions_resync": "Auto subscription resync",
"playlists_pull": "Read playlists",
"playlists_pull": "Sync playlists",
"playlists_push": "Push playlists",
"playlists_delete": "Delete playlist",
"videos_backfill_recent": "Recent backfill",
+9 -9
View File
@@ -29,8 +29,8 @@
"shotFailed": "Couldn't save the screenshot",
"stopFailed": "Couldn't stop recording",
"stopped": "Recording stopped",
"expired_one": "The recording reached its {{count}}-minute limit and stopped.",
"expired_other": "The recording reached its {{count}}-minute limit and stopped."
"expired_one": "The recording reached its {{count}}-minute limit and stopped",
"expired_other": "The recording reached its {{count}}-minute limit and stopped"
},
"settings": {
"title": "Debug session",
@@ -46,7 +46,7 @@
},
"composer": {
"title": "Bug report",
"meta": "{{start}} · {{minutes}} min · {{level}}",
"meta": "{{start}} · {{minutes}} · {{level}}",
"fieldTitle": "Title",
"titlePlaceholder": "What went wrong?",
"description": "Description",
@@ -61,7 +61,7 @@
"stepPlaceholder": "What did you do?",
"withShot": "image",
"addStep": "Add a step",
"removeStep": "Remove step {{n}}",
"removeStep": "Delete step {{n}}",
"environment": "Environment — automatic",
"attachments": "Attachments — what is sent, each one removable",
"clientLog": "Browser log",
@@ -75,7 +75,7 @@
"reviewDone": "Done",
"keepRow": "Keep line: {{event}} {{msg}}",
"screenshot": "Screenshot {{n}}",
"removeShot": "Remove screenshot {{n}}",
"removeShot": "Delete screenshot {{n}}",
"drop": "Paste an image with Ctrl+V, drop it here, or",
"pick": "choose a file",
"close": "Close",
@@ -85,7 +85,7 @@
"saveFailed": "Couldn't save the draft",
"loadFailed": "Couldn't load the session",
"truncated": "The logs carry the first {{kept}} of {{total}} lines.",
"sent": "Sent as an issue.",
"sent": "Sent as an issue",
"openIssue": "Open the issue",
"env": {
"version": "Version",
@@ -99,7 +99,7 @@
"sentOk": "Issue created",
"sendFailed": "Couldn't send the report",
"target": "To: {{repo}} · private",
"partial": "The issue was created, but not every attachment went: {{files}}.",
"partial": "The issue was created, but not every attachment went: {{files}}",
"retry": "Send the missing ones",
"edit": "Edit",
"preview": "Preview",
@@ -107,8 +107,8 @@
"previewTitle": "Preview of the report",
"previewLocal": "Without an issue tracker, this is the zip's issue.md.",
"shotsFailed": "Couldn't load the screenshots",
"removeShotFailed": "Couldn't remove the screenshot",
"partialImages": "The issue was created, but its text does not show the screenshots yet.",
"removeShotFailed": "Couldn't delete the screenshot",
"partialImages": "The issue was created, but its text does not show the screenshots yet",
"retryImages": "Show them in the issue",
"sendingNow": "The report is being sent…",
"exportFailed": "Couldn't build the zip"
+5 -5
View File
@@ -24,7 +24,7 @@
"quotaRemaining": "{{formatted}} left",
"quotaNote": "Shared YouTube Data API budget; resets at midnight US Pacific. Backfill yields to leave headroom for interactive use.",
"minutes": "min",
"range": "Must be between {{min}} and {{max}}.",
"range": "Must be between {{min}} and {{max}}",
"editInterval": "Click to change how often this runs",
"intervalFailed": "Couldn't change the interval",
"runNow": "Run now",
@@ -85,8 +85,8 @@
"backfill": "Backfill (recent + full history)",
"autotag": "Auto-tagging",
"shorts": "Shorts classification",
"subscriptions": "Subscription resync",
"playlist_sync": "YouTube playlist sync",
"subscriptions": "Subscription sync from YouTube",
"playlist_sync": "Playlist sync from YouTube",
"maintenance": "Maintenance + validation",
"demo_reset": "Demo account reset",
"explore_cleanup": "Explored-channel cleanup",
@@ -111,7 +111,7 @@
"liveRefreshHint": "Live/upcoming videos (and just-ended streams without a duration yet) re-checked until they settle."
},
"purgeDiscovery": "Purge discovery",
"purgeDiscoveryHint": "Remove all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.",
"purgeDiscoveryHint": "Delete all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.",
"purgeConfirmTitle": "Purge discovery content?",
"purgedDiscovery": "Removed {{count}} discovery items"
"purgedDiscovery": "Deleted {{count}} discovery items"
}
+30 -32
View File
@@ -42,7 +42,7 @@
},
"notifications": {
"title": "Notifications",
"sound": "Sound on alerts",
"sound": "Sound on notifications",
"soundHint": "Play a short tone when a notification needs your attention (e.g. errors, prompts).",
"autoDismiss": "Auto-dismiss toasts",
"autoDismissHint": "When off, pop-up toasts stay until you close them. When on, they fade after the set time; one with a button (Undo) stays at least 8 seconds.",
@@ -65,7 +65,7 @@
"writeEnableHint": "Lets Siftlode unsubscribe from channels on YouTube for you. Optional — skip it to stay read-only.",
"granted": "Granted",
"enable": "Enable",
"enableHint": "Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.",
"enableHint": "Redirects to Google to grant the permission. Google shows an “unverified app” notice — that's expected; click Advanced → Go to Siftlode.",
"walkMeThrough": "Walk me through setup",
"dangerZone": "Danger zone",
"deleteHint": "Permanently delete your account and all your data — subscriptions, tags, watch history, playlists and settings. This can't be undone.",
@@ -73,7 +73,7 @@
"deleteTitle": "Delete your account?",
"deleteConfirm": "This permanently erases your account and all your data (subscriptions, tags, watch/saved/hidden state, playlists, settings). It can't be undone.",
"deleteConfirmButton": "Delete everything",
"demoNotice": "This is the shared demo account. It has no YouTube connection — you can browse the whole shared library, filter and sort it, build playlists and try things out, but nothing touches a real YouTube account.",
"demoNotice": "This is the shared demo account. It has no YouTube connection — you can browse the whole shared Library, filter and sort it, build playlists and try things out, but nothing touches a real YouTube account.",
"signInMethods": "Sign-in methods",
"googleLink": {
"title": "Google account",
@@ -81,13 +81,13 @@
"connectedHint": "Your Google account is linked. You can sign in with Google and grant YouTube access below.",
"connectHint": "Link a Google account to sign in with Google and to enable YouTube access for your Feed.",
"connect": "Connect Google",
"linked": "Google account linked.",
"conflict": "That Google account is already linked to a different Siftlode account.",
"linked": "Google account linked",
"conflict": "That Google account is already linked to a different Siftlode account",
"mismatch": "That's a different Google account than the one already linked here. Sign in with the linked one.",
"error": "Couldn't link the Google account. Please try again.",
"signedInAs": "Signed in with Google as"
},
"googleCancelled": "Google sign-in was cancelled.",
"googleCancelled": "Google sign-in was cancelled",
"password": {
"title": "Password",
"setHint": "A password is set. You can sign in with your email and password.",
@@ -98,8 +98,8 @@
"new": "New password (min. 10 characters)",
"save": "Save password",
"saving": "Saving…",
"saved": "Password updated for {{email}}.",
"failed": "Couldn't update the password."
"saved": "Password updated for {{email}}",
"failed": "Couldn't update the password"
},
"sessions": {
"title": "Active sessions",
@@ -107,7 +107,7 @@
"action": "Log out other sessions",
"confirmTitle": "Log out other sessions?",
"confirmBody": "This signs you out of every other browser and device. You'll stay signed in here.",
"done": "Signed out of all other sessions.",
"done": "Signed out of all other sessions",
"failed": "Couldn't sign out other sessions. Please try again."
}
},
@@ -116,21 +116,21 @@
"intro": "Some sites (e.g. a private Facebook group you belong to) only let logged-in members view a video. Paste your browser cookies for a site here and your downloads from it will use your login. Cookies are encrypted, kept private to your account, and only ever sent to the matching site.",
"configured": "Set · updated {{when}}",
"notSet": "Not set",
"set": "Set cookies",
"update": "Update",
"remove": "Remove",
"set": "Set cookies…",
"update": "Update…",
"remove": "Delete",
"save": "Save",
"cancel": "Cancel",
"saving": "Saving…",
"placeholder": "Paste the contents of your cookies.txt (Netscape format) here",
"help": "Export a cookies.txt for this site with a browser extension, then paste it here. It's write-only — we never show it back.",
"saved": "Cookies saved.",
"removed": "Cookies removed.",
"failed": "Couldn't save those cookies.",
"removeConfirmTitle": "Remove cookies?",
"saved": "Cookies saved",
"removed": "Cookies deleted",
"failed": "Couldn't save those cookies",
"removeConfirmTitle": "Delete cookies?",
"removeConfirmBody": "Downloads from this site will stop using your login.",
"removeConfirmAction": "Remove",
"loadError": "Couldn't load your download authentication settings."
"removeConfirmAction": "Delete",
"loadError": "Couldn't load your download authentication settings"
},
"demo": {
"title": "Demo access",
@@ -160,14 +160,14 @@
"add": "Add",
"decided": "{{count}} decided",
"approved": "Approved {{email}} — they can sign in now",
"approveFailed": "Approve failed",
"denyFailed": "Deny failed",
"approveFailed": "Couldn't approve the request",
"denyFailed": "Couldn't deny the request",
"addedToWhitelist": "{{email}} added to the whitelist",
"inviteHint": "Send them an invitation. Adding an address never mails anyone — this does.",
"invitedAt": "Invited {{time}}. Sending again will mail them a second time.",
"inviteLabel": "Send an invitation to {{email}}",
"invited": "Invitation sent to {{email}}.",
"inviteFailed": "Couldn't send that invitation.",
"invited": "Invitation sent to {{email}}",
"inviteFailed": "Couldn't send that invitation",
"inviteTitle": "Send invitation?",
"inviteConfirm": "Email {{email}} to say their address is whitelisted and Siftlode is open for them.",
"inviteConfirmAgain": "You already invited {{email}} on {{time}}. Sending now writes to them a second time.",
@@ -188,11 +188,11 @@
"deleteTitle": "Delete whitelist entry?",
"deleteConfirm": "Forget {{email}}? Siftlode drops the record, so anyone at that address would have to ask again.",
"deleteHasAccount": "Someone has registered on this address — manage it under Users & roles, where deleting the account removes this entry too.",
"deleted": "Whitelist entry removed.",
"deleteFailed": "Could not remove the entry.",
"emailOff": "Email sending is not configured, so nobody gets a verification link and no request reaches you by email. Watch this list and your inbox instead, and fix it under Configuration → Email / SMTP.",
"deletedStillAllowed": "Entry removed, but this address is still allow-listed by the server's configuration, so it keeps access. Remove it from ALLOWED_EMAILS / ADMIN_EMAILS to change that.",
"deleteConfirmStillAllowed": "Forget {{email}}? Careful: this address is also allow-listed in the server's configuration (ALLOWED_EMAILS / ADMIN_EMAILS), so removing this row will NOT take its access away — and an ADMIN_EMAILS address is made an admin again on its next Google sign-in."
"deleted": "Whitelist entry deleted",
"deleteFailed": "Couldn't delete the entry",
"emailOff": "Email sending is not configured, so nobody gets a verification link and no request reaches you by email. Watch this list and your Notifications instead, and fix it under Configuration → Email / SMTP.",
"deletedStillAllowed": "Entry deleted, but this address is still allow-listed by the server's configuration, so it keeps access. Remove it from ALLOWED_EMAILS / ADMIN_EMAILS to change that.",
"deleteConfirmStillAllowed": "Forget {{email}}? Careful: this address is also allow-listed in the server's configuration (ALLOWED_EMAILS / ADMIN_EMAILS), so deleting this row will NOT take its access away — and an ADMIN_EMAILS address is made an admin again on its next Google sign-in."
},
"diagnostics": {
"title": "Logging level",
@@ -215,15 +215,13 @@
"server_one": "The Siftlode server receives this too and keeps it for {{count}} day, so the problem can be tracked down.",
"server_other": "The Siftlode server receives this too and keeps it for {{count}} days, so the problem can be tracked down.",
"never": "Passwords, tokens, email addresses and the content of your messages are never included.",
"lapse": "After 24 hours it goes back to Basic by itself.",
"lapse": "After 24 hours it goes back to “Basic” by itself.",
"policy": "Privacy policy",
"cancel": "Cancel",
"confirm": "Turn on for 24 hours"
},
"status": "{{level}} logging · back to Basic in {{time}}",
"hoursMinutes": "{{h}} h {{m}} min",
"minutes": "{{m}} min",
"revert": "Back to Basic now",
"status": "{{level}} logging · back to “Basic” in {{time}}",
"revert": "Back to “Basic” now",
"demo": "The demo account is shared by several people, so detailed logging can't be turned on here.",
"failed": "Couldn't change the logging level"
},
+6 -6
View File
@@ -8,15 +8,15 @@
"desc": "This is the first account and an administrator. You'll sign in with this email and password once setup is done.",
"email": "Email address",
"password": "Password (min. {{n}} characters)",
"invalidEmail": "Enter a valid email address.",
"shortPassword": "Password must be at least {{n}} characters."
"invalidEmail": "Enter a valid email address",
"shortPassword": "Password must be at least {{n}} characters"
},
"google": {
"title": "Sign in with Google (optional)",
"desc": "Paste your Google OAuth client ID and secret to enable Google sign-in and YouTube access. Leave both empty to skip — email+password works without it.",
"clientId": "Google client ID",
"clientSecret": "Google client secret",
"bothRequired": "Enter both the client ID and secret, or leave both empty to skip.",
"bothRequired": "Enter both the client ID and secret, or leave both empty to skip",
"skipHint": "Leave empty to set this up later from the admin Configuration page."
},
"smtp": {
@@ -29,8 +29,8 @@
"password": "SMTP password",
"testTo": "Send a test email to…",
"test": "Send test",
"testOk": "Test email sent — check the inbox.",
"testFail": "Couldn't send — check the settings.",
"testOk": "Test email sent — check the inbox",
"testFail": "Couldn't send — check the settings",
"skipHint": "Leave empty to set this up later from the admin Configuration page."
},
"finish": {
@@ -46,5 +46,5 @@
"title": "Setup link needed",
"body": "Open the setup wizard using the link printed in the container logs at first start (it looks like …/setup?token=…). It's required so only the operator can configure this instance."
},
"genericError": "Something went wrong — please try again."
"genericError": "Something went wrong — please try again"
}
+3 -3
View File
@@ -36,7 +36,7 @@
"year": "Year"
},
"year": {
"more": "More…",
"more": "More",
"moreLabel": "All years",
"none": "No years yet",
"sortBy": "Sort",
@@ -54,11 +54,11 @@
"content": {
"normal": "Normal",
"shorts": "Shorts",
"live": "Live / Upcoming"
"live": "Live / upcoming"
},
"datePresetLabel": "Upload date",
"datePreset": {
"24h": "24h",
"24h": "24 hours",
"1week": "1 week",
"2weeks": "2 weeks",
"1month": "1 month",
+5 -5
View File
@@ -4,14 +4,14 @@
"system": "System"
},
"title": "API quota usage",
"rangeDays": "{{count}}d",
"rangeDays": "{{d}}",
"rangeLabel": "Time range",
"introBefore": "YouTube Data API units attributed by who triggered the spend. ",
"introSystem": "System",
"introAfter": " is shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.",
"loading": "Loading…",
"noData": "No data.",
"dailyTotal": "Daily total ({{count}}d · {{units}} units)",
"dailyTotal": "Daily total ({{d}} · {{units}} units)",
"noUsageInRange": "No usage in this range.",
"dailyTooltip": "{{day}}: {{units}} units",
"system": "System (background)",
@@ -36,15 +36,15 @@
"last7d": "Last 7 days",
"last30d": "Last 30 days",
"allTime": "All time",
"byAction": "By action (30d)",
"byAction": "By action (30 days)",
"noUsage": "No usage yet.",
"actions": "Actions",
"syncSubscriptions": "Sync subscriptions",
"syncSubscriptions": "Sync subscriptions from YouTube",
"syncSubscriptionsHint": "Re-import your YouTube subscriptions and pull in any newly-followed channels.",
"backfillEverything": "Backfill everything",
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.",
"synced": "Synced {{count}} subscriptions",
"syncFailed": "Sync failed",
"syncFailed": "Couldn't sync subscriptions",
"fullHistoryRequested": "Full history requested for {{count}} channels",
"fullHistoryFailed": "Couldn't request full history",
"admin": "Admin",
+2 -2
View File
@@ -9,6 +9,6 @@
"empty": "No tags yet — add one below.",
"newPlaceholder": "New tag name…",
"add": "Add",
"renameFailed": "Couldn't rename the tag.",
"deleteFailed": "Couldn't delete the tag."
"renameFailed": "Couldn't rename the tag",
"deleteFailed": "Couldn't delete the tag"
}
+9 -9
View File
@@ -13,12 +13,12 @@
"inactive": "waiting for activation",
"activate": "Activate",
"activateHint": "Let this account in. Their address is already approved — this only finishes it.",
"activated": "{{email}} can sign in now.",
"activateFailed": "Couldn't activate that account.",
"activated": "{{email}} can sign in now",
"activateFailed": "Couldn't activate that account",
"resend": "Send the confirmation link again",
"resendHint": "Send the confirmation link again, to the address on the account.",
"resent": "Confirmation link sent to {{email}}.",
"resendFailed": "Couldn't send the confirmation link."
"resent": "Confirmation link sent to {{email}}",
"resendFailed": "Couldn't send the confirmation link"
},
"roles": {
"title": "Users & roles",
@@ -42,7 +42,7 @@
"demoteTitle": "Remove admin?",
"promoteConfirm": "Give {{email}} full admin access (settings, scheduler, user management)?",
"demoteConfirm": "Remove admin access from {{email}}? They'll become a regular user.",
"failed": "Couldn't change the role."
"failed": "Couldn't change the role"
},
"suspend": {
"action": "Suspend",
@@ -55,7 +55,7 @@
"undone": "{{email}} unsuspended",
"demoLocked": "The demo account can't be suspended.",
"selfLocked": "You can't suspend your own account.",
"failed": "Couldn't change the suspension."
"failed": "Couldn't change the suspension"
},
"delete": {
"action": "Delete",
@@ -63,7 +63,7 @@
"title": "Delete account?",
"confirm": "Permanently delete {{email}} and all their data — subscriptions, tags, watch history, playlists and settings? This can't be undone.",
"done": "{{email}} deleted",
"failed": "Couldn't delete the account."
"failed": "Couldn't delete the account"
},
"account": {
"access": "Access",
@@ -75,7 +75,7 @@
"approvalWaitingOnYou": "waiting on you",
"copied": "Copied",
"copy": "Copy",
"copyFailed": "Couldn't copy — select the link and copy it by hand.",
"copyFailed": "Couldn't copy — select the link and copy it by hand",
"email": "Email",
"emailConfirmed": "confirmed",
"emailUnconfirmed": "not confirmed",
@@ -97,7 +97,7 @@
"passwordSet": "set",
"recovery": "Recovery",
"resend": "Send a confirmation link",
"resendSent": "Confirmation link sent.",
"resendSent": "Confirmation link sent",
"setPassword": "Create a set-password link",
"setPasswordAgain": "Create another link",
"linkFailed": "Couldn't create the link. Please try again."
+2 -2
View File
@@ -7,8 +7,8 @@
"delete": "Delete",
"deleteConfirm": "Delete the saved view “{{name}}”?",
"reorder": "Drag to reorder",
"actionFailed": "Couldn't update your saved views — please try again.",
"reorderFailed": "Couldn't save the new order — please try again.",
"actionFailed": "Couldn't update your saved views — please try again",
"reorderFailed": "Couldn't save the new order — please try again",
"saved": "Saved views",
"manage": "Manage views…",
"manageTitle": "Personal views",
+13 -13
View File
@@ -11,10 +11,10 @@
},
"preview": {
"feed": "The Feed — filter by channel, language, topic, length and date.",
"channels": "Channel manager",
"channels": "Channels",
"playlists": "Playlists",
"player": "In-app player",
"downloads": "Download Center",
"downloads": "Downloads",
"search": "Search results",
"plex": "Your Plex library"
},
@@ -41,7 +41,7 @@
"body": "A built-in player picks each video up where you left off, with keyboard and scroll controls."
},
"downloads": {
"title": "Download Center",
"title": "Downloads",
"body": "Save videos to your server in a Plex-friendly layout, trim or join them, then share a link."
},
"plex": {
@@ -61,8 +61,8 @@
"email": "Email",
"password": "Password",
"newPassword": "New password (min {{min}} characters)",
"invalidEmail": "Enter a valid email address.",
"weakPassword": "Password must be at least {{min}} characters.",
"invalidEmail": "Enter a valid email address",
"weakPassword": "Password must be at least {{min}} characters",
"genericError": "Something went wrong. Please try again.",
"working": "Please wait…",
"signinButton": "Sign in",
@@ -84,17 +84,17 @@
"demoDenied": "That email isn't enabled for the demo. Ask the admin to add it.",
"registerDone": "Almost there — check your email for a confirmation link, and your spam folder if it isn't there in a minute. After you confirm it, an admin approves your account before you can sign in.",
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox, and your spam folder if it hasn't arrived in a minute.",
"resetDone": "Your password has been set — you can sign in now.",
"resetDone": "Your password has been set — you can sign in now",
"deadLink": {
"title": "This link is no longer valid",
"body": "A password reset link lasts one hour and works once. This one has expired, has already been used, or belongs to an account that no longer exists — so there is nothing to set a password on. Ask for a fresh one and we'll email it.",
"short": "This reset link is no longer valid.",
"short": "This reset link is no longer valid",
"cta": "Request a new link"
},
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
"verifyOkReset": "Email confirmed. The password this account registered with had been cleared for security — choose a new one to sign in.",
"verifyOkResetCta": "Set a new password",
"verifyInvalid": "That verification link is invalid or has expired.",
"verifyInvalid": "That verification link is invalid or has expired",
"verifyUnreachable": "Couldn't reach the server to confirm that link, so it hasn't been used up. Try opening it again in a moment.",
"resendCta": "Send a new link",
"resendDone": "If that email has an unconfirmed account, we've sent a new confirmation link. Check your inbox, and your spam folder if it hasn't arrived in a minute.",
@@ -116,26 +116,26 @@
"email_unverified": {
"title": "Confirm your email address",
"body": "Your password is correct — we just can't let you in until the address on this account is confirmed. Open the link we emailed you, or ask for a new one.",
"short": "Confirm your email address first — check your inbox."
"short": "Confirm your email address first — check your inbox"
},
"pending_approval": {
"title": "Waiting for approval",
"body": "Your password is correct and your address is confirmed. An admin still has to let you in — we'll email you as soon as they decide.",
"short": "Your account is waiting for admin approval."
"short": "Your account is waiting for admin approval"
},
"suspended": {
"title": "Account suspended",
"body": "This account has been suspended, so it can't sign in by any method.",
"short": "This account has been suspended."
"short": "This account has been suspended"
},
"invalid": {
"short": "Invalid email or password."
"short": "Invalid email or password"
},
"rate_limited": {
"short": "Too many attempts. Try again shortly."
}
},
"accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access.",
"accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access",
"suspended": "This account has been suspended and can't sign in. If you think this is a mistake, contact the operator (check your email for details).",
"deleted": "Your account and all its data were permanently deleted. We've emailed you a confirmation."
}
+1 -1
View File
@@ -11,5 +11,5 @@
"fixes": "Javítások",
"olderVersions_one": "{{count}} korábbi verzió",
"olderVersions_other": "{{count}} korábbi verzió",
"notesError": "A verziójegyzéket nem sikerült betölteni."
"notesError": "Nem sikerült betölteni a verziójegyzéket"
}
+3 -3
View File
@@ -26,9 +26,9 @@
"showRequest": "Csak ennek a kérésnek a sorai",
"showJob": "Csak ennek a jobnak a sorai",
"empty": "Nincs a szűrésnek megfelelő sor.",
"stale": "Az utolsó frissítés nem sikerült, amit látsz, elavult lehet.",
"olderFailed": "Nem sikerült betölteni a régebbi sorokat.",
"countsFailed": "A szűrők számai nem töltődtek be.",
"stale": "Nem sikerült frissíteni — amit látsz, elavult lehet",
"olderFailed": "Nem sikerült betölteni a régebbi sorokat",
"countsFailed": "Nem sikerült betölteni a szűrők számait",
"count_one": "{{count}} sor betöltve · a legújabb felül",
"count_other": "{{count}} sor betöltve · a legújabb felül",
"older": "Régebbiek betöltése",
+1 -1
View File
@@ -7,7 +7,7 @@
"clearTitle": "Üríted a naplót?",
"clearConfirm": "Véglegesen törlöd az összes naplóbejegyzést? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)",
"cleared": "{{count}} naplóbejegyzés törölve",
"clearFailed": "A naplót nem sikerült üríteni.",
"clearFailed": "Nem sikerült üríteni a naplót",
"truncated": "A legutóbbi {{returned}} bejegyzés látszik a(z) {{total}}-ból.",
"cols": {
"when": "Mikor",
@@ -22,7 +22,7 @@
"job_run": "Ütemező job lefutott",
"audit_clear": "Napló ürítve",
"download_credential_set": "Letöltési cookie-k beállítva",
"download_credential_delete": "Letöltési cookie-k eltávolítva",
"download_credential_delete": "Letöltési cookie-k törölve",
"invite_delete": "Whitelista-bejegyzés törölve",
"invite_send": "Meghívó elküldve",
"issue_create": "Hibajegy elküldve"
+1 -1
View File
@@ -22,7 +22,7 @@
"menu": {
"label": "Videó",
"unmarkWatched": "Megnézés visszavonása",
"unsave": "Törlés a mentettek közül",
"unsave": "Eltávolítás a mentettek közül",
"more": "További műveletek",
"openChannel": "Csatorna megnyitása"
}
+1 -1
View File
@@ -36,5 +36,5 @@
"fullHistoryProgress": "Előzmény letöltése… {{stored}} / {{total}}",
"fullHistoryLoadingHint": "A teljes előzmény letöltése folyamatban — a videók feltűnnek, ahogy előkerülnek; egy nagyon nagy csatorna maradékát a háttérben fejezi be.",
"fullHistoryQueued": "A csatorna teljes előzményének letöltése elindult — az új videók feltűnnek, ahogy előkerülnek.",
"fullHistoryFailed": "Nem sikerült elindítani a teljes előzmény letöltését."
"fullHistoryFailed": "Nem sikerült elindítani a teljes előzmény letöltését"
}
+12 -12
View File
@@ -1,6 +1,6 @@
{
"actionsLabel": "Katalógus-műveletek",
"syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról",
"syncSubscriptions": "Feliratkozások szinkronizálása a YouTube-ról",
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
"backfillEverything": "Minden letöltése",
"backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.",
@@ -31,7 +31,7 @@
"needWriteHint": "Engedélyezd a lejátszási listák szerkesztését a Beállításokban a feliratkozáshoz.",
"subscribedTitle": "Feliratkozva a YouTube-on",
"subscribedBody": "Mostantól követed: {{name}} — az új feltöltései megjelennek a Feededben.",
"subscribeFailed": "A feliratkozás sikertelen",
"subscribeFailed": "Nem sikerült feliratkozni",
"confirmSubscribe": "Feliratkozol erre a YouTube-on: „{{name}}”? Ez módosítja a valódi YouTube-fiókodat és kevés API-kvótát használ.",
"cols": {
"totalVideos": "Videók",
@@ -54,15 +54,15 @@
"noneYet": "Még nincs címke — hozz létre egyet lent.",
"create": "Címke létrehozása",
"createPlaceholder": "Új címke…",
"createFailed": "A címke létrehozása nem sikerült.",
"updateFailed": "A címke frissítése nem sikerült.",
"createFailed": "Nem sikerült létrehozni a címkét",
"updateFailed": "Nem sikerült frissíteni a címkét",
"noTag": "Nincs címke",
"moreCount_one": "még {{count}} címke",
"moreCount_other": "még {{count}} címke",
"suggestedHint": "A címkézési mintád alapján javasolt ({{pct}}% egyezés) — kattints a hozzáadáshoz",
"acceptSuggestion": "{{name}} javasolt címke hozzáadása",
"dismissSuggestion": "Javaslat elvetése",
"suggestionsHint": "A címkézetlen csatornáidhoz javasolt címkék, abból tanulva, hogyan címkézted a hasonlókat. Kattints egy szellem-chipre a hozzáadáshoz, vagy ×-re az elvetéshez.",
"dismissSuggestion": "Javaslat elrejtése",
"suggestionsHint": "A címkézetlen csatornáidhoz javasolt címkék, abból tanulva, hogyan címkézted a hasonlókat. Kattints egy szellem-chipre a hozzáadáshoz, vagy ×-re az elrejtéshez.",
"suggestionsCount_one": "{{count}} javaslat",
"suggestionsCount_other": "{{count}} javaslat",
"acceptAllHigh_one": "{{count}} biztos elfogadása",
@@ -102,7 +102,7 @@
},
"row": {
"openOnYouTube": "Megnyitás a YouTube-on",
"editTags": "Címkék szerkesztése",
"editTags": "Címkék szerkesztése…",
"filterFeedByTag": "Feed szűrése: „{{name}}”",
"priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a Feedet „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.",
"fullHint": "Teljes előzmény letöltve.",
@@ -126,12 +126,12 @@
},
"notify": {
"synced": "{{count}} feliratkozás szinkronizálva",
"syncFailed": "A feliratkozások szinkronizálása sikertelen",
"syncFailed": "Nem sikerült szinkronizálni a feliratkozásokat",
"unsubscribed": "Leiratkozva a(z) {{name}} csatornáról a YouTube-on",
"unsubscribeFailed": "A leiratkozás sikertelen",
"unsubscribeFailed": "Nem sikerült leiratkozni",
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.",
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod",
"connect": "Csatlakoztatás",
"filteredByTag": "Feed szűrve címkére: {{name}}",
"resetDone": "{{name}} resetelve — újraletöltés folyamatban",
@@ -143,12 +143,12 @@
"searchPlaceholder": "Csatornák keresése…",
"blocked": {
"title": "Tiltott csatornák",
"hint": "A videóik el vannak rejtve a Feededből, a Library-ből és a YouTube-keresésből.",
"hint": "A videóik el vannak rejtve a Feededből, a Könyvtárból és a YouTube-keresésből.",
"empty": "Nincs tiltott csatorna. Egy csatorna oldaláról tilthatsz le egyet, hogy minden videóját elrejtsd leiratkozás nélkül."
},
"hidden": {
"title": "Elrejtett csatornák",
"hint": "A videóik kimaradnak a személyes Feededből, de a megosztott Library-ben továbbra is megjelennek.",
"hint": "A videóik kimaradnak a személyes Feededből, de a megosztott Könyvtárban továbbra is megjelennek.",
"empty": "Nincs elrejtett csatorna. Egy feliratkozást a sorában (a szem ikonnal) rejthetsz el, hogy kimaradjon a személyes Feededből leiratkozás nélkül.",
"unhide": "Felfedés"
}
+2 -2
View File
@@ -10,7 +10,7 @@
"undo": "Visszavonás",
"redo": "Újra",
"loading": "Betöltés…",
"somethingWrong": "Hiba történt.",
"somethingWrong": "Hiba történt",
"itemCount_one": "{{formattedCount}} elem",
"itemCount_other": "{{formattedCount}} elem",
"accessRequestsTitle": "Hozzáférési kérések",
@@ -20,7 +20,7 @@
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.",
"backToTop": "Vissza a tetejére",
"retry": "Újra",
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra.",
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra",
"seeAll": "Összes",
"scrollLeft": "Görgetés balra",
"scrollRight": "Görgetés jobbra",
+4 -4
View File
@@ -134,7 +134,7 @@
},
"download_upload_max_bytes": {
"label": "Feltöltési méretkorlát fájlonként (bájt)",
"hint": "A letöltési központba feltölthető legnagyobb egyedi fájl. Legalább 1, így a korlát mindig él; a felhasználó teljes tárhelyét továbbra is a kvótája korlátozza. Alapérték 500 MiB."
"hint": "A Letöltésekbe feltölthető legnagyobb egyedi fájl. Legalább 1, így a korlát mindig él; a felhasználó teljes tárhelyét továbbra is a kvótája korlátozza. Alapérték 500 MiB."
},
"download_retention_days": {
"label": "Megőrzés (nap)",
@@ -292,12 +292,12 @@
"testEmail": "Teszt-email küldése",
"testEmailHint": "Teszt-email küldése a saját címedre a fenti beállításokkal, hogy ellenőrizd, működnek-e.",
"testSent": "Teszt-email elküldve ide: {{to}}",
"testFailed": "A teszt-email sikertelen — ellenőrizd a beállításokat",
"testFailed": "Nem sikerült elküldeni a teszt-emailt — ellenőrizd a beállításokat",
"plexTest": "Kapcsolat tesztelése",
"plexTestHint": "Ellenőrzi a Plex-szerver URL-t és tokent, és betölti a könyvtárlistát. Előbb mentsd a módosításokat.",
"testDirty": "Mentsd a változásokat a tesztelés előtt.",
"plexTestOk": "Kapcsolódva: {{name}}",
"plexTestFailed": "A Plex-kapcsolat sikertelen — ellenőrizd az URL-t és a tokent",
"plexTestFailed": "Nem sikerült csatlakozni a Plexhez — ellenőrizd az URL-t és a tokent",
"plexConnected": "Kapcsolódva: {{name}} {{version}}",
"plexMediaOk": "A media-mount rendben — egy mintafájl megtalálható a lemezen.",
"plexMediaMissing": "A média nem olvasható itt: {{path}} — ellenőrizd, hogy a Plex média be van-e mountolva a konténerbe, és hogy helyes-e az útvonal-leképezés. A lejátszás addig nem fog működni, amíg ez nincs javítva.",
@@ -309,5 +309,5 @@
"issuesTest": "Kapcsolat ellenőrzése",
"issuesTestHint": "Megnézi, hogy a tárhely elérhető, privát, és fogad hibajegyet. Előbb mentsd a módosításokat.",
"issuesTestOk": "Rendben: {{repo}} (privát)",
"issuesTestFailed": "A hibajegy-kezelő ellenőrzése nem sikerült"
"issuesTestFailed": "Nem sikerült ellenőrizni a hibajegy-kezelőt"
}
+32 -32
View File
@@ -13,14 +13,14 @@
"submit": "Hozzáadás a letöltésekhez",
"added": "Hozzáadva a letöltésekhez",
"view": "Megnyitás",
"manage": "Formátumok kezelése"
"manage": "Formátumok kezelése…"
},
"page": {
"addUrl": "Illessz be egy videólinket (YouTube, Facebook, …) vagy YouTube-videóazonosítót…",
"add": "Hozzáad",
"paste": "Link beillesztése a vágólapról",
"upload": "Feltöltés",
"manage": "Formátumok kezelése"
"manage": "Formátumok kezelése…"
},
"upload": {
"title": "Fájl feltöltése",
@@ -33,7 +33,7 @@
"uploading": "Feltöltés… {{pct}}%",
"uploadProgress": "Feltöltés folyamata",
"added": "Fájl feltöltve",
"failed": "A feltöltés nem sikerült."
"failed": "Nem sikerült feltölteni a fájlt"
},
"tabs": {
"queue": "Sor",
@@ -65,15 +65,15 @@
"pause": "Szünet",
"resume": "Folytatás",
"cancel": "Megszakítás",
"delete": "Eltávolítás",
"delete": "Törlés",
"retry": "Újra",
"download": "Mentés a gépemre",
"play": "Lejátszás",
"rename": "Átnevezés",
"editDetails": "Adatok szerkesztése",
"editDetails": "Adatok szerkesztése…",
"share": "Megosztás (link másolása)",
"shareManage": "Megosztás kezelése",
"edit": "Szerkesztés / vágás",
"shareManage": "Megosztás kezelése…",
"edit": "Szerkesztés / vágás…",
"redownload": "Újraletöltés (fájl újraépítése)",
"removeShared": "Eltávolítás a listámból"
},
@@ -88,7 +88,7 @@
},
"usage": {
"title": "Tárhelyed",
"items": "{{used}} / {{max}} elem",
"items": "{{used}} / {{max}} letöltés",
"unlimited": "Korlátlan"
},
"source": {
@@ -108,9 +108,9 @@
"share": {
"title": "Letöltés megosztása",
"done": "Megosztva vele: {{email}}",
"failed": "A megosztás nem sikerült.",
"failed": "Nem sikerült megosztani",
"userSection": "Megosztás felhasználóval",
"userHint": "A „Velem megosztott” fülön találja meg.",
"userHint": "A címzett a „Velem megosztva” fülön találja meg.",
"pickPlaceholder": "Keress egy felhasználót…",
"noUsers": "Nincs más felhasználó.",
"linkSection": "Megosztás linkkel",
@@ -127,7 +127,7 @@
"days": "{{n}} nap",
"password": "Jelszó (opcionális)",
"copy": "Link másolása",
"copyFailed": "Nem sikerült a másolás.",
"copyFailed": "Nem sikerült másolni",
"revoke": "Visszavonás",
"revokeConfirm": "A link azonnal használhatatlanná válik.",
"views": "{{n}} megtekintés",
@@ -145,8 +145,8 @@
"cancelTitle": "Megszakítod a letöltést?",
"cancelBody": "Ez leállítja a letöltést és eltávolítja a sorból.",
"cancelAction": "Letöltés leállítása",
"deleteTitle": "Eltávolítod a letöltést?",
"deleteBody": "Ez eltávolítja a listádról. A fájl a lejáratáig még a gyorsítótárban maradhat mások számára.",
"deleteTitle": "Törlöd a letöltést?",
"deleteBody": "Ez törli a letöltéseid közül. A fájl a lejáratáig még a gyorsítótárban maradhat mások számára.",
"removeSharedTitle": "Eltávolítod a listádból?",
"removeSharedBody": "Csak a te megosztott listádból tűnik el — a tulajdonos fájlját nem törli.",
"redownloadTitle": "Újraletöltöd ezt a fájlt?",
@@ -198,28 +198,28 @@
"errors": {
"quota": {
"max_bytes": "Elfogyott a letöltési tárhelykereted ({{size}} GB). Előbb szabadíts fel helyet.",
"max_jobs": "Elérted a letöltési korlátodat ({{limit}} elem). Előbb távolíts el néhányat.",
"generic": "Túllépted a letöltési kvótát."
"max_jobs": "Elérted a letöltési korlátodat ({{limit}} letöltés). Előbb törölj néhányat.",
"generic": "Túllépted a letöltési kvótát"
},
"edit": {
"empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet.",
"source_not_ready": "A forrásletöltés még nem szerkeszthető.",
"generic": "Ezt a vágást nem sikerült létrehozni."
"empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet",
"source_not_ready": "A forrásletöltés még nem szerkeszthető",
"generic": "Nem sikerült létrehozni a vágást"
},
"upload": {
"tooLarge": "Ez a fájl túl nagy (legfeljebb {{size}}).",
"empty": "Ez a fájl üres."
"tooLarge": "Ez a fájl túl nagy (legfeljebb {{size}})",
"empty": "Ez a fájl üres"
},
"urlBlocked": {
"scheme": "Csak http:// és https:// hivatkozás tölthető le.",
"no_host": "Ez a hivatkozás nem tartalmaz webcímet.",
"scheme": "Csak http:// és https:// hivatkozás tölthető le",
"no_host": "Ez a hivatkozás nem tartalmaz webcímet",
"unresolvable": "Ezt a címet nem sikerült feloldani. Ellenőrizd a hivatkozást, és próbáld újra.",
"private": "Ez a cím a szerver saját hálózatán van, ezért nem tölthető le róla.",
"generic": "Erről a címről nem lehet letölteni.",
"malformed": "Ez a hivatkozás nem érvényes webcím."
"private": "Ez a cím a szerver saját hálózatán van, ezért nem tölthető le róla",
"generic": "Erről a címről nem lehet letölteni",
"malformed": "Ez a hivatkozás nem érvényes webcím"
},
"fail": {
"title": "A letöltés nem sikerült",
"title": "Nem sikerült letölteni",
"detailsLink": "részletek",
"detailsToggle": "Technikai részletek",
"cat": {
@@ -248,7 +248,7 @@
"body": "Ez a videó DRM-védett, ezért nem tölthető le."
},
"postprocess": {
"short": "A feldolgozás megszakadt",
"short": "Nem sikerült feldolgozni a videót",
"body": "A videó letöltődött, de egy feldolgozási lépés (összefűzés vagy konvertálás) meghiúsult. Újrapróbálásra gyakran megjavul."
},
"source_missing": {
@@ -260,8 +260,8 @@
"body": "Hálózati hiba szakította meg a letöltést. Újrapróbálásra általában megjavul."
},
"unknown": {
"short": "A letöltés nem sikerült",
"body": "Valami hiba történt az elem letöltése közben. Az alábbi technikai részletek segíthetnek."
"short": "Nem sikerült letölteni",
"body": "Valami hiba történt ennél a letöltésnél. Az alábbi technikai részletek segíthetnek."
},
"quota_exceeded": {
"short": "Betelt a tárhely",
@@ -300,9 +300,9 @@
}
},
"cookies": {
"empty": "Előbb illeszd be a cookie-kat.",
"format": "Ez nem tűnik cookies.txt fájlnak (Netscape formátum).",
"generic": "A cookie-kat nem sikerült menteni."
"empty": "Előbb illeszd be a cookie-kat",
"format": "Ez nem tűnik cookies.txt fájlnak (Netscape formátum)",
"generic": "Nem sikerült menteni a cookie-kat"
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@
"createN": "{{count}} klip létrehozása",
"created_one": "{{count}} klip a letöltésekhez adva",
"created_other": "{{count}} klip a letöltésekhez adva",
"failed": "A klip létrehozása nem sikerült.",
"failed": "Nem sikerült létrehozni a klipet",
"partlyCreated": "{{total}} klipből csak {{done}} került a letöltésekhez — a többi kijelölve maradt, így az újbóli létrehozás csak azokat adja hozzá.",
"splitHere": "Vágás itt",
"output": {
+138 -138
View File
@@ -1,57 +1,57 @@
{
"title": "Nem sikerült",
"generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.",
"demo": "Ez a bemutató fiókban nem érhető el.",
"demo": "Ez a bemutató fiókban nem érhető el",
"validation": "Néhány megadott érték érvénytelen volt. Ellenőrizd, majd próbáld újra.",
"tooLarge": "Ez túl nagy, a szerver nem fogadta el.",
"tooLarge": "Ez túl nagy, a szerver nem fogadta el",
"server": "Szerverhiba",
"syncCooldown": "Az imént többször is szinkronizáltál — várj pár percet. A YouTube-keret közös az instance összes felhasználójával, a háttérszinkron pedig így is fut tovább.",
"notFound": {
"generic": "Ez az elem már nem létezik.",
"view": "Ez a nézet már nem létezik.",
"tag": "Ez a címke már nem létezik.",
"pin": "Ez már nincs a komfort polcodon.",
"playlist": "Ez a lejátszási lista már nem létezik.",
"move_target": "A lejátszási lista, ahová áthelyeznéd, már nem létezik.",
"video": "Ez a videó már nincs a könyvtárban.",
"invite": "Ez a meghívó már nem létezik.",
"user": "Ez a felhasználó már nem létezik.",
"channel": "Ez a csatorna már nincs a könyvtárban.",
"subscription": "Már nem vagy feliratkozva erre a csatornára.",
"recipient": "Nincs felhasználó ezzel az e-mail címmel.",
"job": "Ez a feladat már nem létezik.",
"download": "Ez a letöltés már nem létezik.",
"debug_session": "Ez a hibakereső munkamenet már nem létezik.",
"screenshot": "Ez a képernyőkép már nem létezik.",
"link": "Ez a megosztási link már nem létezik.",
"notification": "Ez az értesítés már nem létezik.",
"account": "Ez a fiók már nem létezik.",
"collection": "Ez a gyűjtemény már nem létezik.",
"config_key": "Nincs ilyen beállítás.",
"file": "A fájl már nem érhető el.",
"filmstrip": "Ehhez nincs képsor.",
"host": "Ez a kiszolgáló már nincs a listán.",
"image": "Ehhez nincs kép.",
"item": "Ez az elem már nem létezik.",
"library": "Ez a könyvtár már nem létezik.",
"plex_item": "Ez a Plex-elem már nincs a könyvtárban.",
"poster": "Ehhez nincs poszter.",
"profile": "Ez a letöltési profil már nem létezik.",
"season": "Ez az évad már nincs a könyvtárban.",
"show": "Ez a sorozat már nincs a könyvtárban."
"generic": "Ez az elem már nem létezik",
"view": "Ez a nézet már nem létezik",
"tag": "Ez a címke már nem létezik",
"pin": "Ez már nincs a komfort polcodon",
"playlist": "Ez a lejátszási lista már nem létezik",
"move_target": "A lejátszási lista, ahová áthelyeznéd, már nem létezik",
"video": "Ez a videó már nincs a könyvtárban",
"invite": "Ez a meghívó már nem létezik",
"user": "Ez a felhasználó már nem létezik",
"channel": "Ez a csatorna már nincs a könyvtárban",
"subscription": "Már nem vagy feliratkozva erre a csatornára",
"recipient": "Nincs felhasználó ezzel az e-mail címmel",
"job": "Ez a feladat már nem létezik",
"download": "Ez a letöltés már nem létezik",
"debug_session": "Ez a hibakereső munkamenet már nem létezik",
"screenshot": "Ez a képernyőkép már nem létezik",
"link": "Ez a megosztási link már nem létezik",
"notification": "Ez az értesítés már nem létezik",
"account": "Ez a fiók már nem létezik",
"collection": "Ez a gyűjtemény már nem létezik",
"config_key": "Nincs ilyen beállítás",
"file": "A fájl már nem érhető el",
"filmstrip": "Ehhez nincs képsor",
"host": "Ez a kiszolgáló már nincs a listán",
"image": "Ehhez nincs kép",
"item": "Ez az elem már nem létezik",
"library": "Ez a könyvtár már nem létezik",
"plex_item": "Ez a Plex-elem már nincs a könyvtárban",
"poster": "Ehhez nincs poszter",
"profile": "Ez a letöltési profil már nem létezik",
"season": "Ez az évad már nincs a könyvtárban",
"show": "Ez a sorozat már nincs a könyvtárban"
},
"admin": {
"not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.",
"not_applicable": "Ezen a fiókon már nincs mit megerősíteni.",
"not_approved": "Csak jóváhagyott címre küldhető meghívó.",
"not_applicable": "Ezen a fiókon már nincs mit megerősíteni",
"not_approved": "Csak jóváhagyott címre küldhető meghívó",
"email_off": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Konfiguráció → E-mail / SMTP fület.",
"send_failed": "Nem sikerült elküldeni a levelet: a levelezőszerver nem volt elérhető, vagy elutasította a belépést vagy a levelet. Nézd meg a Konfiguráció → E-mail / SMTP fület, majd próbáld újra.",
"demo": "A bemutató fiók közös, ezért nem kaphat jelszóbeállító linket."
"demo": "A bemutató fiók közös, ezért nem kaphat jelszóbeállító linket"
},
"reason": {
"unreachable": "A szerver nem volt elérhető. Próbáld újra.",
"forbidden": "Ehhez nincs jogosultságod.",
"rateLimited": "Túl sok kérés egyszerre — várj egy kicsit, és próbáld újra.",
"forbidden": "Ehhez nincs jogosultságod",
"rateLimited": "Túl sok kérés egyszerre — várj egy kicsit, és próbáld újra",
"unexpected": "Váratlan hiba történt az alkalmazásban. Próbáld újra."
},
"writeFailed": {
@@ -70,15 +70,15 @@
"delete": "Nem sikerült törölni a letöltést",
"redownload": "Nem sikerült újraindítani a letöltést"
},
"removeShared": "Nem sikerült eltávolítani a megosztott elemet",
"removeShared": "Nem sikerült eltávolítani a megosztott letöltést",
"downloadDetails": "Nem sikerült menteni a letöltés adatait",
"enqueueDownload": "Nem sikerült sorba állítani a letöltést",
"downloadProfileSave": "Nem sikerült menteni a letöltési profilt",
"downloadProfileDelete": "Nem sikerült törölni a letöltési profilt",
"downloadCookiesRemove": "Nem sikerült eltávolítani ezeket a sütiket",
"downloadCookiesRemove": "Nem sikerült törölni ezeket a cookie-kat",
"notifRead": "Nem sikerült olvasottnak jelölni az értesítést",
"notifReadAll": "Nem sikerült olvasottnak jelölni az értesítéseket",
"notifDismiss": "Nem sikerült elvetni az értesítést",
"notifDismiss": "Nem sikerült elrejteni az értesítést",
"notifClear": "Nem sikerült törölni az értesítéseket",
"createPlaylist": "Nem sikerült létrehozni a lejátszási listát",
"schedulerRun": "Nem sikerült elindítani a feladatot",
@@ -106,8 +106,8 @@
"body": "Nem sikerült elérni a szervert — lehet, hogy épp újraindul. Ez az üzenet eltűnik, amint helyreáll a kapcsolat."
},
"boundary": {
"title": "Hiba történt.",
"subtitle": "Az alkalmazás váratlan hibába ütközött.",
"title": "Hiba történt",
"subtitle": "Az alkalmazás váratlan hibába ütközött",
"reload": "Újratöltés",
"notifTitle": "Valami elromlott",
"notifMessage": "Váratlan hiba"
@@ -118,111 +118,111 @@
"unavailable": "A YouTube most nem fogadta el. Próbáld újra."
},
"refused": {
"account_no_access": "Ennek a fióknak már nincs hozzáférése.",
"account_signed_out": "Ezt a fiókot máshol kijelentkeztették — lépj be újra.",
"account_suspended": "Ez a fiók fel van függesztve.",
"address_has_account": "Ehhez a címhez már tartozik fiók — azt töröld helyette.",
"admin_first": "Előbb hozd létre az admin fiókot.",
"admin_only": "Ezt csak admin teheti meg.",
"already_set_up": "Ez a példány már be van állítva.",
"already_subscribed": "Erre a csatornára már feliratkoztál.",
"channel_blocked": "Ezt a csatornát letiltottad.",
"collection_fields_required": "A címet és egy elemet is meg kell adni.",
"collection_pending_sync": "A gyűjtemény a Plexben megváltozott, de a helyi másolat frissítése nem sikerült — a következő szinkron pótolja.",
"collection_read_only": "Ez a gyűjtemény csak olvasható.",
"collection_smart": "Az okos és az automatikus listás gyűjtemények nem tehetők szerkeszthetővé.",
"demo_activate": "A bemutató fiók nem aktiválható.",
"demo_delete": "A bemutató fiók nem törölhető.",
"demo_role": "A bemutató fiók szerepköre nem módosítható.",
"demo_suspend": "A bemutató fiók nem függeszthető fel.",
"download_not_ready": "Ez a letöltés még nincs kész.",
"edit_unfinished": "Csak befejezett letöltés szerkeszthető.",
"filters_not_object": "A nézet szűrői hibás formátumúak.",
"account_no_access": "Ennek a fióknak már nincs hozzáférése",
"account_signed_out": "Ezt a fiókot máshol kijelentkeztették — lépj be újra",
"account_suspended": "Ez a fiók fel van függesztve",
"address_has_account": "Ehhez a címhez már tartozik fiók — azt töröld helyette",
"admin_first": "Előbb hozd létre az admin fiókot",
"admin_only": "Ezt csak admin teheti meg",
"already_set_up": "Ez a példány már be van állítva",
"already_subscribed": "Erre a csatornára már feliratkoztál",
"channel_blocked": "Ezt a csatornát letiltottad",
"collection_fields_required": "A címet és egy elemet is meg kell adni",
"collection_pending_sync": "A gyűjtemény a Plexben megváltozott, de a helyi másolat frissítése nem sikerült — a következő szinkron pótolja",
"collection_read_only": "Ez a gyűjtemény csak olvasható",
"collection_smart": "Az okos és az automatikus listás gyűjtemények nem tehetők szerkeszthetővé",
"demo_activate": "A bemutató fiók nem aktiválható",
"demo_delete": "A bemutató fiók nem törölhető",
"demo_role": "A bemutató fiók szerepköre nem módosítható",
"demo_suspend": "A bemutató fiók nem függeszthető fel",
"download_not_ready": "Ez a letöltés még nincs kész",
"edit_unfinished": "Csak befejezett letöltés szerkeszthető",
"filters_not_object": "A nézet szűrői hibás formátumúak",
"google_no_userinfo": "A Google nem küldte vissza a fiók adatait. Próbálj újra belépni.",
"image_fetch_failed": "A képet nem sikerült betölteni.",
"image_host": "Erről a címről nem töltünk be képet.",
"invalid_cursor": "A lista pozíciója elavult — töltsd újra az oldalt.",
"invalid_email": "Adj meg egy érvényes e-mail címet.",
"invalid_interval": "Az intervallumnak {{min}} és {{max}} perc között kell lennie.",
"invalid_link": "Ez nem tűnik érvényes videólinknek.",
"invalid_role": "Ismeretlen szerepkör.",
"invalid_status": "Ismeretlen állapot.",
"invite_decided": "Erről a kérésről már döntöttek.",
"job_running": "Ez a feladat már fut.",
"last_active_admin": "Ez az egyetlen aktív admin — előbb tegyél meg valaki mást adminnak.",
"last_admin": "Ez az egyetlen admin — előbb tegyél meg valaki mást adminnak.",
"link_gone": "Ez a link már nem érhető el.",
"link_required": "Adj meg egy videólinket vagy -azonosítót.",
"media_missing": "A médiafájl nincs a lemezen.",
"message_empty": "Az üzenet üres.",
"message_rate": "Túl sok üzenet — várj egy kicsit.",
"message_self": "Magadnak nem küldhetsz üzenetet.",
"message_too_long": "Az üzenet túl hosszú.",
"messaging_set_up": "A biztonságos üzenetküldés már be van állítva.",
"name_required": "Adj meg egy nevet.",
"no_encryption_key": "Titkos értékek tárolásához állítsd be a TOKEN_ENCRYPTION_KEY-t.",
"no_messaging": "Ez a tag még nem állította be az üzenetküldést.",
"no_profile": "Nincs használható letöltési profil.",
"no_subscription_id": "Még nincs rögzített YouTube-feliratkozás — előbb szinkronizáld a feliratkozásaidat.",
"not_authenticated": "Nem vagy bejelentkezve.",
"not_ready": "Még nem érhető el.",
"not_redownloadable": "Ez az elem nem tölthető le újra.",
"not_your_account": "Ebbe a fiókba itt nem léptél be.",
"only_admin_delete": "Te vagy az egyetlen admin — a fiókod törlése előtt tegyél meg valaki mást adminnak.",
"own_delete": "A saját fiókodat a Beállítások → Fiók alatt törölheted.",
"own_role": "A saját szerepkörödet nem módosíthatod.",
"own_suspend": "A saját fiókodat nem függesztheted fel.",
"plex_disabled": "A Plex modul ki van kapcsolva.",
"plex_not_configured": "A Plex nincs beállítva — add meg a szerver címét és a tokent a Konfiguráció → Plex alatt.",
"image_fetch_failed": "Nem sikerült betölteni a képet",
"image_host": "Erről a címről nem töltünk be képet",
"invalid_cursor": "A lista pozíciója elavult — töltsd újra az oldalt",
"invalid_email": "Adj meg egy érvényes e-mail címet",
"invalid_interval": "Az intervallumnak {{min}} és {{max}} perc között kell lennie",
"invalid_link": "Ez nem tűnik érvényes videólinknek",
"invalid_role": "Ismeretlen szerepkör",
"invalid_status": "Ismeretlen állapot",
"invite_decided": "Erről a kérésről már döntöttek",
"job_running": "Ez a feladat már fut",
"last_active_admin": "Ez az egyetlen aktív admin — előbb tegyél meg valaki mást adminnak",
"last_admin": "Ez az egyetlen admin — előbb tegyél meg valaki mást adminnak",
"link_gone": "Ez a link már nem érhető el",
"link_required": "Adj meg egy videólinket vagy -azonosítót",
"media_missing": "A médiafájl nincs a lemezen",
"message_empty": "Az üzenet üres",
"message_rate": "Túl sok üzenet — várj egy kicsit",
"message_self": "Magadnak nem küldhetsz üzenetet",
"message_too_long": "Az üzenet túl hosszú",
"messaging_set_up": "A biztonságos üzenetküldés már be van állítva",
"name_required": "Adj meg egy nevet",
"no_encryption_key": "Titkos értékek tárolásához állítsd be a TOKEN_ENCRYPTION_KEY-t",
"no_messaging": "Ez a tag még nem állította be az üzenetküldést",
"no_profile": "Nincs használható letöltési profil",
"no_subscription_id": "Még nincs rögzített YouTube-feliratkozás — előbb szinkronizáld a feliratkozásaidat",
"not_authenticated": "Nem vagy bejelentkezve",
"not_ready": "Még nem érhető el",
"not_redownloadable": "Ez a letöltés nem tölthető le újra",
"not_your_account": "Ebbe a fiókba itt nem léptél be",
"only_admin_delete": "Te vagy az egyetlen admin — a fiókod törlése előtt tegyél meg valaki mást adminnak",
"own_delete": "A saját fiókodat a Beállítások → Fiók alatt törölheted",
"own_role": "A saját szerepkörödet nem módosíthatod",
"own_suspend": "A saját fiókodat nem függesztheted fel",
"plex_disabled": "A Plex modul ki van kapcsolva",
"plex_not_configured": "A Plex nincs beállítva — add meg a szerver címét és a tokent a Konfiguráció → Plex alatt",
"plex_unreachable": "A Plex-szerver nem válaszolt. Ellenőrizd a címet és a tokent, majd próbáld újra.",
"plex_write_failed": "A Plex nem fogadta el a módosítást. Próbáld újra.",
"profile_name_required": "Adj meg egy profilnevet.",
"quota_reserve": "A mai közös YouTube-keret a tartalékánál tart — próbáld később.",
"profile_name_required": "Adj meg egy profilnevet",
"quota_reserve": "A mai közös YouTube-keret a tartalékánál tart — próbáld később",
"quota_spent": "A mai közös YouTube-keret elfogyott. Próbáld holnap.",
"recipient_required": "Adj meg egy címzett e-mail címet.",
"registration_closed": "A regisztráció jelenleg zárva van.",
"recipient_required": "Adj meg egy címzett e-mail címet",
"registration_closed": "A regisztráció jelenleg zárva van",
"search_daily_limit": "Elérted a mai YouTube-keresési korlátot ({{limit}}). Próbáld holnap.",
"search_term_required": "Írj be egy keresőkifejezést.",
"session_gone": "A lejátszási munkamenet véget ért.",
"setting_not_a_choice": "Válassz ezek közül: {{choices}}.",
"setting_not_a_number": "Adj meg egy egész számot.",
"setting_too_high": "Az érték nem lehet nagyobb, mint {{max}}.",
"setting_too_low": "Az érték nem lehet kisebb, mint {{min}}.",
"setup_token": "A beállítási token hiányzik vagy hibás.",
"share_own": "Ez már a te letöltésed.",
"share_password_required": "Ehhez a linkhez jelszó kell.",
"share_unfinished": "Csak befejezett letöltés osztható meg.",
"smtp_failed": "Nem sikerült elküldeni a levelet — ellenőrizd az SMTP-beállításokat.",
"smtp_off": "Az SMTP nincs beállítva.",
"spec_not_object": "A profil beállításai hibás formátumúak.",
"still_downloading": "Ez a letöltés még folyamatban van.",
"tag_name_taken": "Már van „{{name}}” nevű címkéd.",
"title_required": "Adj meg egy címet.",
"search_term_required": "Írj be egy keresőkifejezést",
"session_gone": "A lejátszási munkamenet véget ért",
"setting_not_a_choice": "Válassz ezek közül: {{choices}}",
"setting_not_a_number": "Adj meg egy egész számot",
"setting_too_high": "Az érték nem lehet nagyobb, mint {{max}}",
"setting_too_low": "Az érték nem lehet kisebb, mint {{min}}",
"setup_token": "A beállítási token hiányzik vagy hibás",
"share_own": "Ez már a te letöltésed",
"share_password_required": "Ehhez a linkhez jelszó kell",
"share_unfinished": "Csak befejezett letöltés osztható meg",
"smtp_failed": "Nem sikerült elküldeni a levelet — ellenőrizd az SMTP-beállításokat",
"smtp_off": "Az SMTP nincs beállítva",
"spec_not_object": "A profil beállításai hibás formátumúak",
"still_downloading": "Ez a letöltés még folyamatban van",
"tag_name_taken": "Már van „{{name}}” nevű címkéd",
"title_required": "Adj meg egy címet",
"too_many_attempts": "Túl sok próbálkozás. Próbáld kicsit később.",
"unknown_setting": "Ismeretlen beállítás: {{key}}",
"value_missing": "Adj meg egy értéket.",
"video_gone": "Ez a videó már nem érhető el.",
"view_name_taken": "Már van „{{name}}” nevű nézeted.",
"watch_sync_off": "Előbb kapcsold be a Plex megtekintés-szinkront.",
"weak_password": "A jelszónak legalább {{min}} karakterből kell állnia.",
"wrong_password": "A jelenlegi jelszó helytelen.",
"wrong_share_password": "Hibás jelszó.",
"youtube_editing_off": "Előbb kapcsold be a YouTube-szerkesztést a Beállításokban.",
"youtube_read_scope": "Előbb kapcsold össze a YouTube-fiókodat (olvasási hozzáférés).",
"value_missing": "Adj meg egy értéket",
"video_gone": "Ez a videó már nem érhető el",
"view_name_taken": "Már van „{{name}}” nevű nézeted",
"watch_sync_off": "Előbb kapcsold be a Plex megtekintés-szinkront",
"weak_password": "A jelszónak legalább {{min}} karakterből kell állnia",
"wrong_password": "A jelenlegi jelszó helytelen",
"wrong_share_password": "Hibás jelszó",
"youtube_editing_off": "Előbb kapcsold be a YouTube-szerkesztést a Beállításokban",
"youtube_read_scope": "Előbb kapcsold össze a YouTube-fiókodat (olvasási hozzáférés)",
"debug_session_conflict": "Egy másik lapon épp most indult rögzítés. Frissítsd az oldalt.",
"issue_already_sent": "Ezt a hibajegyet már elküldted, a vázlata nem változtatható.",
"issue_title_required": "Adj címet a hibajegynek, mielőtt elküldöd.",
"screenshot_already_sent": "A hibajegy már elment, ehhez a munkamenethez nem adhatsz több képet.",
"screenshot_too_many": "Egy munkamenethez legfeljebb {{limit}} képernyőkép tartozhat.",
"screenshot_empty": "A kép üres.",
"screenshot_not_an_image": "Ez nem PNG, JPEG vagy WebP kép.",
"issue_already_sent": "Ezt a hibajegyet már elküldted, a vázlata nem változtatható",
"issue_title_required": "Adj címet a hibajegynek, mielőtt elküldöd",
"screenshot_already_sent": "A hibajegy már elment, ehhez a munkamenethez nem adhatsz több képet",
"screenshot_too_many": "Egy munkamenethez legfeljebb {{limit}} képernyőkép tartozhat",
"screenshot_empty": "A kép üres",
"screenshot_not_an_image": "Ez nem PNG, JPEG vagy WebP kép",
"issues_not_configured": "Nincs beállítva hibajegy-kezelő. Töltsd le a hibajegyet zipként.",
"issues_unreachable": "A hibajegy-kezelő nem válaszol. Próbáld újra később.",
"issues_bad_token": "A hibajegy-kezelő nem fogadta el a tokent, vagy hiányzik a jogosultsága.",
"issues_repo_not_found": "A beállított hibajegy-tárhely nem található.",
"issues_bad_token": "A hibajegy-kezelő nem fogadta el a tokent, vagy hiányzik a jogosultsága",
"issues_repo_not_found": "A beállított hibajegy-tárhely nem található",
"issues_public_repo": "A hibajegy-tárhely nyilvános. A napló miatt csak privát tárhelyre küldünk.",
"issues_disabled": "A hibajegy-tárhelyen ki van kapcsolva a hibajegy-kezelés.",
"issues_rejected": "A hibajegy-kezelő visszautasította a kérést.",
"issues_disabled": "A hibajegy-tárhelyen ki van kapcsolva a hibajegy-kezelés",
"issues_rejected": "A hibajegy-kezelő visszautasította a kérést",
"issues_rate_limited": "Óránként legfeljebb {{limit}} hibajegyet küldhetsz. Próbáld újra később.",
"debug_session_rate_limited": "Óránként legfeljebb {{limit}} új rögzítést indíthatsz. Próbáld újra később.",
"export_rate_limited": "Túl sok letöltés egymás után. Várj egy percet.",
+6 -6
View File
@@ -12,16 +12,16 @@
"maxAge_other": "Utolsó {{count}} nap"
},
"loading": "Feed betöltése…",
"loadError": "Nem sikerült betölteni a Feedet.",
"loadError": "Nem sikerült betölteni a Feedet",
"emptyTitle": "A Feeded üres",
"emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a Feededet.",
"setUp": "Feed beállítása",
"browseShared": "Vagy csak böngészd a közös könyvtárat",
"browseShared": "Vagy csak böngészd a közös Könyvtárat",
"demoEmptyTitle": "Itt a demóban nincs semmi",
"demoEmptyBody": "A demo fióknak nincsenek saját feliratkozásai — de a teljes közös könyvtár a tiéd: böngészd, szűrd és rendezd kedvedre.",
"demoEmptyBody": "A demo fióknak nincsenek saját feliratkozásai — de a teljes közös Könyvtár a tiéd: böngészd, szűrd és rendezd kedvedre.",
"browseLibrary": "Könyvtár böngészése",
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
"channelScopeHint": "Ez a csatorna nincs a Feededben – lehet, hogy el van rejtve. A videói a közös könyvtárban vannak.",
"channelScopeHint": "Ez a csatorna nincs a Feededben – lehet, hogy el van rejtve. A videói a közös Könyvtárban vannak.",
"viewInLibrary": "Megnézés a Könyvtárban",
"sortLabel": "Rendezés",
"viewLabel": "Nézet",
@@ -70,7 +70,7 @@
"back": "Vissza a Feedhez",
"searching": "Keresés a YouTube-on…",
"noResults": "Nincs YouTube-találat.",
"error": "A YouTube-keresés nem sikerült.",
"error": "Nem sikerült keresni a YouTube-on",
"loadMore": "Továbbiak betöltése (kvótát fogyaszt)",
"loadMoreFree": "Továbbiak betöltése",
"show": "Mutat",
@@ -78,6 +78,6 @@
"clearNow": "Törlés most",
"cleared_one": "{{count}} találat törölve",
"cleared_other": "{{count}} találat törölve",
"showHint": "Hány találatot szerezzen egyszerre — a Több betöltése ennyivel tölt tovább."
"showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább."
}
}
+7 -7
View File
@@ -5,7 +5,7 @@
"searchYoutubeTip": "Élő keresés a YouTube-on erre a kifejezésre (a közös API-kvótát fogyasztja). Entert is nyomhatsz.",
"go": "Mehet",
"goTip": "Keresés (vagy Enter)",
"channelManager": "Csatornakezelő",
"channelManager": "Csatornák",
"usageStats": "Használat és statisztika",
"scheduler": "Ütemező",
"configuration": "Konfiguráció",
@@ -15,7 +15,7 @@
"my": "Sajátom",
"myTip": "Csak a saját feliratkozásaid videói",
"all": "Könyvtár",
"allTip": "A közös könyvtár összes videója"
"allTip": "A közös Könyvtár összes videója"
},
"account": {
"admin": "Adminisztrátor",
@@ -51,14 +51,14 @@
"resume": "Szinkron folytatása"
},
"banner": {
"updated": "Frissítve a(z) v{{version}} verzióra — nézd meg, mi változott.",
"updated": "Frissítve a(z) v{{version}} verzióra — nézd meg, mi változott",
"releaseNotes": "Verziójegyzék",
"dismiss": "Elvetés"
"dismiss": "Elrejtés"
},
"verify": {
"confirmed": "Megerősítettük az e-mail-címedet.",
"confirmedNamed": "Megerősítettük az e-mail-címedet — {{email}}.",
"invalid": "Ez a megerősítő link már nem érvényes — lejárt, vagy már felhasználták.",
"confirmed": "Megerősítettük az e-mail-címedet",
"confirmedNamed": "Megerősítettük az e-mail-címedet — {{email}}",
"invalid": "Ez a megerősítő link már nem érvényes — lejárt, vagy már felhasználták",
"unreachable": "Nem sikerült elérni a szervert a link megerősítéséhez, így az nem is használódott el. Próbáld megnyitni újra egy pillanat múlva.",
"resetSignedOut": "A jelszó-visszaállító linkek kijelentkezett ablakban nyílnak meg. Jelentkezz ki itt, vagy nyisd meg a linket privát ablakban, és onnan folytatódik.",
"reset": "A fiók regisztrációkori jelszavát biztonsági okból töröltük; belépés előtt állíts be újat."
+8 -8
View File
@@ -1,8 +1,8 @@
{
"navLabel": "Kezdőlap",
"title": "Mit nézzek most?",
"error": "Nem sikerült betölteni az ajánlatokat.",
"actionFailed": "Nem sikerült elmenteni.",
"error": "Nem sikerült betölteni az ajánlatokat",
"actionFailed": "Nem sikerült elmenteni",
"budget": {
"ask": "Mennyi időd van?",
"short": "10 perc",
@@ -38,17 +38,17 @@
"moveLeft": "Balra",
"moveRight": "Jobbra",
"moveLast": "A polc végére",
"moveFailed": "Nem sikerült átrendezni a polcot.",
"moveFailed": "Nem sikerült átrendezni a polcot",
"moveSaving": "Az új sorrend mentése…",
"openPlaylist": "Lejátszási lista megnyitása"
},
"pin": {
"menu": "Komfort polc",
"add": "Komfort polcra",
"remove": "Levétel a polcról",
"added": "{{title}} felkerült a komfort polcra.",
"removed": "{{title}} lekerült a komfort polcról.",
"addFailed": "Nem sikerült a polcra tűzni.",
"removeFailed": "Nem sikerült levenni a polcról."
"remove": "Eltávolítás a polcról",
"added": "{{title}} felkerült a komfort polcra",
"removed": "{{title}} lekerült a komfort polcról",
"addFailed": "Nem sikerült a polcra tűzni",
"removeFailed": "Nem sikerült eltávolítani a polcról"
}
}
+5 -5
View File
@@ -6,15 +6,15 @@
"clearConfirmTitle": "Törlöd az összes értesítést?",
"clearConfirmBody": "Minden itteni értesítés eltűnik a listából, olvasott és olvasatlan is. Nem hozhatók vissza.",
"markRead": "Olvasottnak jelöl",
"dismiss": "Elvetés",
"dismiss": "Elrejtés",
"rowMenu": "Értesítés műveletei",
"menu": "Értesítésközpont műveletei",
"menu": "Értesítések műveletei",
"sectionSystem": "Rendszer",
"sectionActivity": "Tevékenység",
"andMore": "és még {{count}}",
"openScheduler": "Ütemező megnyitása",
"openDownloads": "Letöltések megnyitása",
"revertFailed": "Nem sikerült visszavonni — próbáld újra.",
"revertFailed": "Nem sikerült visszavonni — próbáld újra",
"downloadDone": {
"title": "Letöltés kész"
},
@@ -28,11 +28,11 @@
},
"jobDone": {
"titleOk": "{{job}} kész",
"titleError": "{{job}} hibázott"
"titleError": "Nem sikerült lefuttatni: {{job}}"
},
"emailUndelivered": {
"title": "Nem megy ki e-mail",
"notConfigured": "Az SMTP nincs beállítva, így semmi nem kézbesíthető, amit ez a példány küldene — megerősítő linkek, jelszó-visszaállítások és hozzáférés-kérelmek egyaránt. Az egyik ilyen: „{{subject}}”. Állítsd be a Konfiguráció → E-mail / SMTP fülön.",
"sendFailed": "A küldés nem sikerült, így az üzenetek nem kézbesíthetők. Az egyik ilyen: „{{subject}}”. Nézd meg a Konfiguráció → E-mail / SMTP fület, és küldj tesztet; az okot a szerver naplója tartalmazza."
"sendFailed": "Nem sikerült e-mailt küldeni, így az üzenetek nem kézbesíthetők. Az egyik ilyen: „{{subject}}”. Nézd meg a Konfiguráció → E-mail / SMTP fület, és küldj tesztet; az okot a szerver naplója tartalmazza."
}
}
+1 -1
View File
@@ -15,7 +15,7 @@
"audio": "Hangsáv váltása",
"subtitle": "Felirat váltása",
"help": "Súgó megjelenítése",
"home": "Ugrás a kezdőlapra (mit nézzek?)",
"home": "Ugrás: Kezdőlap (mit nézzek?)",
"info": "Média infó",
"stats": "Statisztika (nerdöknek)",
"back": "Leállítás és vissza",
+6 -6
View File
@@ -5,7 +5,7 @@
"minimize": "Összecsukás",
"close": "Bezárás",
"conversations": "Beszélgetések",
"new": "Új üzenet",
"new": "Új üzenet…",
"newTitle": "Új üzenet",
"empty": "Még nincs beszélgetés. Indíts egyet az Új üzenet gombbal.",
"searchPeople": "Emberek keresése…",
@@ -27,15 +27,15 @@
"unlock": "Feloldás",
"passphrase": "Jelszó",
"passphraseConfirm": "Jelszó megerősítése",
"passTooShort": "Legalább 6 karakter legyen.",
"passMismatch": "A jelszavak nem egyeznek.",
"wrongPass": "Hibás jelszó.",
"setupFailed": "A biztonságos üzenetküldés beállítása nem sikerült. Próbáld újra.",
"passTooShort": "Legalább 6 karakter legyen",
"passMismatch": "A jelszavak nem egyeznek",
"wrongPass": "Hibás jelszó",
"setupFailed": "Nem sikerült beállítani a biztonságos üzenetküldést. Próbáld újra.",
"forgotPass": "Elfelejtetted a jelszavad?",
"resetTitle": "Biztonságos üzenetküldés visszaállítása",
"resetWarn": "Ez véglegesen törli a teljes üzenet-előzményedet — minden beszélgetést, nálad és a partnereidnél is —, mert az többé nem visszafejthető. Utána új jelszót állíthatsz be és tiszta lappal indulhatsz. Ez nem visszavonható.",
"resetCurrentPass": "Jelenlegi fiók-jelszó",
"resetNeedPass": "Add meg a jelenlegi fiók-jelszavad a megerősítéshez.",
"resetButton": "Visszaállítás és előzmény törlése",
"resetFailed": "A biztonságos üzenetküldés visszaállítása nem sikerült. Próbáld újra."
"resetFailed": "Nem sikerült visszaállítani a biztonságos üzenetküldést. Próbáld újra."
}
+2 -2
View File
@@ -12,13 +12,13 @@
"channels": "Csatornák",
"playlists": "Listák",
"plex": "Plex",
"notifications": "Értesítés",
"notifications": "Értesítések",
"messages": "Üzenetek",
"downloads": "Letöltések",
"stats": "Statisztika",
"scheduler": "Ütemező",
"config": "Konfig",
"users": "Fiókok",
"users": "Felhasználók",
"audit": "Audit",
"logs": "Napló",
"settings": "Beállítások"
@@ -1,9 +1,9 @@
{
"actionNeeded": "Teendő",
"dismiss": "Elvetés",
"dismiss": "Elrejtés",
"findInFeed": "Keresés a Feedben",
"unhide": "Megjelenítés",
"unwatch": "Megtekintés visszavonása",
"openInManager": "Csatornakezelő",
"openInManager": "Megnyitás a Csatornákban",
"openOnYouTube": "Megnyitás a YouTube-on"
}
+1 -1
View File
@@ -13,7 +13,7 @@
},
"write": {
"title": "Csatlakoztatva",
"importFailed": "A YouTube-fiókod csatlakoztatva, de a feliratkozások importálása sikertelen volt — újrapróbálhatod a Beállítások → Szinkronizálás menüben. ",
"importFailed": "A YouTube-fiókod csatlakoztatva, de nem sikerült importálni a feliratkozásaidat — újrapróbálhatod a Beállítások → Szinkronizálás menüben. ",
"feedReady": "A Feeded készen áll{{channels}}. Választhatóan engedélyezheted, hogy a Siftlode ",
"feedReadyChannels": " ({{count}} csatorna)",
"rationale": "<0>leiratkozzon</0> helyetted a YouTube-csatornákról — ehhez egy plusz írási engedély szükséges. Hagyd ki, ha csak olvasható módban szeretnél maradni; ezt később bármikor engedélyezheted a Beállításokban.",
+37 -36
View File
@@ -3,9 +3,9 @@
"manageGroup": "Kezelés",
"listGroup": "Listáid",
"watchLater": "Megnézem később",
"syncYoutube": "Szinkron YouTube-ról",
"syncYoutube": "Szinkronizálás a YouTube-ról",
"syncedToast": "{{count}} lista szinkronizálva a YouTube-ról",
"syncFailed": "Nem sikerült a YouTube-szinkron",
"syncFailed": "Nem sikerült szinkronizálni a YouTube-ról",
"noneYet": "Még nincs lejátszási lista",
"newPlaylist": "Új lista…",
"searchPlaceholder": "Listák keresése…",
@@ -19,20 +19,21 @@
"delete": "Törlés",
"rename": "Átnevezés",
"confirmDelete": "Törlöd a(z) „{{name}}” listát? Nem vonható vissza.",
"addToPlaylist": "Hozzáadás listához",
"addToPlaylist": "Hozzáadás listához…",
"addToPlaylistTitle": "Hozzáadás listához",
"exportToYoutube": "Exportálás YouTube-ra",
"pushToYoutube": "Szinkron YouTube-ra",
"pushToYoutube": "Szinkronizálás a YouTube-ra",
"unsynced": "Nincs szinkronizálva",
"unsyncedHint": "Vannak helyi módosításaid, amelyek még nincsenek a YouTube-ra szinkronizálva.",
"openOnYoutube": "Megnyitás YouTube-on",
"revert": "Visszaállítás YouTube-ról",
"revertTitle": "Eldobod a helyi módosításokat?",
"revertMsg": "Ez újratölti a listát a YouTube-ról, és eldobja a nem szinkronizált helyi módosításaidat (sorrend, hozzáadás, eltávolítás, átnevezés). Folytatod?",
"revertConfirm": "Eldobás és újratöltés",
"revertDone": "„{{name}}” újratöltve a YouTube-ról ✓",
"revertFailed": "Nem sikerült újratölteni a YouTube-ról.",
"pushTitle": "Szinkron YouTube-ra",
"pushConfirm": "Szinkron",
"revertTitle": "Elveted a helyi módosításokat?",
"revertMsg": "Ez újratölti a listát a YouTube-ról, és elveti a nem szinkronizált helyi módosításaidat (sorrend, hozzáadás, eltávolítás, átnevezés). Folytatod?",
"revertConfirm": "Elvetés és újratöltés",
"revertDone": "„{{name}}” szinkronizálva a YouTube-ról",
"revertFailed": "Nem sikerült szinkronizálni a YouTube-ról",
"pushTitle": "Szinkronizálás a YouTube-ra",
"pushConfirm": "Szinkronizálás",
"pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
"mergeAddLocal_one": "{{count}} videó bekerül a Siftlode-listádba a YouTube-ról.",
"mergeAddLocal_other": "{{count}} videó bekerül a Siftlode-listádba a YouTube-ról.",
@@ -50,19 +51,19 @@
"mergeQuota": " (~{{units}} kvótaegység; ma még {{left}} maradt.)",
"mergeQuotaFree": " (~0 kvótaegység.)",
"pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.",
"pushUpToDate": "A(z) „{{name}}” már szinkronban van a YouTube-bal.",
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
"pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.",
"pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.",
"pushUpToDate": "A(z) „{{name}}” már szinkronban van a YouTube-bal",
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra",
"pushPartial": "Szinkronizálva, de {{count}} videó kimaradt",
"pushFailed": "Nem sikerült szinkronizálni a YouTube-ra",
"deleteHereOnly": "Törlés csak itt",
"deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva.",
"deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt törölve",
"sortLabel": "Rendezés",
"dirAsc": "Növekvő",
"dirDesc": "Csökkenő",
"groupByChannel": "Csoportosítás csatorna szerint",
"railSortCustom": "Egyéni sorrend",
"railSortName": "Név",
"railSortCount": "Elemszám",
"railSortCount": "Videók száma",
"railSortDuration": "Összhossz",
"railSortRecent": "Utoljára módosított",
"dirtyFirst": "Nem szinkronizáltak elöl",
@@ -72,24 +73,24 @@
"rowMenu": "Videó műveletek",
"moveTo": "Áthelyezés másik listába",
"moveToRecent": "Áthelyezés ide: „{{name}}”",
"moveNoTargets": "Még nincs másik lejátszási lista, ahová áthelyezhetnéd.",
"movedToast": "„{{title}}” áthelyezve ide: „{{name}}” ✓",
"moveFailed": "Nem sikerült áthelyezni a videót.",
"moveNoTargets": "Még nincs másik lejátszási lista, ahová áthelyezhetnéd",
"movedToast": "„{{title}}” áthelyezve ide: „{{name}}”",
"moveFailed": "Nem sikerült áthelyezni a videót",
"removedToast": "„{{title}}” eltávolítva a listáról",
"undoRemoveFailed": "Nem sikerült visszatenni a videót.",
"removeFailed": "Nem sikerült eltávolítani a videót.",
"undoRemoveFailed": "Nem sikerült visszatenni a videót",
"removeFailed": "Nem sikerült eltávolítani a videót",
"errors": {
"name_required": "Adj nevet a lejátszási listának.",
"youtube_read_scope": "A listák szinkronizálásához kapcsold össze a YouTube-ot (olvasási hozzáférés).",
"not_on_youtube": "Ez a lejátszási lista nincs fent a YouTube-on.",
"watch_later_not_pushable": "A „Megnézem később” listát nem lehet a YouTube-ra küldeni.",
"watch_later_undeletable": "A „Megnézem később” lista nem törölhető.",
"youtube_editing_off": "Előbb engedélyezd a YouTube-szerkesztést a Beállításokban.",
"quota_exceeded": "Mára nem maradt elég YouTube-keret ehhez a szinkronhoz."
"name_required": "Adj nevet a lejátszási listának",
"youtube_read_scope": "A listák szinkronizálásához kapcsold össze a YouTube-ot (olvasási hozzáférés)",
"not_on_youtube": "Ez a lejátszási lista nincs fent a YouTube-on",
"watch_later_not_pushable": "A „Megnézem később” listát nem lehet a YouTube-ra küldeni",
"watch_later_undeletable": "A „Megnézem később” lista nem törölhető",
"youtube_editing_off": "Előbb engedélyezd a YouTube-szerkesztést a Beállításokban",
"quota_exceeded": "Mára nem maradt elég YouTube-keret ehhez a szinkronhoz"
},
"moveErrors": {
"not_in_playlist": "Ez a videó már nincs ebben a listában — időközben máshol megváltozott.",
"same_playlist": "Ez a videó már ebben a listában van."
"not_in_playlist": "Ez a videó már nincs ebben a listában — időközben máshol megváltozott",
"same_playlist": "Ez a videó már ebben a listában van"
},
"railMenu": "Lejátszási lista műveletei",
"builtinLocked": "A beépített „{{name}}” lista nem nevezhető át és nem törölhető.",
@@ -102,10 +103,10 @@
"saveOrder": "Sorrend mentése",
"filteredCount": "{{count}} látszik",
"noneMatchFilter": "Egy videó sem felel meg a szűrőnek.",
"loadFailed": "Nem sikerült betölteni ezt a listát.",
"reorderFailed": "Nem sikerült elmenteni az új sorrendet.",
"renameFailed": "Nem sikerült átnevezni a listát.",
"deleteFailed": "Nem sikerült törölni a listát.",
"loadFailed": "Nem sikerült betölteni ezt a listát",
"reorderFailed": "Nem sikerült elmenteni az új sorrendet",
"renameFailed": "Nem sikerült átnevezni a listát",
"deleteFailed": "Nem sikerült törölni a listát",
"confirmDeleteLinked": "Törlöd a(z) „{{name}}” listát? A YouTube-bal szinkronban van — válaszd ki, hogy ott is törlődjön-e. Nem vonható vissza.",
"dragBlockedBuiltin": "Ennek a listának kötött a sorrendje — kézzel nem rendezhető át.",
"deleteBoth": "Törlés itt és a YouTube-on",
@@ -114,7 +115,7 @@
"viewDiffers": "Ideiglenes nézet ({{view}}) — a lista mentett sorrendje változatlan. Mentsd, ha ez maradjon.",
"viewMatches": "Ez a nézet ({{view}}) megegyezik a lista mentett sorrendjével, így nincs mit menteni.",
"viewFiltered": "Szűrt nézet — {{total}} elemből {{shown}} látszik. Szűrés közben a sorrend nem menthető.",
"addFailed": "Nem sikerült hozzáadni a lejátszási listához.",
"addFailed": "Nem sikerült hozzáadni a lejátszási listához",
"limits": {
"hint": "Hiányzik egy lista?",
"why": "Miért?",
+28 -27
View File
@@ -5,7 +5,7 @@
"playAll": "Összes lejátszása",
"shuffle": "Keverés",
"rename": "Átnevezés",
"addItem": "Cím hozzáadása",
"addItem": "Cím hozzáadása…",
"delete": "Törlés",
"enableEdit": "Szerkesztés engedélyezése",
"removeItem": "Eltávolítás a kollekcióból",
@@ -21,15 +21,16 @@
"add": "Hozzáad",
"added": "Hozzáadva",
"deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót? Ez a Plexből is eltávolítja.",
"editFailed": "A kollekció szerkesztése sikertelen",
"playFailed": "Nem sikerült összeállítani a lejátszási sort — próbáld újra.",
"playEmpty": "Ebben a kollekcióban még nincs mit lejátszani.",
"newCollection": "Új kollekció",
"editFailed": "Nem sikerült szerkeszteni a kollekciót",
"playFailed": "Nem sikerült összeállítani a lejátszási sort — próbáld újra",
"playEmpty": "Ebben a kollekcióban még nincs mit lejátszani",
"newCollection": "Új kollekció…",
"newCollectionTitle": "Új kollekció",
"namePlaceholder": "Kollekció neve",
"nameTaken": "Már létezik ilyen nevű kollekció.",
"nameTaken": "Már létezik ilyen nevű kollekció",
"newHint": "Válaszd ki az első címet — egy kollekcióhoz legalább egy elem kell.",
"createWith": "Létrehozás",
"deleteFailed": "Nem sikerült törölni a gyűjteményt."
"deleteFailed": "Nem sikerült törölni a gyűjteményt"
},
"searchPlaceholder": "Keresés a Plexben…",
"searchField": {
@@ -78,9 +79,9 @@
"duration": "Hossz",
"durationUnit": "perc",
"durationOpt": {
"short": "90 perc alatt",
"mid": "90–120 perc",
"long": "2 óra felett"
"short": "90 p alatt",
"mid": "90–120 p",
"long": "2 ó felett"
},
"added": "Plexhez adva",
"addedOpt": {
@@ -128,7 +129,7 @@
"markNextWatched": "S{{season}}·E{{episode}} megnézettnek jelöl",
"markNextUnwatched": "S{{season}}·E{{episode}} nem-nézettnek jelöl",
"nextUp": "Következik: S{{season}}·E{{episode}}",
"watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra.",
"watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra",
"seasons": "{{count}} évad",
"unified": {
"titles": "Címek",
@@ -159,7 +160,7 @@
"delete": "Törlés",
"deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót a Plexből? Ez minden felhasználót érint.",
"note": "A változtatások a Plexbe íródnak, és minden kliensen/felhasználónál megjelennek.",
"others": "{{count}} meglévő kollekció kezelése…",
"others": "{{count}} meglévő kollekció kezelése",
"makeEditable": "Kezelem"
},
"player": {
@@ -174,8 +175,8 @@
"restart": "Újra az elejéről",
"prev": "Előző rész",
"next": "Következő rész",
"tapBack": "−{{n}} mp",
"tapForward": "+{{n}} mp",
"tapBack": "−{{d}}",
"tapForward": "+{{d}}",
"mute": "Némítás (m)",
"volume": "Hangerő",
"fullscreen": "Teljes képernyő (f)",
@@ -186,7 +187,7 @@
"subOff": "Ki",
"errNotFound": "Ez a fájl nem játszható le — a média nem érhető el a szerveren (hiányzik a Plex media-mount, vagy hibás az útvonal-leképezés). Kérd meg az adminisztrátort, hogy ellenőrizze a Plex beállításait.",
"errTranscode": "Ennek a fájlnak a formátuma transzkódolást igényel, ami még nem támogatott.",
"errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra.",
"errGeneric": "Nem sikerült elindítani a lejátszást. Próbáld újra.",
"help": "Billentyűparancsok",
"infoBtn": "Média infó",
"more": "Továbbiak",
@@ -222,9 +223,9 @@
"subStyle": "Feliratok",
"subPreparing": "Felirat előkészítése: {{label}}…",
"subUnavailable": {
"bitmap": "Ez a felirat képalapú, szövegként nem jeleníthető meg.",
"missing": "Ez a feliratsáv nem érhető el.",
"failed": "Ezt a feliratot nem sikerült betölteni."
"bitmap": "Ez a felirat képalapú, szövegként nem jeleníthető meg",
"missing": "Ez a feliratsáv nem érhető el",
"failed": "Nem sikerült betölteni ezt a feliratot"
},
"subSize": "Szövegméret",
"subPos": "Pozíció",
@@ -294,16 +295,16 @@
"transcode_sw": "Szoftveresen átkódolva (legnehezebb)"
},
"playlistAdd": {
"manage": "Listához adás",
"manage": "Listához adás…",
"title": "Listához adás — {{title}}",
"newPlaceholder": "Új lista neve…",
"create": "Létrehozás",
"none": "Még nincs lista. Hozz létre egyet fent.",
"count": "{{count}} elem",
"groupProgress": "{{in}} / {{size}}",
"addFailed": "Nem sikerült hozzáadni a lejátszási listához.",
"removeFailed": "Nem sikerült eltávolítani a lejátszási listáról.",
"createFailed": "Nem sikerült létrehozni a lejátszási listát."
"addFailed": "Nem sikerült hozzáadni a lejátszási listához",
"removeFailed": "Nem sikerült eltávolítani a lejátszási listáról",
"createFailed": "Nem sikerült létrehozni a lejátszási listát"
},
"playlist": {
"section": "Lejátszási listák",
@@ -326,10 +327,10 @@
"addShow": "Egész sorozat listához adása",
"addSeason": "Évad listához adása",
"addEpisode": "Epizód listához adása",
"removeFailed": "Nem sikerült eltávolítani a lejátszási listáról.",
"renameFailed": "Nem sikerült átnevezni a lejátszási listát.",
"deleteFailed": "Nem sikerült törölni a lejátszási listát.",
"reorderFailed": "Nem sikerült menteni az új sorrendet.",
"removeFailed": "Nem sikerült eltávolítani a lejátszási listáról",
"renameFailed": "Nem sikerült átnevezni a lejátszási listát",
"deleteFailed": "Nem sikerült törölni a lejátszási listát",
"reorderFailed": "Nem sikerült menteni az új sorrendet",
"reorderItem": "„{{title}}” áthelyezése",
"rowMenu": "Elem műveletei",
"menu": "Lista műveletei",
@@ -337,7 +338,7 @@
"playSeason": "Évad lejátszása",
"removeFromPlaylist": "Eltávolítás a listáról",
"removedToast": "„{{title}}” eltávolítva a listáról",
"undoRemoveFailed": "Nem sikerült visszatenni a listára."
"undoRemoveFailed": "Nem sikerült visszatenni a listára"
},
"people": {
"count_one": "{{count}} cím",
@@ -1,7 +1,7 @@
{
"subscriptions_pull": "Feliratkozások beolvasása",
"subscriptions_pull": "Feliratkozások szinkronizálása",
"subscriptions_resync": "Automatikus feliratkozás-újraszinkron",
"playlists_pull": "Lejátszási listák beolvasása",
"playlists_pull": "Lejátszási listák szinkronizálása",
"playlists_push": "Lejátszási listák feltöltése",
"playlists_delete": "Lejátszási lista törlése",
"videos_backfill_recent": "Friss feltöltések betöltése",
+7 -7
View File
@@ -29,8 +29,8 @@
"shotFailed": "Nem sikerült elmenteni a képernyőképet",
"stopFailed": "Nem sikerült leállítani a rögzítést",
"stopped": "Rögzítés leállítva",
"expired_one": "A rögzítés elérte a {{count}} perces határt, és leállt.",
"expired_other": "A rögzítés elérte a {{count}} perces határt, és leállt."
"expired_one": "A rögzítés elérte a {{count}} perces határt, és leállt",
"expired_other": "A rögzítés elérte a {{count}} perces határt, és leállt"
},
"settings": {
"title": "Hibakereső munkamenet",
@@ -46,7 +46,7 @@
},
"composer": {
"title": "Hibajegy",
"meta": "{{start}} · {{minutes}} perc · {{level}}",
"meta": "{{start}} · {{minutes}} · {{level}}",
"fieldTitle": "Cím",
"titlePlaceholder": "Mi romlott el?",
"description": "Leírás",
@@ -75,7 +75,7 @@
"reviewDone": "Kész",
"keepRow": "Sor megtartása: {{event}} {{msg}}",
"screenshot": "{{n}}. képernyőkép",
"removeShot": "{{n}}. képernyőkép kivétele",
"removeShot": "{{n}}. képernyőkép törlése",
"drop": "Kép beillesztése: Ctrl+V, vagy húzd ide, vagy",
"pick": "válassz fájlt",
"close": "Bezárás",
@@ -85,7 +85,7 @@
"saveFailed": "Nem sikerült menteni a vázlatot",
"loadFailed": "Nem sikerült betölteni a munkamenetet",
"truncated": "A napló az első {{kept}} sort tartalmazza, összesen {{total}} volt.",
"sent": "Elküldve hibajegyként.",
"sent": "Elküldve hibajegyként",
"openIssue": "Hibajegy megnyitása",
"env": {
"version": "Verzió",
@@ -99,7 +99,7 @@
"sentOk": "Hibajegy létrehozva",
"sendFailed": "Nem sikerült elküldeni a hibajegyet",
"target": "Cél: {{repo}} · privát",
"partial": "A hibajegy létrejött, de nem ment el minden csatolmány: {{files}}.",
"partial": "A hibajegy létrejött, de nem ment el minden csatolmány: {{files}}",
"retry": "Hiányzók küldése",
"edit": "Szerkesztés",
"preview": "Előnézet",
@@ -108,7 +108,7 @@
"previewLocal": "Hibajegy-kezelő nélkül ez a zip issue.md fájlja.",
"shotsFailed": "Nem sikerült betölteni a képernyőképeket",
"removeShotFailed": "Nem sikerült törölni a képernyőképet",
"partialImages": "A hibajegy létrejött, de a képernyőképek még nem látszanak a szövegében.",
"partialImages": "A hibajegy létrejött, de a képernyőképek még nem látszanak a szövegében",
"retryImages": "Megjelenítés a hibajegyben",
"sendingNow": "A hibajegy küldése folyamatban…",
"exportFailed": "Nem sikerült elkészíteni a zipet"
+5 -5
View File
@@ -24,7 +24,7 @@
"quotaRemaining": "{{formatted}} maradt",
"quotaNote": "Közös YouTube Data API-keret; éjfélkor (US Pacific) nullázódik. A backfill hátrébb sorol, hogy maradjon hely az interaktív használatnak.",
"minutes": "perc",
"range": "{{min}} és {{max}} között lehet.",
"range": "{{min}} és {{max}} között lehet",
"editInterval": "Kattints a gyakoriság módosításához",
"intervalFailed": "Nem sikerült módosítani a gyakoriságot",
"runNow": "Futtatás most",
@@ -85,8 +85,8 @@
"backfill": "Backfill (friss + teljes előzmény)",
"autotag": "Automatikus címkézés",
"shorts": "Shorts-besorolás",
"subscriptions": "Feliratkozások újraszinkronja",
"playlist_sync": "YouTube lejátszási listák szinkronja",
"subscriptions": "Feliratkozások szinkronizálása a YouTube-ról",
"playlist_sync": "Lejátszási listák szinkronizálása a YouTube-ról",
"maintenance": "Karbantartás + ellenőrzés",
"demo_reset": "Demo fiók visszaállítása",
"explore_cleanup": "Felfedezett csatornák takarítása",
@@ -111,7 +111,7 @@
"liveRefreshHint": "Élő/közelgő videók (és a frissen véget ért, hossz nélküli adások), amíg le nem ülepednek."
},
"purgeDiscovery": "Felfedezés ürítése",
"purgeDiscoveryHint": "Most eltávolítja az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.",
"purgeDiscoveryHint": "Most törli az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.",
"purgeConfirmTitle": "Felfedezés-tartalom törlése?",
"purgedDiscovery": "{{count}} felfedezés-elem eltávolítva"
"purgedDiscovery": "{{count}} felfedezés-elem törölve"
}
+29 -31
View File
@@ -36,17 +36,17 @@
"railLabels": "Feliratok az oldalsó menüben",
"railLabelsHint": "Rövid név minden ikon alatt a bal oldali menüben. A menü ugyanolyan keskeny marad, csak magasabb lesz.",
"startup": "Indulás",
"startOnHome": "Indítás a kezdőlapon",
"startOnHome": "Indítás a Kezdőlapon",
"startOnHomeHint": "Minden induláskor a „Mit nézzek most?” oldal nyílik meg. Kikapcsolva ott folytatod, ahol abbahagytad.",
"textSize": "Betűméret"
},
"notifications": {
"title": "Értesítések",
"sound": "Hang riasztásoknál",
"sound": "Hang értesítéseknél",
"soundHint": "Rövid hangjelzés, ha egy értesítés a figyelmedet igényli (pl. hibák, kérdések).",
"autoDismiss": "Buborékok automatikus eltüntetése",
"autoDismiss": "Buborékok automatikus elrejtése",
"autoDismissHint": "Kikapcsolva a felugró buborékok addig maradnak, amíg be nem zárod őket. Bekapcsolva a beállított idő után elhalványulnak; a gombos (Visszavonás) legalább 8 másodpercig marad.",
"dismissAfter": "Eltüntetés ennyi után",
"dismissAfter": "Elrejtés ennyi után",
"sendTest": "Tesztértesítés küldése",
"testTitle": "Tesztértesítés",
"testMessage": "Így néz ki egy értesítés."
@@ -73,7 +73,7 @@
"deleteTitle": "Törlöd a fiókodat?",
"deleteConfirm": "Ez véglegesen törli a fiókodat és minden adatodat (feliratkozások, címkék, megnézett/mentett/elrejtett állapot, lejátszási listák, beállítások). Nem vonható vissza.",
"deleteConfirmButton": "Minden törlése",
"demoNotice": "Ez a közös demo fiók. Nincs YouTube-kapcsolata — böngészheted a teljes közös könyvtárat, szűrheted és rendezheted, listákat építhetsz és kipróbálhatsz dolgokat, de semmi nem nyúl valódi YouTube-fiókhoz.",
"demoNotice": "Ez a közös demo fiók. Nincs YouTube-kapcsolata — böngészheted a teljes közös Könyvtárat, szűrheted és rendezheted, listákat építhetsz és kipróbálhatsz dolgokat, de semmi nem nyúl valódi YouTube-fiókhoz.",
"signInMethods": "Bejelentkezési módok",
"googleLink": {
"title": "Google-fiók",
@@ -81,13 +81,13 @@
"connectedHint": "A Google-fiókod össze van kapcsolva. Bejelentkezhetsz Google-lel, és alább engedélyezheted a YouTube-hozzáférést.",
"connectHint": "Kapcsolj össze egy Google-fiókot, hogy Google-lel jelentkezhess be, és engedélyezhesd a YouTube-hozzáférést a Feededhez.",
"connect": "Google összekapcsolása",
"linked": "Google-fiók összekapcsolva.",
"conflict": "Ez a Google-fiók már egy másik Siftlode-fiókhoz van kapcsolva.",
"linked": "Google-fiók összekapcsolva",
"conflict": "Ez a Google-fiók már egy másik Siftlode-fiókhoz van kapcsolva",
"mismatch": "Ez nem az a Google-fiók, amely ide már össze van kapcsolva. Az összekapcsolttal jelentkezz be.",
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra.",
"signedInAs": "Google-belépés ezzel a címmel:"
},
"googleCancelled": "A Google-belépés megszakadt.",
"googleCancelled": "A Google-belépés megszakadt",
"password": {
"title": "Jelszó",
"setHint": "Van beállított jelszó. Bejelentkezhetsz e-mail-címmel és jelszóval.",
@@ -98,8 +98,8 @@
"new": "Új jelszó (min. 10 karakter)",
"save": "Jelszó mentése",
"saving": "Mentés…",
"saved": "Jelszó frissítve ehhez: {{email}}.",
"failed": "Nem sikerült frissíteni a jelszót."
"saved": "Jelszó frissítve ehhez: {{email}}",
"failed": "Nem sikerült frissíteni a jelszót"
},
"sessions": {
"title": "Aktív munkamenetek",
@@ -107,7 +107,7 @@
"action": "Kijelentkezés máshonnan",
"confirmTitle": "Kijelentkezés a többi munkamenetből?",
"confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről. Itt bejelentkezve maradsz.",
"done": "Kijelentkeztetve minden más munkamenetből.",
"done": "Kijelentkeztetve minden más munkamenetből",
"failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra."
}
},
@@ -116,21 +116,21 @@
"intro": "Egyes oldalak (pl. egy zárt Facebook-csoport, aminek tagja vagy) csak bejelentkezett tagoknak mutatják a videót. Illeszd be ide a böngésződ cookie-jait egy oldalhoz, és az onnan induló letöltések a te bejelentkezésedet használják. A cookie-k titkosítva, csak a te fiókodhoz kötve tárolódnak, és kizárólag az adott oldalnak küldjük el őket.",
"configured": "Beállítva · frissítve: {{when}}",
"notSet": "Nincs beállítva",
"set": "Cookie-k beállítása",
"update": "Frissítés",
"remove": "Eltávolítás",
"set": "Cookie-k beállítása…",
"update": "Frissítés…",
"remove": "Törlés",
"save": "Mentés",
"cancel": "Mégse",
"saving": "Mentés…",
"placeholder": "Illeszd be ide a cookies.txt tartalmát (Netscape formátum)",
"help": "Exportálj cookies.txt-t ehhez az oldalhoz egy böngészőbővítménnyel, majd illeszd be ide. Csak írható — sosem mutatjuk vissza.",
"saved": "Cookie-k mentve.",
"removed": "Cookie-k eltávolítva.",
"failed": "A cookie-kat nem sikerült menteni.",
"removeConfirmTitle": "Cookie-k eltávolítása?",
"saved": "Cookie-k mentve",
"removed": "Cookie-k törölve",
"failed": "Nem sikerült menteni a cookie-kat",
"removeConfirmTitle": "Törlöd a cookie-kat?",
"removeConfirmBody": "Az erről az oldalról induló letöltések nem használják tovább a bejelentkezésedet.",
"removeConfirmAction": "Eltávolítás",
"loadError": "Nem sikerült betölteni a letöltési hitelesítési beállításokat."
"removeConfirmAction": "Törlés",
"loadError": "Nem sikerült betölteni a letöltési hitelesítési beállításokat"
},
"demo": {
"title": "Demo hozzáférés",
@@ -160,14 +160,14 @@
"add": "Hozzáadás",
"decided": "{{count}} eldöntve",
"approved": "Jóváhagyva: {{email}} — most már be tud jelentkezni",
"approveFailed": "A jóváhagyás sikertelen",
"denyFailed": "Az elutasítás sikertelen",
"approveFailed": "Nem sikerült jóváhagyni a kérelmet",
"denyFailed": "Nem sikerült elutasítani a kérelmet",
"addedToWhitelist": "{{email}} hozzáadva a fehérlistához",
"inviteHint": "Küldj neki meghívót. A cím hozzáadása soha nem küld levelet — ez igen.",
"invitedAt": "Meghívva: {{time}}. Az újraküldés másodszor is levelet küld neki.",
"inviteLabel": "Meghívó küldése ide: {{email}}",
"invited": "Meghívó elküldve ide: {{email}}.",
"inviteFailed": "Nem sikerült elküldeni a meghívót.",
"invited": "Meghívó elküldve ide: {{email}}",
"inviteFailed": "Nem sikerült elküldeni a meghívót",
"inviteTitle": "Küldesz meghívót?",
"inviteConfirm": "Levél megy a(z) {{email}} címre arról, hogy a címe engedélyezve van, és nyitva áll előtte a Siftlode.",
"inviteConfirmAgain": "Ezt a címet már meghívtad ekkor: {{time}} ({{email}}). Ha most küldesz, másodszor is írsz neki.",
@@ -188,9 +188,9 @@
"deleteTitle": "Törlöd a whitelista-bejegyzést?",
"deleteConfirm": "Elfelejted {{email}} címet? A Siftlode eldobja a bejegyzést, így az adott címről újra kérelmezni kell.",
"deleteHasAccount": "Erre a címre már regisztrált valaki — a Felhasználók és szerepek fülön kezelhető, ahol a fiók törlése ezt a bejegyzést is eltávolítja.",
"deleted": "Whitelista-bejegyzés eltávolítva.",
"deleteFailed": "A bejegyzést nem sikerült eltávolítani.",
"emailOff": "Az e-mail-küldés nincs beállítva, így senki nem kap megerősítő linket, és e-mailben egy kérelem sem jut el hozzád. Ezt a listát és az értesítéseidet figyeld helyette, a javítás pedig a Konfiguráció → E-mail / SMTP fülön van.",
"deleted": "Whitelista-bejegyzés törölve",
"deleteFailed": "Nem sikerült törölni a bejegyzést",
"emailOff": "Az e-mail-küldés nincs beállítva, így senki nem kap megerősítő linket, és e-mailben egy kérelem sem jut el hozzád. Ezt a listát és az Értesítéseket figyeld helyette, a javítás pedig a Konfiguráció → E-mail / SMTP fülön van.",
"deletedStillAllowed": "A bejegyzés törölve, de ezt a címet a szerver konfigurációja továbbra is engedélyezi, tehát megmarad a hozzáférése. Vedd ki az ALLOWED_EMAILS / ADMIN_EMAILS listából, ha ezen változtatnál.",
"deleteConfirmStillAllowed": "Elfelejted {{email}} címet? Figyelem: ezt a címet a szerver konfigurációja is engedélyezi (ALLOWED_EMAILS / ADMIN_EMAILS), így a bejegyzés törlése NEM veszi el a hozzáférését — sőt egy ADMIN_EMAILS cím a következő Google-belépéskor újra adminná válik."
},
@@ -215,14 +215,12 @@
"server_one": "Ezt a Siftlode szervere is megkapja, és {{count}} napig őrzi, hogy a hibát ki lehessen nyomozni.",
"server_other": "Ezt a Siftlode szervere is megkapja, és {{count}} napig őrzi, hogy a hibát ki lehessen nyomozni.",
"never": "Jelszó, token, e-mail-cím és az üzeneteid tartalma soha nem kerül bele.",
"lapse": "24 óra múlva magától visszaáll Alapra.",
"lapse": "24 óra múlva magától visszaáll „Alap” szintre.",
"policy": "Adatvédelmi irányelvek",
"cancel": "Mégse",
"confirm": "Bekapcsolom 24 órára"
},
"status": "{{level}} naplózás · visszaáll Alapra {{time}} múlva",
"hoursMinutes": "{{h}} ó {{m}} p",
"minutes": "{{m}} p",
"status": "{{level}} naplózás · {{time}} múlva visszaáll „Alap” szintre",
"revert": "Visszaállítás most",
"demo": "A demo fiókot többen használják, ezért itt nem kapcsolható be részletes naplózás.",
"failed": "Nem sikerült módosítani a naplózási szintet"
+8 -8
View File
@@ -8,16 +8,16 @@
"desc": "Ez az első fiók, egyben adminisztrátor. A telepítés után ezzel az e-maillel és jelszóval jelentkezel be.",
"email": "E-mail-cím",
"password": "Jelszó (min. {{n}} karakter)",
"invalidEmail": "Adj meg érvényes e-mail-címet.",
"shortPassword": "A jelszó legalább {{n}} karakter legyen."
"invalidEmail": "Adj meg érvényes e-mail-címet",
"shortPassword": "A jelszó legalább {{n}} karakter legyen"
},
"google": {
"title": "Google bejelentkezés (opcionális)",
"desc": "Illeszd be a Google OAuth kliens-azonosítót és -titkot a Google-bejelentkezés és a YouTube-hozzáférés engedélyezéséhez. Hagyd üresen mindkettőt a kihagyáshoz — e-mail+jelszó enélkül is működik.",
"clientId": "Google kliens-azonosító",
"clientSecret": "Google kliens-titok",
"bothRequired": "Add meg a kliens-azonosítót és a -titkot is, vagy hagyd üresen mindkettőt a kihagyáshoz.",
"skipHint": "Hagyd üresen, ha később, az admin Configuration oldalon állítanád be."
"bothRequired": "Add meg a kliens-azonosítót és a -titkot is, vagy hagyd üresen mindkettőt a kihagyáshoz",
"skipHint": "Hagyd üresen, ha később, az admin Konfiguráció oldalon állítanád be."
},
"smtp": {
"title": "E-mail / SMTP (opcionális)",
@@ -29,9 +29,9 @@
"password": "SMTP-jelszó",
"testTo": "Teszt-e-mail ide…",
"test": "Teszt küldése",
"testOk": "Teszt-e-mail elküldve — nézd meg a postafiókot.",
"testFail": "Nem sikerült elküldeni — ellenőrizd a beállításokat.",
"skipHint": "Hagyd üresen, ha később, az admin Configuration oldalon állítanád be."
"testOk": "Teszt-e-mail elküldve — nézd meg a postafiókot",
"testFail": "Nem sikerült elküldeni — ellenőrizd a beállításokat",
"skipHint": "Hagyd üresen, ha később, az admin Konfiguráció oldalon állítanád be."
},
"finish": {
"title": "Minden kész!",
@@ -46,5 +46,5 @@
"title": "Setup-link szükséges",
"body": "Nyisd meg a varázslót a konténer logjaiba az első indításkor kiírt linkkel (így néz ki: …/setup?token=…). Ez azért kell, hogy csak az üzemeltető tudja beállítani a példányt."
},
"genericError": "Valami hiba történt — próbáld újra."
"genericError": "Valami hiba történt — próbáld újra"
}
+2 -2
View File
@@ -36,7 +36,7 @@
"year": "Év"
},
"year": {
"more": "Több…",
"more": "Több",
"moreLabel": "Összes év",
"none": "Még nincs év",
"sortBy": "Rendezés",
@@ -54,7 +54,7 @@
"content": {
"normal": "Normál",
"shorts": "Shorts",
"live": "Élő / Hamarosan"
"live": "Élő / hamarosan"
},
"datePresetLabel": "Feltöltés dátuma",
"datePreset": {
+4 -4
View File
@@ -4,14 +4,14 @@
"system": "Rendszer"
},
"title": "API-kvóta használat",
"rangeDays": "{{count}} nap",
"rangeDays": "{{d}}",
"rangeLabel": "Időtartomány",
"introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ",
"introSystem": "Rendszer",
"introAfter": " a megosztott háttérmunka (ütemezett letöltés, gazdagítás, újraszinkronizálás), amely nem köthető egy személyhez.",
"loading": "Betöltés…",
"noData": "Nincs adat.",
"dailyTotal": "Napi összesen ({{count}} nap · {{units}} egység)",
"dailyTotal": "Napi összesen ({{d}} · {{units}} egység)",
"noUsageInRange": "Nincs használat ebben az időszakban.",
"dailyTooltip": "{{day}}: {{units}} egység",
"system": "Rendszer (háttér)",
@@ -39,12 +39,12 @@
"byAction": "Művelet szerint (30 nap)",
"noUsage": "Még nincs használat.",
"actions": "Műveletek",
"syncSubscriptions": "Feliratkozások szinkronizálása",
"syncSubscriptions": "Feliratkozások szinkronizálása a YouTube-ról",
"syncSubscriptionsHint": "Újraimportálja a YouTube-feliratkozásaidat és behúzza az újonnan követett csatornákat.",
"backfillEverything": "Minden letöltése",
"backfillEverythingHint": "Teljes archívum letöltésének kérése minden csatornához, amelyre fel vagy iratkozva. A régebbi videók és a teljes keresés annyira töltődnek fel, amennyit a megosztott napi kvóta enged.",
"synced": "{{count}} feliratkozás szinkronizálva",
"syncFailed": "A szinkronizálás sikertelen",
"syncFailed": "Nem sikerült szinkronizálni a feliratkozásokat",
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
"admin": "Adminisztrátor",
+2 -2
View File
@@ -9,6 +9,6 @@
"empty": "Még nincs címke — adj hozzá lentebb.",
"newPlaceholder": "Új címke neve…",
"add": "Hozzáad",
"renameFailed": "Nem sikerült átnevezni a címkét.",
"deleteFailed": "Nem sikerült törölni a címkét."
"renameFailed": "Nem sikerült átnevezni a címkét",
"deleteFailed": "Nem sikerült törölni a címkét"
}
+9 -9
View File
@@ -13,12 +13,12 @@
"inactive": "aktiválásra vár",
"activate": "Aktiválás",
"activateHint": "Beengedi ezt a fiókot. A címe már jóvá van hagyva — ez csak befejezi.",
"activated": "{{email}} mostantól be tud lépni.",
"activateFailed": "Nem sikerült aktiválni a fiókot.",
"activated": "{{email}} mostantól be tud lépni",
"activateFailed": "Nem sikerült aktiválni a fiókot",
"resend": "Megerősítő link újraküldése",
"resendHint": "Újraküldi a megerősítő linket, a fiókon szereplő címre.",
"resent": "Megerősítő link elküldve ide: {{email}}.",
"resendFailed": "Nem sikerült elküldeni a megerősítő linket."
"resent": "Megerősítő link elküldve ide: {{email}}",
"resendFailed": "Nem sikerült elküldeni a megerősítő linket"
},
"roles": {
"title": "Felhasználók és szerepek",
@@ -42,7 +42,7 @@
"demoteTitle": "Elveszed az admin jogot?",
"promoteConfirm": "Teljes admin hozzáférést adsz neki: {{email}} (beállítások, ütemező, felhasználókezelés)?",
"demoteConfirm": "Elveszed az admin jogot tőle: {{email}}? Sima felhasználó lesz.",
"failed": "Nem sikerült módosítani a szerepkört."
"failed": "Nem sikerült módosítani a szerepkört"
},
"suspend": {
"action": "Felfüggeszt",
@@ -55,7 +55,7 @@
"undone": "{{email}} felfüggesztése feloldva",
"demoLocked": "A demo fiók nem függeszthető fel.",
"selfLocked": "A saját fiókodat nem függesztheted fel.",
"failed": "Nem sikerült módosítani a felfüggesztést."
"failed": "Nem sikerült módosítani a felfüggesztést"
},
"delete": {
"action": "Törlés",
@@ -63,7 +63,7 @@
"title": "Törlöd a fiókot?",
"confirm": "Véglegesen törlöd: {{email}} és minden adatát — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások? Ez nem visszavonható.",
"done": "{{email}} törölve",
"failed": "Nem sikerült törölni a fiókot."
"failed": "Nem sikerült törölni a fiókot"
},
"account": {
"access": "Hozzáférés",
@@ -75,7 +75,7 @@
"approvalWaitingOnYou": "rád vár",
"copied": "Másolva",
"copy": "Másolás",
"copyFailed": "Nem sikerült másolni — jelöld ki a linket, és másold kézzel.",
"copyFailed": "Nem sikerült másolni — jelöld ki a linket, és másold kézzel",
"email": "E-mail",
"emailConfirmed": "megerősítve",
"emailUnconfirmed": "nincs megerősítve",
@@ -97,7 +97,7 @@
"passwordSet": "be van állítva",
"recovery": "Helyreállítás",
"resend": "Megerősítő link küldése",
"resendSent": "Megerősítő link elküldve.",
"resendSent": "Megerősítő link elküldve",
"setPassword": "Jelszóbeállító link készítése",
"setPasswordAgain": "Új link készítése",
"linkFailed": "Nem sikerült létrehozni a linket. Próbáld újra."
+2 -2
View File
@@ -7,8 +7,8 @@
"delete": "Törlés",
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
"reorder": "Húzd az átrendezéshez",
"actionFailed": "Nem sikerült frissíteni a mentett nézeteket — próbáld újra.",
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra.",
"actionFailed": "Nem sikerült frissíteni a mentett nézeteket — próbáld újra",
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra",
"saved": "Mentett nézetek",
"manage": "Nézetek kezelése…",
"manageTitle": "Személyes nézetek",
+13 -13
View File
@@ -11,10 +11,10 @@
},
"preview": {
"feed": "A Feed — szűrés csatorna, nyelv, téma, hossz és dátum szerint.",
"channels": "Csatorna-kezelő",
"channels": "Csatornák",
"playlists": "Lejátszási listák",
"player": "Beépített lejátszó",
"downloads": "Letöltésközpont",
"downloads": "Letöltések",
"search": "Keresési találatok",
"plex": "A Plex-könyvtárad"
},
@@ -41,7 +41,7 @@
"body": "A beépített lejátszó onnan folytatja a videót, ahol abbahagytad — billentyű- és görgetés-vezérléssel."
},
"downloads": {
"title": "Letöltésközpont",
"title": "Letöltések",
"body": "Ments videókat a szerveredre Plex-barát elrendezésben, vágd vagy fűzd össze őket, majd oszd meg egy linkkel."
},
"plex": {
@@ -61,8 +61,8 @@
"email": "E-mail",
"password": "Jelszó",
"newPassword": "Új jelszó (min. {{min}} karakter)",
"invalidEmail": "Adj meg egy érvényes e-mail-címet.",
"weakPassword": "A jelszónak legalább {{min}} karakteresnek kell lennie.",
"invalidEmail": "Adj meg egy érvényes e-mail-címet",
"weakPassword": "A jelszónak legalább {{min}} karakteresnek kell lennie",
"genericError": "Valami hiba történt. Próbáld újra.",
"working": "Egy pillanat…",
"signinButton": "Belépés",
@@ -84,17 +84,17 @@
"demoDenied": "Ez az e-mail nincs engedélyezve a demóhoz. Kérd az admint, hogy adja hozzá.",
"registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért, és a Spam mappát is, ha egy percen belül nem jön meg. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.",
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod, és a Spam mappát is, ha egy percen belül nem érkezik meg.",
"resetDone": "A jelszavad be van állítva — most már beléphetsz.",
"resetDone": "A jelszavad be van állítva — most már beléphetsz",
"deadLink": {
"title": "Ez a link már nem érvényes",
"body": "A jelszó-visszaállító link egy óráig él, és egyszer használható. Ez lejárt, már felhasználták, vagy olyan fiókhoz tartozik, ami már nem létezik — nincs mire jelszót állítani. Kérj újat, és elküldjük.",
"short": "Ez a visszaállító link már nem érvényes.",
"short": "Ez a visszaállító link már nem érvényes",
"cta": "Új link kérése"
},
"verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.",
"verifyOkReset": "E-mail megerősítve. A fiók regisztrációkori jelszavát biztonsági okból korábban töröltük — válassz újat a belépéshez.",
"verifyOkResetCta": "Új jelszó beállítása",
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.",
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt",
"verifyUnreachable": "Nem sikerült elérni a szervert a link megerősítéséhez, így az nem is használódott el. Próbáld megnyitni újra egy pillanat múlva.",
"resendCta": "Új link küldése",
"resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod, és a Spam mappát is, ha egy percen belül nem érkezik meg.",
@@ -116,26 +116,26 @@
"email_unverified": {
"title": "Erősítsd meg az e-mail-címed",
"body": "A jelszavad jó — csak addig nem engedhetünk be, amíg a fiókon lévő cím nincs megerősítve. Nyisd meg a kiküldött linket, vagy kérj újat.",
"short": "Előbb erősítsd meg az e-mail-címed — nézd meg a postafiókod."
"short": "Előbb erősítsd meg az e-mail-címed — nézd meg a postafiókod"
},
"pending_approval": {
"title": "Jóváhagyásra vár",
"body": "A jelszavad jó, a címed megerősítve. Már csak arra várunk, hogy egy admin beengedjen — szólunk e-mailben, amint döntött.",
"short": "A fiókod admin jóváhagyásra vár."
"short": "A fiókod admin jóváhagyásra vár"
},
"suspended": {
"title": "Felfüggesztett fiók",
"body": "Ez a fiók fel van függesztve, így semmilyen módon nem tud belépni.",
"short": "Ez a fiók fel van függesztve."
"short": "Ez a fiók fel van függesztve"
},
"invalid": {
"short": "Hibás e-mail-cím vagy jelszó."
"short": "Hibás e-mail-cím vagy jelszó"
},
"rate_limited": {
"short": "Túl sok próbálkozás. Próbáld újra kicsit később."
}
},
"accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól.",
"accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól",
"suspended": "Ez a fiók fel van függesztve, nem tud belépni. Ha tévedésnek gondolod, keresd az üzemeltetőt (a részletek az e-mailedben).",
"deleted": "A fiókod és minden adata véglegesen törölve lett. A megerősítést e-mailben is elküldtük."
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// `local/date-format` (eslint.config.js): docs/DESIGN.md "Copy" — a date is shown in the forms
// lib/format.ts builds, in the UI language. This pins that the rule fires on the ways a date used to
// be formatted by hand, and where it stands down.
const RULE = "local/date-format";
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
describe("a hand-formatted date", () => {
it("is refused as toLocale*String or an Intl date formatter", async () => {
expect(await hits("export const a = (d: Date) => d.toLocaleString('en');\n")).toBe(1);
expect(await hits("export const b = (d: Date) => d.toLocaleDateString();\n")).toBe(1);
expect(await hits("export const c = (d: Date) => d.toLocaleTimeString();\n")).toBe(1);
expect(
await hits("export const e = new Intl.DateTimeFormat('en', { month: 'short' });\n"),
).toBe(1);
expect(await hits("export const f = new Intl.RelativeTimeFormat('en');\n")).toBe(1);
});
it("is left alone in lib/format.ts, in a test and for a number format", async () => {
const src = "export const g = (d: Date) => d.toLocaleString('en');\n";
expect(await hits(src, "src/lib/format.ts")).toBe(0);
expect(await hits(src, "src/lib/format.test.ts")).toBe(0);
expect(await hits("export const h = new Intl.NumberFormat('en');\n")).toBe(0);
});
});
+1 -1
View File
@@ -43,7 +43,7 @@ describe("failCopyKeys", () => {
).toBe("Sign-in required");
// A category this client has no copy for (a newer backend): the generic sentence, never a key.
expect(i18n.t(failCopyKeys({ error_code: "rate_limited", error_detail: null }, "short"))).toBe(
"Download failed",
"Couldn't download",
);
});
});
+30 -15
View File
@@ -3,6 +3,7 @@ import {
RELATIVE_UNITS,
formatDate,
formatDateTime,
formatDateTimeSec,
formatNumber,
formatRuntime,
formatTotalHours,
@@ -161,7 +162,7 @@ describe("formatNumber", () => {
it("carries the UI language into the channel total-hours label", () => {
i18nStub.language = "hu";
expect(formatTotalHours(12345 * 3600)).toBe(`${new Intl.NumberFormat("hu").format(12345)} h`);
expect(formatTotalHours(12345 * 3600)).toBe(`${new Intl.NumberFormat("hu").format(12345)} ó`);
});
});
@@ -170,26 +171,32 @@ describe("formatRuntime", () => {
[null, ""],
[undefined, ""],
[0, ""], // nothing to show — the call sites suppress their separator on the empty string
[1, "1s"],
[45, "45s"], // one Short: the regression case
[45.7, "45s"], // truncates, so the seconds tier cannot overstate either
[59, "59s"], // last second before the minute tier
[60, "1m"],
[90, "1m"], // floors, never rounds up — a total must not overstate
[90.9, "1m"],
[3599, "59m"],
[3600, "1h 0m"], // the zero minutes stay: "1h" alone reads as an approximation
[5025, "1h 23m"],
[360000, "100h 0m"], // a large aggregate keeps counting hours rather than rolling to days
[1, "1 sec"],
[45, "45 sec"], // one Short: the regression case
[45.7, "45 sec"], // truncates, so the seconds tier cannot overstate either
[59, "59 sec"], // last second before the minute tier
[60, "1 min"],
[90, "1 min"], // floors, never rounds up — a total must not overstate
[90.9, "1 min"],
[3599, "59 min"],
[3600, "1 hr 0 min"], // the zero minutes stay: "1 hr" alone reads as an approximation
[5025, "1 hr 23 min"],
[360000, "100 hr 0 min"], // a large aggregate keeps counting hours rather than rolling to days
];
it.each(CASES)("formats %o as %o", (sec, expected) => {
expect(formatRuntime(sec)).toBe(expected);
});
it("speaks the UI language's units (DESIGN Copy: Intl short), not English letters", () => {
i18nStub.language = "hu";
expect(formatRuntime(5025)).toBe("1 ó 23 p");
i18nStub.language = "en";
});
it("never reports a non-zero duration as zero", () => {
for (let sec = 1; sec < 3600; sec++) {
expect(formatRuntime(sec), `${sec}s`).not.toBe("0m");
expect(formatRuntime(sec), `${sec}s`).not.toBe("0 min");
}
});
});
@@ -212,12 +219,20 @@ describe("formatDateTime / formatDate", () => {
const asEnglish = formatDateTime(iso);
i18nStub.language = "hu";
const asHungarian = formatDateTime(iso);
expect(asEnglish).toBe(new Date(iso).toLocaleString("en"));
expect(asHungarian).toBe(new Date(iso).toLocaleString("hu"));
expect(asEnglish).not.toBe(asHungarian);
expect(formatDate(iso, "en")).not.toBe(formatDate(iso, "hu"));
});
it("names the month and stops at the minute; seconds only in the log form (DESIGN Copy)", () => {
process.env.TZ = "UTC";
const iso = "2026-09-17T08:30:14Z";
expect(formatDateTime(iso)).toBe("Sep 17, 2026, 08:30 AM");
expect(formatDateTimeSec(iso)).toBe("Sep 17, 2026, 08:30:14 AM");
i18nStub.language = "hu";
expect(formatDateTime(iso)).toBe("2026. szept. 17. 08:30");
expect(formatDate(iso, "hu")).toBe("2026. szept. 17.");
});
it("shows a date-only value as that calendar day in every time zone", () => {
process.env.TZ = "America/New_York";
expect(formatDate("2026-09-17", "en")).toBe("Sep 17, 2026");
+41 -15
View File
@@ -96,19 +96,34 @@ export function formatDuration(sec: number | null): string {
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
/** Compact human runtime ("1h 30m", "45m", "45s") from seconds. Formats BOTH a single item's
* length (Plex movies/episodes) and an aggregate total (playlist playtime, on the YouTube and the
* Plex side alike) — it is the one renderer for a playlist total, so a total never appears in two
* shapes on one screen. Empty string for a missing/zero duration (nothing to show).
// A duration's unit in the UI language, in the browser's own short form (docs/DESIGN.md "Copy", E28
// S3g): "90 min", "2 hr" / "90 p", "2 ó". Hand-typed "90m" / "2h" were English in both languages and
// came in three spellings. One formatter per language × unit, built lazily (the grids format one per card).
export type DurationUnit = "second" | "minute" | "hour" | "day";
const unitFormatters = new Map<string, Intl.NumberFormat>();
export function formatUnit(value: number, unit: DurationUnit): string {
const key = `${i18n.language}|${unit}`;
let f = unitFormatters.get(key);
if (!f) {
f = new Intl.NumberFormat(i18n.language, { style: "unit", unit, unitDisplay: "short" });
unitFormatters.set(key, f);
}
return f.format(value);
}
/** Compact human runtime ("1 hr 30 min", "45 min", "45 sec"; HU "1 ó 30 p") from seconds. Formats
* BOTH a single item's length (Plex movies/episodes) and an aggregate total (playlist playtime, on
* the YouTube and the Plex side alike) — it is the one renderer for a playlist total, so a total
* never appears in two shapes on one screen. Empty string for a missing/zero duration.
* The sub-minute tier is load-bearing, not decoration: an aggregate is the only input that can land
* between 1 and 59 seconds (a playlist of Shorts), and flooring that to a flat "0m" made a
* between 1 and 59 seconds (a playlist of Shorts), and flooring that to a flat "0 min" made a
* non-empty playlist report no runtime at all. Keep it, or that case silently regresses. */
export function formatRuntime(sec?: number | null): string {
if (!sec) return "";
if (sec < 60) return `${Math.floor(sec)}s`;
if (sec < 60) return formatUnit(Math.floor(sec), "second");
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
return h ? `${h}h ${m}m` : `${m}m`;
return h ? `${formatUnit(h, "hour")} ${formatUnit(m, "minute")}` : formatUnit(m, "minute");
}
// Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved
@@ -128,11 +143,11 @@ export function formatEta(seconds: number): string {
return i18n.t("time.eta.days", { count: Math.round(hours / 24) });
}
/** Compact whole-channel total length ("12 h", "45 m"), hours-scale so H:MM:SS isn't huge. */
/** Compact whole-channel total length ("12 hr", "45 min"), hours-scale so H:MM:SS isn't huge. */
export function formatTotalHours(sec: number): string {
if (!sec) return "—";
const h = Math.round(sec / 3600);
return h >= 1 ? `${formatNumber(h)} h` : `${Math.max(1, Math.round(sec / 60))} m`;
return h >= 1 ? formatUnit(h, "hour") : formatUnit(Math.max(1, Math.round(sec / 60)), "minute");
}
// One grouped-number formatter per language, built lazily — the same reason as the relative-time
@@ -169,10 +184,23 @@ export function formatCountOrDash(n: number | null | undefined): string {
return n != null ? formatViews(n) : "—";
}
// Absolute date AND time in the UI language (e.g. "16 Sept 2026, 08:30:14"). A bare `toLocaleString()`
// formats in the browser's locale instead, so an English UI showed a Hungarian-shaped date. Empty for null.
// The app's date forms (docs/DESIGN.md "Copy", E28 S3g), all in the UI language — never a bare
// `toLocaleString()`, which formats in the BROWSER's locale (an English UI showed a Hungarian-shaped
// date) and, with no options, as numbers with seconds ("9/27/2026, 8:30:14 AM"):
// relativeTime "3d ago" / "3 napja" — recency in a list; the exact form on hover
// formatDate "Sep 27, 2026" / "2026. szept. 27."
// formatDateTime "Sep 27, 2026, 08:30 AM" / "2026. szept. 27. 08:30"
// formatDateTimeSec the same with seconds — only the server log, the audit and the recorder
// lint (`no-restricted-syntax`) keeps date formatting in this file. Empty for null.
const DAY: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", day: "numeric" };
const MINUTE: Intl.DateTimeFormatOptions = { ...DAY, hour: "2-digit", minute: "2-digit" };
export function formatDateTime(iso: string | null | undefined): string {
return iso ? new Date(iso).toLocaleString(i18n.language) : "";
return iso ? new Date(iso).toLocaleString(i18n.language, MINUTE) : "";
}
export function formatDateTimeSec(iso: string | null | undefined): string {
return iso ? new Date(iso).toLocaleString(i18n.language, { ...MINUTE, second: "2-digit" }) : "";
}
// Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null.
@@ -183,9 +211,7 @@ export function formatDate(iso: string | null | undefined, lang: string): string
if (!iso) return "";
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(iso);
return new Date(iso).toLocaleDateString(lang, {
year: "numeric",
month: "short",
day: "numeric",
...DAY,
...(dateOnly ? { timeZone: "UTC" } : {}),
});
}