From d3ac58a3fbeead2001e7797cae7cd442a04cba91 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 10:52:07 +0100 Subject: [PATCH 01/13] refactor(server): read the media sort parameters through one helper --- internal/server/media.go | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/internal/server/media.go b/internal/server/media.go index c6d06459..6cd529c3 100644 --- a/internal/server/media.go +++ b/internal/server/media.go @@ -221,20 +221,23 @@ func applyMediaDates(query url.Values, filter *media.Filter) error { // applyMediaOrdering reads the orderby and order query parameters into filter. func applyMediaOrdering(query url.Values, filter *media.Filter) error { - if raw, ok := query["orderby"]; ok { - orderBy, err := media.ParseOrderBy(raw[0]) - if err != nil { - return err - } - filter.OrderBy = orderBy + if err := parseGiven(query, "orderby", media.ParseOrderBy, &filter.OrderBy); err != nil { + return err } - if raw, ok := query["order"]; ok { - order, err := media.ParseOrder(raw[0]) - if err != nil { - return err - } - filter.Order = order + return parseGiven(query, "order", media.ParseOrder, &filter.Order) +} + +// parseGiven reads the named query parameter through parse into target, untouched when the query lacks it. +func parseGiven[T any](query url.Values, name string, parse func(string) (T, error), target *T) error { + raw, ok := query[name] + if !ok { + return nil + } + value, err := parse(raw[0]) + if err != nil { + return err } + *target = value return nil } From 981591c0b93f8272bf16f591168099ee842f2af9 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 10:52:07 +0100 Subject: [PATCH 02/13] feat(server): send the author avatar with each listed media file --- internal/server/content_avatar_test.go | 40 ++++++---- internal/server/media.go | 33 ++++++-- internal/server/media_avatar_test.go | 100 +++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 internal/server/media_avatar_test.go diff --git a/internal/server/content_avatar_test.go b/internal/server/content_avatar_test.go index b7c8e57c..a416088c 100644 --- a/internal/server/content_avatar_test.go +++ b/internal/server/content_avatar_test.go @@ -32,16 +32,27 @@ type avatarBody struct { AuthorAvatar *string `json:"author_avatar"` } -// postAuthor is who wrote one post authoredServer stores, by name and email. -type postAuthor struct { +// itemAuthor is who wrote one listed item, by name and email. +type itemAuthor struct { name string email string } +// secondAuthor is the writer of the avatar tests beside Maria Perez. +var secondAuthor = itemAuthor{name: "Second Author", email: "second@example.com"} + // authoredPosts are the posts authoredServer stores, each title with its author. -var authoredPosts = map[string]postAuthor{ +var authoredPosts = map[string]itemAuthor{ "Hello World": {name: "Maria Perez", email: "maria@example.com"}, - "Notes": {name: "Ada Lovelace", email: "ada@example.com"}, + "Notes": secondAuthor, +} + +// addAuthors stores the signed-in administrator, then Maria Perez and secondAuthor as authors, and returns both. +func addAuthors(t *testing.T, users *testkit.Store) (gouncer.User, gouncer.User) { + t.Helper() + addAda(t, users) + maria := addWithRole(t, users, "maria@example.com", "Maria Perez", role.Author) + return maria, addWithRole(t, users, secondAuthor.email, secondAuthor.name, role.Author) } // avatarListBody is the admin listing as the author cell reads it. @@ -70,12 +81,11 @@ func authoredServer( ) (http.Handler, content.Content) { t.Helper() users := newFakeUserStore() - ada := addAda(t, users) - maria := addWithRole(t, users, "maria@example.com", "Maria Perez", role.Author) + maria, second := addAuthors(t, users) posts := newFakePostStore() - posts.authors = map[uuid.UUID]string{maria.ID: maria.Name, ada.ID: ada.Name} + posts.authors = map[uuid.UUID]string{maria.ID: maria.Name, second.ID: second.Name} post := posts.add(newPost(t, "Hello World", maria.ID)) - posts.add(newPost(t, "Notes", ada.ID)) + posts.add(newPost(t, "Notes", second.ID)) handler := authedServerWithStores(t, server.Config{ Users: wrap(users), Content: posts, Types: newFakeTypeStore(), Avatars: options, }) @@ -100,21 +110,21 @@ var avatarCases = map[string]avatar.Options{ } // wantedAvatar returns the address the author cell must receive for the writer under options, empty with avatars off. -func wantedAvatar(writer postAuthor, options avatar.Options) string { +func wantedAvatar(writer itemAuthor, options avatar.Options) string { if options.Mode == avatar.ModeOff { return "" } return avatar.Address(writer.name, writer.email, authorCell, options) } -// checkListedAuthors fails for each listed post not shown with its own author and that author's avatar under options. -func checkListedAuthors(t *testing.T, body avatarListBody, options avatar.Options) { +// checkListedAuthors fails for each listed item lacking its author in authored or that author's avatar under options. +func checkListedAuthors(t *testing.T, body avatarListBody, options avatar.Options, authored map[string]itemAuthor) { t.Helper() - if len(body.Items) != len(authoredPosts) { - t.Fatalf("body = %+v, want the %d posts", body, len(authoredPosts)) + if len(body.Items) != len(authored) { + t.Fatalf("body = %+v, want the %d items", body, len(authored)) } for _, item := range body.Items { - writer := authoredPosts[item.Title] + writer := authored[item.Title] got, want := avatarOf(t, item), wantedAvatar(writer, options) if item.AuthorName != writer.name || got != want { t.Errorf("%q is by %q with author_avatar %q, want by %q with %q", @@ -146,7 +156,7 @@ func TestContentListSendsEachAuthorsAvatarAtTwiceTheAuthorCell(t *testing.T) { if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want %d: %s", recorder.Code, http.StatusOK, recorder.Body) } - checkListedAuthors(t, decodeBody[avatarListBody](t, recorder), options) + checkListedAuthors(t, decodeBody[avatarListBody](t, recorder), options, authoredPosts) }) } } diff --git a/internal/server/media.go b/internal/server/media.go index 6cd529c3..10e3f1f4 100644 --- a/internal/server/media.go +++ b/internal/server/media.go @@ -153,10 +153,11 @@ func (s *server) handleMediaUpload() http.HandlerFunc { } } -// mediaRow is one row of the admin media listing, carrying its author's name. +// mediaRow is one row of the admin media listing, carrying its author's name and the avatar the author cell draws. type mediaRow struct { mediaView - AuthorName string `json:"author_name"` + AuthorName string `json:"author_name"` + AuthorAvatar string `json:"author_avatar"` } // mediaListResponse is a page of media items with the total matching the filter and the page size the server used. @@ -301,14 +302,36 @@ func (s *server) handleMediaList() http.HandlerFunc { respondDomainError(w, err) return } - items := make([]mediaRow, len(rows)) - for i, m := range rows { - items[i] = mediaRow{mediaView: newMediaView(m.Media), AuthorName: m.AuthorName} + items, err := s.listedMedia(r.Context(), rows) + if err != nil { + respondDomainError(w, err) + return } authkit.Respond(w, http.StatusOK, mediaListResponse{Items: items, Total: total, PerPage: filter.PerPage}) } } +// listedMedia returns the rows of a media listing as the admin reads them, each author with the avatar the cell draws. +func (s *server) listedMedia(ctx context.Context, rows []media.Listed) ([]mediaRow, error) { + names := make(map[uuid.UUID]string, len(rows)) + for _, m := range rows { + names[m.AuthorID] = m.AuthorName + } + authors, err := s.authorsOf(ctx, names) + if err != nil { + return nil, err + } + items := make([]mediaRow, len(rows)) + for i, m := range rows { + items[i] = mediaRow{ + mediaView: newMediaView(m.Media), + AuthorName: m.AuthorName, + AuthorAvatar: authors[m.AuthorID].avatar, + } + } + return items, nil +} + // respondMediaByIDs answers the stored files the query names, in the order it names them. func (s *server) respondMediaByIDs(w http.ResponseWriter, r *http.Request) { ids, err := mediaIDs(r.URL.Query()) diff --git a/internal/server/media_avatar_test.go b/internal/server/media_avatar_test.go new file mode 100644 index 00000000..67b51594 --- /dev/null +++ b/internal/server/media_avatar_test.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 + +package server_test + +import ( + "net/http" + "testing" + + "github.com/google/uuid" + + "github.com/gopherium/gouncer/authkit/testkit" + "github.com/gopherium/gouncer/avatar" + + "github.com/gopherium/gophenberg/internal/media" + "github.com/gopherium/gophenberg/internal/mediahost" + "github.com/gopherium/gophenberg/internal/server" +) + +// uploadedMedia are the files authoredMediaServer stores, each title with who uploaded it. +var uploadedMedia = map[string]itemAuthor{ + "harbor": {name: "Maria Perez", email: "maria@example.com"}, + "sunrise": secondAuthor, +} + +// authoredMediaServer returns a signed-in server holding uploadedMedia, drawing avatars under options over users. +func authoredMediaServer( + t *testing.T, options avatar.Options, wrap func(*testkit.Store) server.UserStore, +) http.Handler { + t.Helper() + users := newFakeUserStore() + maria, second := addAuthors(t, users) + store := newFakeMediaStore() + store.authors = map[uuid.UUID]string{maria.ID: maria.Name, second.ID: second.Name} + accounts := map[string]uuid.UUID{maria.Email: maria.ID, second.Email: second.ID} + for title, uploader := range uploadedMedia { + if _, err := store.Create(t.Context(), media.Media{ + Type: media.TypeImage, Title: title, MimeType: "image/jpeg", AuthorID: accounts[uploader.email], + }); err != nil { + t.Fatalf("storing %q: %v", title, err) + } + } + return authedServerWithStores(t, server.Config{ + Users: wrap(users), Content: newFakePostStore(), Types: newFakeTypeStore(), + Media: mediahost.New(mediahost.Config{Dir: t.TempDir()}), MediaStore: store, Avatars: options, + }) +} + +func TestMediaListSendsEachUploadersAvatarAtTwiceTheAuthorCell(t *testing.T) { + t.Parallel() + + for name, options := range avatarCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + handler := authoredMediaServer(t, options, readable) + + recorder := doRequest(t, handler, http.MethodGet, "/api/media", "") + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", recorder.Code, http.StatusOK, recorder.Body) + } + checkListedAuthors(t, decodeBody[avatarListBody](t, recorder), options, uploadedMedia) + }) + } +} + +func TestMediaAvatarsReadNoAccountUnlessGravatarDrawsThem(t *testing.T) { + t.Parallel() + + for name, options := range map[string]avatar.Options{ + "initials": {Mode: avatar.ModeInitials}, + "off": {Mode: avatar.ModeOff}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + handler := authoredMediaServer(t, options, unreadable) + + recorder := doRequest(t, handler, http.MethodGet, "/api/media", "") + + if recorder.Code != http.StatusOK { + t.Errorf("GET /api/media = %d, want %d without reading accounts: %s", + recorder.Code, http.StatusOK, recorder.Body) + } + }) + } +} + +func TestMediaGravatarListReportsAFailedAccountRead(t *testing.T) { + t.Parallel() + + handler := authoredMediaServer(t, gravatarOptions, unreadable) + + recorder := doRequest(t, handler, http.MethodGet, "/api/media", "") + + if recorder.Code != http.StatusInternalServerError { + t.Errorf("GET /api/media = %d, want %d when the uploader emails cannot be read", + recorder.Code, http.StatusInternalServerError) + } +} From cdfd842e1721727b4f25de4bceb3c6a9f49f209c Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 10:52:08 +0100 Subject: [PATCH 03/13] feat(sdk): pass on the file type icons, Tooltip and Popover --- sdk/frontend/editor/index.ts | 2 +- sdk/frontend/index.ts | 5 +++++ sdk/frontend/test/menu-icons.test.ts | 33 +++++++++++++++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/sdk/frontend/editor/index.ts b/sdk/frontend/editor/index.ts index 8bce682c..a2ed134f 100644 --- a/sdk/frontend/editor/index.ts +++ b/sdk/frontend/editor/index.ts @@ -15,7 +15,7 @@ export { BlockPreview } from './preview' export { BlockToolbar } from './toolbar' export { getBlockTypes, parse, serialize } from '@wordpress/blocks' export type { Block } from '@wordpress/blocks' -export { Button, SlotFillProvider } from '@wordpress/components' +export { Button, Popover, SlotFillProvider } from '@wordpress/components' export { ShortcutProvider } from '@wordpress/keyboard-shortcuts' export { useStateWithHistory } from '@wordpress/compose' export { CURATED_BLOCKS, registerCuratedBlocks } from './blocks' diff --git a/sdk/frontend/index.ts b/sdk/frontend/index.ts index fed9e213..015e067f 100644 --- a/sdk/frontend/index.ts +++ b/sdk/frontend/index.ts @@ -51,9 +51,11 @@ export { Stack, Tabs, Text, + Tooltip, VisuallyHidden, } from '@wordpress/ui' export { + audio as audioIcon, commentAuthorAvatar as authorIcon, chevronLeft as backIcon, backup as backupIcon, @@ -62,6 +64,8 @@ export { closeSmall as closeIcon, customPostType as customPostTypeIcon, chevronDown as downIcon, + file as fileIcon, + image as imageIcon, language as languageIcon, list as listIcon, listView as listViewIcon, @@ -77,6 +81,7 @@ export { undo as undoIcon, chevronUp as upIcon, upload as uploadIcon, + video as videoIcon, } from '@wordpress/icons' export { Dialog } from './dialog' export { TextareaControl } from './TextareaControl' diff --git a/sdk/frontend/test/menu-icons.test.ts b/sdk/frontend/test/menu-icons.test.ts index 2c5fae7e..52d421e1 100644 --- a/sdk/frontend/test/menu-icons.test.ts +++ b/sdk/frontend/test/menu-icons.test.ts @@ -1,18 +1,38 @@ // SPDX-License-Identifier: Apache-2.0 -import { brush, category, customPostType, language, list, media, page, post, settings } from '@wordpress/icons' +import { + audio, + brush, + category, + customPostType, + file, + image, + language, + list, + media, + page, + post, + settings, + video, +} from '@wordpress/icons' +import { Tooltip as UiTooltip } from '@wordpress/ui' import { expect, test } from 'vitest' import { + audioIcon, brushIcon, categoryIcon, customPostTypeIcon, + fileIcon, + imageIcon, languageIcon, listIcon, mediaIcon, pageIcon, postIcon, settingsIcon, + Tooltip, + videoIcon, } from '../index' test('hands the sidebar rows the glyphs WordPress draws them with', () => { @@ -26,3 +46,14 @@ test('hands the sidebar rows the glyphs WordPress draws them with', () => { expect(customPostTypeIcon).toBe(customPostType) expect(listIcon).toBe(list) }) + +test('hands a media card the glyph WordPress draws for each kind of file', () => { + expect(imageIcon).toBe(image) + expect(videoIcon).toBe(video) + expect(audioIcon).toBe(audio) + expect(fileIcon).toBe(file) +}) + +test('hands out the tooltip WordPress shows a cut file name in', () => { + expect(Tooltip).toBe(UiTooltip) +}) From 9f960458749cad97ee7bc3e846d140df086a07ee Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 10:52:08 +0100 Subject: [PATCH 04/13] refactor(lists): share the rename send, hold any unsaved value and report a focused dialog --- frontend/src/lists/RenameModal.tsx | 42 +++++++++++++++++++++--------- frontend/src/lists/focusOnList.ts | 8 ++++++ frontend/src/lists/unsavedText.ts | 16 ++++++------ 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/frontend/src/lists/RenameModal.tsx b/frontend/src/lists/RenameModal.tsx index 82eefb28..8eb99982 100644 --- a/frontend/src/lists/RenameModal.tsx +++ b/frontend/src/lists/RenameModal.tsx @@ -7,10 +7,8 @@ import { __ } from '@wordpress/i18n' import { markLatest } from './unsavedText' import type { UnsavedText } from './unsavedText' -/** What the rename modal of a list takes beside the props DataViews hands a modal. */ -interface RenameModalProps extends RenderModalProps { - /** Returns the name an item carries now. */ - nameOf: (item: Item) => string +/** What the send of a new name for one item needs. */ +export interface RenameSend { /** Returns the id an item is known by. */ idOf: (item: Item) => string /** Writes the new name of an item. */ @@ -21,18 +19,25 @@ interface RenameModalProps extends RenderModalProps { unsaved: UnsavedText } +/** What the rename modal of a list takes beside the props DataViews hands a modal. */ +interface RenameModalProps extends RenderModalProps, RenameSend { + /** Returns the name an item carries now. */ + nameOf: (item: Item) => string +} + /** - * Renders the modal writing a new name for one item, closed the moment Save is pressed, its failure in a toast. - * @param props - The item, the handler closing the modal, the name and id readers, the write, the refresh and the - * unsaved names. - * @returns The rename form. + * Returns the send of a new name for one item, which closes the dialog at once and toasts how the write ended. + * @param item - The item. + * @param close - Takes the dialog away. + * @param send - The id reader, the write, the refresh and the unsaved names. + * @returns The send, given the name as typed. */ -export function RenameModal(props: RenameModalProps) { - const { items: [item], closeModal, nameOf, idOf, rename, refresh, unsaved } = props +export function useRenameSend(item: Item, close: (() => void) | undefined, send: RenameSend) { const toaster = useToaster() + const { idOf, rename, refresh, unsaved } = send const id = idOf(item) - const save = (name: string) => { - closeModal?.() + return (name: string) => { + close?.() unsaved.delete(id) const stillLatest = markLatest(unsaved, id) void rename(item, name).then( @@ -48,9 +53,20 @@ export function RenameModal(props: RenameModalProps) { }, ) } +} + +/** + * Renders the modal writing a new name for one item, closed the moment Save is pressed, its failure in a toast. + * @param props - The item, the handler closing the modal, the name and id readers, the write, the refresh and the + * unsaved names. + * @returns The rename form. + */ +export function RenameModal(props: RenameModalProps) { + const { items: [item], closeModal, nameOf } = props + const save = useRenameSend(item, closeModal, props) return ( +/** What a failed write left unsaved, by the id of the item it was typed for, the text of one field unless named. */ +export type UnsavedText = Map /** - * Returns the text failed writes left unsaved, the same store for as long as the screen stays. + * Returns what failed writes left unsaved, the same store for as long as the screen stays. * @returns The store a row form takes its first value from when it opens again. */ -export function useUnsavedText(): UnsavedText { - const [held] = useState(() => new Map()) +export function useUnsavedText(): UnsavedText { + const [held] = useState>(() => new Map()) return held } /** The latest write each store sent, by the id of the item it went to. */ -const latestWrites = new WeakMap>() +const latestWrites = new WeakMap, Map>() /** * Marks a write as the latest one sent for an item, the only one that may keep its text. - * @param unsaved - The store the write keeps its text in. + * @param unsaved - The store the latest writes of the item are marked on. * @param id - The item the write goes to. * @returns The check telling whether no later write went to the item since. */ -export function markLatest(unsaved: UnsavedText, id: string): () => boolean { +export function markLatest(unsaved: UnsavedText, id: string): () => boolean { const writes = latestWrites.get(unsaved) ?? new Map() latestWrites.set(unsaved, writes) const write = Symbol(id) From 763f9b683d08c620d31efdc29d0846b019d05e89 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 10:52:09 +0100 Subject: [PATCH 05/13] fix(content): close only the posts confirm that ran, its button in WordPress's words --- frontend/src/content/actions.tsx | 75 ++++++++++++++++-------- frontend/src/test/posts-actions.test.tsx | 27 ++++++++- frontend/src/test/posts-trash.test.tsx | 41 ++++++++++--- test/e2e/tests/content-list.spec.ts | 2 +- 4 files changed, 113 insertions(+), 32 deletions(-) diff --git a/frontend/src/content/actions.tsx b/frontend/src/content/actions.tsx index 4851a5ac..dcc9aa71 100644 --- a/frontend/src/content/actions.tsx +++ b/frontend/src/content/actions.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { backupIcon, sessionMayChange, trashIcon } from '@gophenberg/frontend-sdk' -import type { Action } from '@gophenberg/frontend-sdk/dataviews' +import type { Action, RenderModalProps } from '@gophenberg/frontend-sdk/dataviews' import { RunConfirm, useBulkRun, useToaster } from '@gopherium/godmin' import type { BulkRun, ListRun } from '@gopherium/godmin' import { useSession } from '@gopherium/react-auth' @@ -9,6 +9,7 @@ import { __, _x } from '@wordpress/i18n' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' +import { useCloseWhileOpen } from '../lists/closeWhileOpen' import { deleteQuestion, deleteWords } from '../lists/itemWords' import type { NameCut } from '../lists/itemWords' import { useUnsavedText } from '../lists/unsavedText' @@ -46,6 +47,54 @@ function useRunOver(list: ListRun): BulkRun { return useBulkRun(list, refreshPosts) } +/** What a confirm of the posts list takes beside the props DataViews hands a modal. */ +interface PostConfirmProps extends RenderModalProps { + /** Runs one call over the picked posts. */ + run: BulkRun + /** Cuts a title to the length a toast shows. */ + name: NameCut +} + +/** + * Renders the confirm moving the picked posts to the trash, closing only itself once the run ends. + * @param props - The posts, the handler closing the modal, the run and the name cut. + * @returns The confirm. + */ +function TrashConfirm({ items, closeModal, run, name }: PostConfirmProps) { + const close = useCloseWhileOpen(closeModal) + return ( + run(picked, (post) => trashPost(post.id), trashWords(name))} + > + {trashQuestion(items, name)} + + ) +} + +/** + * Renders the confirm deleting the picked posts for good, closing only itself once the run ends. + * @param props - The posts, the handler closing the modal, the run and the name cut. + * @returns The confirm. + */ +function DeleteConfirm({ items, closeModal, run, name }: PostConfirmProps) { + const close = useCloseWhileOpen(closeModal) + return ( + run(picked, (post) => deletePost(post.id), deleteWords(name, postName))} + > + {deleteQuestion(items, name, postName)} + + ) +} + /** * Returns the actions offered on the rows of a trash view. * @param mine - Whether the session may change a post. @@ -60,7 +109,6 @@ function trashViewActions( name: NameCut, type: string, ): Action[] { - const confirmLabel = __('Permanently delete', 'gophenberg') return [ { id: 'restore', @@ -79,16 +127,7 @@ function trashViewActions( isEligible: mine, hideModalHeader: true, modalFocusOnMount: 'firstContentElement', - RenderModal: (props) => ( - run(items, (post) => deletePost(post.id), deleteWords(name, postName))} - > - {deleteQuestion(props.items, name, postName)} - - ), + RenderModal: (props) => , }, ] } @@ -119,7 +158,6 @@ function listActions( served: boolean, unsaved: UnsavedForms, ): Action[] { - const confirmLabel = __('Move to trash', 'gophenberg') const changeable = (post: Post) => post.status !== 'trash' && mine(post) return [ viewAction(served), @@ -147,16 +185,7 @@ function listActions( isEligible: changeable, hideModalHeader: true, modalFocusOnMount: 'firstContentElement', - RenderModal: (props) => ( - run(items, (post) => trashPost(post.id), trashWords(name))} - > - {trashQuestion(props.items, name)} - - ), + RenderModal: (props) => , }, ] } diff --git a/frontend/src/test/posts-actions.test.tsx b/frontend/src/test/posts-actions.test.tsx index 0f033c35..a0c7d8ab 100644 --- a/frontend/src/test/posts-actions.test.tsx +++ b/frontend/src/test/posts-actions.test.tsx @@ -3,7 +3,7 @@ import { http, HttpResponse, server } from '@gophenberg/frontend-sdk/testing' import { LIST_CHROME_DOMAIN, listChromeCatalogFor } from '@gopherium/godmin' import { buttonClasses } from '@gopherium/godmin/testing' -import { screen, waitFor, within } from '@testing-library/react' +import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { resetLocaleData, setLocaleData } from '@wordpress/i18n' import { beforeAll, beforeEach, expect, onTestFinished, test, vi } from 'vitest' @@ -16,6 +16,7 @@ import { gate } from './gate' import { renderAt } from './render' import { storedPostWithId } from './postFixture' import { siteSettings } from './siteSettings' +import { clickDimmedPage, waitBusy } from './typeFixtures' beforeAll(async () => { await import('../content/EditorScreen') @@ -388,6 +389,30 @@ test('keeps the confirm open when Cancel is pressed while the trash runs', async await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) }) +test('leaves a dialog opened on the row after a trash dismissed while busy open once it ends', async () => { + const answer = gate() + server.use( + http.delete('/api/content/:id', async () => { + await answer.held + return HttpResponse.json({}, { status: 500 }) + }), + ) + renderAt('/content/post') + await trashTheListedPost() + const busy = screen.getByRole('dialog') + await waitBusy(busy, 'Move to trash') + await clickDimmedPage() + await openRowActions() + await userEvent.click(await screen.findByRole('menuitem', { name: 'Rename…' })) + const rename = await screen.findByRole('dialog', { name: 'Rename' }) + + answer.release() + await screen.findByText('The item could not be moved to the trash.', { selector: '.godmin-toast' }) + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) + + expect(screen.getByRole('dialog', { name: 'Rename' })).toBe(rename) +}) + test('speaks Cargando to a Spanish reader while the trash runs, as WordPress es_ES does', async () => { const answer = gate() server.use( diff --git a/frontend/src/test/posts-trash.test.tsx b/frontend/src/test/posts-trash.test.tsx index cc42e255..c7a685d4 100644 --- a/frontend/src/test/posts-trash.test.tsx +++ b/frontend/src/test/posts-trash.test.tsx @@ -17,6 +17,7 @@ import { gate } from './gate' import { renderAt, renderRoutedAt } from './render' import { storedPost } from './postFixture' import { siteSettings } from './siteSettings' +import { clickDimmedPage, waitBusy } from './typeFixtures' import { warmPostsScreen } from './warm' warmPostsScreen() @@ -474,7 +475,7 @@ test('deletes every ticked post for good behind one confirm and counts them', as await userEvent.click(screen.getByRole('button', { name: 'Permanently delete…' })) const dialog = await screen.findByRole('dialog') expect(dialog).toHaveTextContent('Delete 2 items for good? This cannot be undone.') - await userEvent.click(within(dialog).getByRole('button', { name: 'Permanently delete' })) + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete permanently' })) expect(await screen.findByText('2 items permanently deleted.', { selector: '.godmin-toast' })).toBeInTheDocument() expect(deleted).toHaveLength(2) @@ -524,7 +525,7 @@ test('opens a post deleted for good as a missing post', async () => { await goToList(router) await openTrashedRowActions() await userEvent.click(await screen.findByRole('menuitem', { name: 'Permanently delete…' })) - await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Permanently delete' })) + await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete permanently' })) await waitFor(() => expect(deleted).toHaveLength(1)) await goToEditor(router) @@ -628,7 +629,7 @@ test('deletes for good once confirmed', async () => { await openTrashedRowActions() await userEvent.click(await screen.findByRole('menuitem', { name: 'Permanently delete…' })) - await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Permanently delete' })) + await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete permanently' })) await waitFor(() => expect(deleted).toHaveLength(1)) expect(deleted[0].pathname).toBe(`/api/content/${TRASHED.id}`) @@ -640,7 +641,7 @@ test('names the post it deleted for good in a toast', async () => { await openTrashedRowActions() await userEvent.click(await screen.findByRole('menuitem', { name: 'Permanently delete…' })) - await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Permanently delete' })) + await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete permanently' })) expect(await screen.findByText('"Old Notes" permanently deleted.', { selector: '.godmin-toast' })).toBeInTheDocument() }) @@ -651,12 +652,38 @@ test('moves the focus to the list once a permanent delete finishes', async () => await openTrashedRowActions() await userEvent.click(await screen.findByRole('menuitem', { name: 'Permanently delete…' })) - await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Permanently delete' })) + await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete permanently' })) await screen.findByText('"Old Notes" permanently deleted.', { selector: '.godmin-toast' }) await waitFor(() => expect(screen.getByRole('region', { name: 'Posts' })).toHaveFocus()) }) +test('leaves a confirm opened on the row after a permanent delete dismissed while busy open once it ends', async () => { + const answer = gate() + server.use( + http.delete('/api/content/:id', async () => { + await answer.held + return HttpResponse.json({}, { status: 500 }) + }), + ) + renderAt('/content/post') + await openTrashedRowActions() + await userEvent.click(await screen.findByRole('menuitem', { name: 'Permanently delete…' })) + const busy = await screen.findByRole('dialog') + await userEvent.click(within(busy).getByRole('button', { name: 'Delete permanently' })) + await waitBusy(busy, 'Delete permanently') + await clickDimmedPage() + await userEvent.click(screen.getByRole('button', { name: 'Actions' })) + await userEvent.click(await screen.findByRole('menuitem', { name: 'Permanently delete…' })) + const reopened = await screen.findByRole('dialog') + + answer.release() + await screen.findByText('The item could not be permanently deleted.', { selector: '.godmin-toast' }) + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) + + expect(screen.getByRole('dialog')).toBe(reopened) +}) + test('keeps the post when the permanent delete is dismissed', async () => { renderAt('/content/post') await openTrashedRowActions() @@ -687,7 +714,7 @@ test('reports a permanent delete the server refused in a toast, never above the await openTrashedRowActions() await userEvent.click(await screen.findByRole('menuitem', { name: 'Permanently delete…' })) - await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Permanently delete' })) + await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete permanently' })) expect(await screen.findByText('The item could not be permanently deleted.', { selector: '.godmin-toast' })) .toBeInTheDocument() @@ -711,7 +738,7 @@ test('counts only the posts a partly refused bulk permanent delete could not rem await tickBothTrashed() await userEvent.click(screen.getByRole('button', { name: 'Permanently delete…' })) - await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Permanently delete' })) + await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete permanently' })) expect(await screen.findByText('1 item could not be permanently deleted.', { selector: '.godmin-toast' })) .toBeInTheDocument() diff --git a/test/e2e/tests/content-list.spec.ts b/test/e2e/tests/content-list.spec.ts index abd5804b..3606d7e5 100644 --- a/test/e2e/tests/content-list.spec.ts +++ b/test/e2e/tests/content-list.spec.ts @@ -265,7 +265,7 @@ test('restores one item from the Trash tab and deletes another for good', async await expect(page.locator('#root').getByText(`"${kept}" has been restored.`)).toBeVisible() await row(gone).getByRole('button', { name: 'Actions' }).click() await page.getByRole('menuitem', { name: 'Permanently delete…', exact: true }).click() - await page.getByRole('dialog').getByRole('button', { name: 'Permanently delete', exact: true }).click() + await page.getByRole('dialog').getByRole('button', { name: 'Delete permanently', exact: true }).click() await expect(page.locator('#root').getByText(`"${gone}" permanently deleted.`)).toBeVisible() await expect(page.getByRole('link', { name: kept, exact: true })).toBeHidden() From 07c4ad293864a50ae9a01f3c08d23b0f14a309fa Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 10:52:09 +0100 Subject: [PATCH 06/13] feat(media): list the media library with WordPress DataViews --- frontend/src/media/FieldsPane.tsx | 58 + frontend/src/media/MediaLibraryPicker.tsx | 94 +- frontend/src/media/MediaModals.tsx | 287 ++++ frontend/src/media/MediaScreen.tsx | 231 ++- frontend/src/media/actions.tsx | 218 +-- frontend/src/media/api.ts | 181 ++- frontend/src/media/fields.tsx | 487 +++++-- frontend/src/media/focusBack.ts | 72 + frontend/src/media/format.ts | 97 +- frontend/src/media/media.css | 111 +- frontend/src/router.tsx | 1 + frontend/src/test/date-cells.test.tsx | 51 +- frontend/src/test/editor-media.test.ts | 4 + frontend/src/test/media-actions.test.tsx | 1337 ++++++++++++++++++ frontend/src/test/media-api.test.ts | 183 ++- frontend/src/test/media-fields.test.ts | 94 -- frontend/src/test/media-fields.test.tsx | 470 ++++++ frontend/src/test/media-inserter.test.ts | 6 + frontend/src/test/media-library.test.tsx | 190 +-- frontend/src/test/media-list-view.test.tsx | 525 +++++++ frontend/src/test/media-picker.test.tsx | 132 +- frontend/src/test/media-upload.test.tsx | 121 +- frontend/src/test/media-words.test.ts | 95 ++ frontend/src/test/ownership-screens.test.tsx | 71 +- 24 files changed, 4245 insertions(+), 871 deletions(-) create mode 100644 frontend/src/media/FieldsPane.tsx create mode 100644 frontend/src/media/MediaModals.tsx create mode 100644 frontend/src/media/focusBack.ts create mode 100644 frontend/src/test/media-actions.test.tsx delete mode 100644 frontend/src/test/media-fields.test.ts create mode 100644 frontend/src/test/media-fields.test.tsx create mode 100644 frontend/src/test/media-list-view.test.tsx create mode 100644 frontend/src/test/media-words.test.ts diff --git a/frontend/src/media/FieldsPane.tsx b/frontend/src/media/FieldsPane.tsx new file mode 100644 index 00000000..2c87bf9d --- /dev/null +++ b/frontend/src/media/FieldsPane.tsx @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { __ } from '@wordpress/i18n' +import { useLayoutEffect, useRef, useState } from 'react' +import type { ReactNode } from 'react' + +/** + * Returns the class of the fields pane, marked as the components Modal marks its own content. + * @param scrollable - Whether the fields overflow the pane. + * @param scrolled - Whether the pane sits scrolled down from its top. + * @returns The class names, is-scrollable while the fields overflow and has-scrolled-content once it scrolled. + */ +function paneClass(scrollable: boolean, scrolled: boolean): string { + const marks = ['gophenberg-media__edit-fields'] + if (scrollable) { + marks.push('is-scrollable') + } + if (scrolled) { + marks.push('has-scrolled-content') + } + return marks.join(' ') +} + +/** + * Renders the pane the fields of Edit scroll in, as the components Modal treats its own content: named Scrollable + * section while the fields overflow it, checked each time they resize, and marked once it scrolled. It turns a tab stop + * only once the focus the Modal puts on its first field landed inside, so the Modal never picks the pane for that. + * @param props - The fields. + * @returns The pane. + */ +export function FieldsPane({ children }: { children: ReactNode }) { + const pane = useRef(null) + const content = useRef(null) + const [scrollable, setScrollable] = useState(false) + const [scrolled, setScrolled] = useState(false) + const [entered, setEntered] = useState(false) + useLayoutEffect(() => { + const scroller = pane.current as HTMLDivElement + const measure = () => setScrollable(scroller.scrollHeight > scroller.clientHeight) + const watcher = new ResizeObserver(measure) + watcher.observe(content.current as HTMLDivElement) + measure() + return () => watcher.disconnect() + }, []) + return ( +
setEntered(true)} + onScroll={(event) => setScrolled(event.currentTarget.scrollTop > 0)} + > +
{children}
+
+ ) +} diff --git a/frontend/src/media/MediaLibraryPicker.tsx b/frontend/src/media/MediaLibraryPicker.tsx index e5eaa60d..2df4924b 100644 --- a/frontend/src/media/MediaLibraryPicker.tsx +++ b/frontend/src/media/MediaLibraryPicker.tsx @@ -3,18 +3,19 @@ import { Button, Dialog, Stack, StyleProvider, Text } from '@gophenberg/frontend-sdk' import { DataViewsPicker } from '@gophenberg/frontend-sdk/dataviews' import type { View } from '@gophenberg/frontend-sdk/dataviews' +import { Popover, SlotFillProvider } from '@gophenberg/frontend-sdk/editor' import type { MediaLibraryProps } from '@gophenberg/frontend-sdk/editor' import { ErrorNotice, paginationOf } from '@gopherium/godmin' -import { useQuery } from '@tanstack/react-query' +import { keepPreviousData, useQuery } from '@tanstack/react-query' import { __ } from '@wordpress/i18n' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useListSettings } from '../settings/useListSettings' import './media.css' import { listMedia, mediaQueryKey } from './api' import type { MediaItem, MediaPage } from './api' import { toAttachment } from './editorMedia' -import { mediaFields } from './fields' +import { mediaFields, mediaListing } from './fields' const PREVIEW_SIZE = 170 @@ -67,16 +68,18 @@ export function MediaLibraryPicker({ render, }: MediaLibraryProps) { const settings = useListSettings().data + const fields = useMemo(() => mediaFields(), []) const [open, setOpen] = useState(false) const [held, setHeld] = useState(INITIAL_VIEW) const view = { ...held, perPage: held.perPage ?? settings?.picker_page_size } const [selection, setSelection] = useState([]) const takesMany = multiple === true || multiple === 'add' - const mimes = allowedMimes(allowedTypes) + const asked = { mimes: allowedMimes(allowedTypes), ...mediaListing(view), page: view.page, perPage: view.perPage } const media = useQuery({ - queryKey: [...mediaQueryKey, 'picker', mimes.join(','), view.search, view.page, view.perPage], - queryFn: () => listMedia({ mimes, search: view.search, page: view.page, perPage: view.perPage }), + queryKey: [...mediaQueryKey, 'picker', asked], + queryFn: () => listMedia(asked), enabled: open, + placeholderData: keepPreviousData, }) const page = media.data ?? EMPTY_PAGE /** @@ -116,44 +119,47 @@ export function MediaLibraryPicker({ close()}> - - {__('Media Library', 'gophenberg')} - - - - {media.isError ? ( - {__('The media library could not be loaded.', 'gophenberg')} - ) : ( - String(item.id)} - searchLabel={__('Search media', 'gophenberg')} - config={{ perPageSizes: [] }} - paginationInfo={paginationOf({ total: page.total, limit: page.perPage })} - defaultLayouts={ - { - pickerGrid: { fields: [] }, - pickerTable: { fields: ['filename', 'filesize'] }, - } as unknown as Record - } - empty={{__('No media has been uploaded yet.', 'gophenberg')}} - /> - )} - - - - - - + + + {__('Media Library', 'gophenberg')} + + + + {media.isError ? ( + {__('The media library could not be loaded.', 'gophenberg')} + ) : ( + String(item.id)} + searchLabel={__('Search media', 'gophenberg')} + config={{ perPageSizes: [] }} + paginationInfo={paginationOf({ total: page.total, limit: page.perPage })} + defaultLayouts={ + { + pickerGrid: { fields: [] }, + pickerTable: { fields: ['filename', 'filesize'] }, + } as unknown as Record + } + empty={{__('No media has been uploaded yet.', 'gophenberg')}} + /> + )} + + + + + + + + diff --git a/frontend/src/media/MediaModals.tsx b/frontend/src/media/MediaModals.tsx new file mode 100644 index 00000000..a7f7f067 --- /dev/null +++ b/frontend/src/media/MediaModals.tsx @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { InputControl } from '@gophenberg/frontend-sdk' +import { DataForm } from '@gophenberg/frontend-sdk/dataviews' +import type { Form, RenderModalProps } from '@gophenberg/frontend-sdk/dataviews' +import { RunConfirm, useToaster } from '@gopherium/godmin' +import type { BulkRun } from '@gopherium/godmin' +import { FormDialog } from '@gopherium/godmin/dialogs' +import type { QueryClient } from '@tanstack/react-query' +import { __ } from '@wordpress/i18n' +import { useMemo, useState } from 'react' + +import { holdWrite, writesSettled } from '../content/postWrites' +import { useRenameSend } from '../lists/RenameModal' +import { useCloseWhileOpen } from '../lists/closeWhileOpen' +import { deleteQuestion, deleteWords } from '../lists/itemWords' +import type { NameCut } from '../lists/itemWords' +import { markLatest } from '../lists/unsavedText' +import type { UnsavedText } from '../lists/unsavedText' +import { deleteMedia, editMedia } from './api' +import type { MediaDescriptions, MediaItem } from './api' +import { mediaFields } from './fields' +import { FieldsPane } from './FieldsPane' +import type { Door } from './focusBack' +import { saveFailure, titleOf } from './format' + +/** What the actions of the media library need from the screen around them. */ +export interface MediaTools { + /** The query client the writes run on. */ + client: QueryClient + /** Cuts a name to the length a toast shows. */ + name: NameCut + /** Reads the library again. */ + refresh: () => Promise + /** Runs a call over files that may leave the list. */ + removals: BulkRun + /** The fields failed edits left unsaved. */ + edits: UnsavedText + /** The names failed renames left unsaved, the store the latest write of each file is marked on. */ + renames: UnsavedText +} + +/** What a row dialog of the media library takes beside the props DataViews hands a modal. */ +type MediaModalProps = RenderModalProps & { tools: MediaTools } + +/** A dialog the screen holds open on one file, so a card moving to another row of the grid never takes it away. */ +export interface OpenedDialog { + /** The action the dialog belongs to. */ + action: 'edit' | 'rename' + /** The file the dialog is open on. */ + item: MediaItem + /** The element the dialog opened from, which takes the focus back once the dialog is done. */ + door: Door +} + +/** What a dialog the screen holds open on one file takes. */ +interface MediaDialogProps { + /** The file. */ + item: MediaItem + /** What the dialog needs from the screen. */ + tools: MediaTools + /** Takes the dialog away. */ + onClose: () => void + /** Puts a focus the list lost while it read the library again back on the element the dialog opened from. */ + onSettled: () => void +} + +/** The layout of a field a reader writes in, its label on top, as WordPress lays out the attachment form. */ +const TOP_LABEL = { type: 'regular', labelPosition: 'top' } as const + +/** The attachment form of WordPress: the four fields a reader writes in, then the rows a reader only reads. */ +const EDIT_FORM: Form = { + layout: { type: 'panel' }, + fields: [ + { id: 'title', layout: TOP_LABEL }, + { id: 'altText', layout: TOP_LABEL }, + { id: 'caption', layout: TOP_LABEL }, + { id: 'description', layout: TOP_LABEL }, + 'date', + 'author', + 'filename', + 'mimeType', + 'filesize', + 'dimensions', + ], +} + +/** + * Sends a write to a file once every earlier write to it has answered, holding it until it answers in turn. + * @param client - The query client the writes belong to. + * @param id - The file the write goes to. + * @param write - Sends the write. + * @returns The write. + */ +function inTurn(client: QueryClient, id: number, write: () => Promise): Promise { + const slot = `media ${id}` + return holdWrite(client, slot, writesSettled(client, slot).then(write)) +} + +/** + * Edits a file once every earlier write to it has answered, so its version is read after them. + * @param client - The query client the writes belong to. + * @param item - The file to edit. + * @param edits - The descriptions the reader changed. + * @param unreached - The words to throw when no answer came. + * @returns The stored file. + */ +function editInTurn(client: QueryClient, item: MediaItem, edits: MediaDescriptions, unreached: string) { + return inTurn(client, item.id, () => editMedia(item.id, edits, unreached)) +} + +/** + * Drops the title a failed edit of a file kept, leaving the other fields it kept. + * @param edits - The fields failed edits left unsaved. + * @param id - The file a rename now goes to. + */ +function dropKeptTitle(edits: UnsavedText, id: string) { + const kept = edits.get(id) + if (kept !== undefined) { + const others = { ...kept } + delete others.title + edits.set(id, others) + } +} + +/** + * Returns the form editing a file and its send, which closes the dialog at once, as WordPress closes a row edit. + * @param item - The file. + * @param tools - What the dialog needs from the screen. + * @param close - Takes the dialog away. + * @param settled - Puts a focus the list lost while it read the library again back on the element Edit opened from. + * @returns The props of the form and the send. + */ +function useEditMedia(item: MediaItem, tools: MediaTools, close: () => void, settled: () => void) { + const toaster = useToaster() + const id = String(item.id) + const fields = useMemo(() => mediaFields(), []) + const [typed, setTyped] = useState(() => tools.edits.get(id) ?? {}) + const save = () => { + close() + tools.edits.delete(id) + if (typed.title !== undefined) { + tools.renames.delete(id) + } + const stillLatest = markLatest(tools.renames, id) + const unreached = __('The server could not be reached, so nothing was saved.', 'gophenberg') + void editInTurn(tools.client, item, typed, unreached).then( + async () => { + await tools.refresh() + settled() + toaster.show(__('Media file updated.', 'gophenberg')) + }, + async (failure: Error) => { + if (stillLatest()) { + tools.edits.set(id, typed) + } + await tools.refresh() + settled() + toaster.show(saveFailure(item, failure.message)) + }, + ) + } + const onChange = (changed: MediaDescriptions) => setTyped((now) => ({ ...now, ...changed })) + return { form: { data: { ...item, ...typed }, fields, form: EDIT_FORM, onChange }, save } +} + +/** + * Renders Edit on a file, closed the moment Save is pressed, its fields scrolling in a pane above Cancel and Save as + * WordPress's media editor scrolls them. + * @param props - The file, what the dialog needs from the screen, what takes the dialog away and what gives the focus + * back once the write read the library again. + * @returns The dialog. + */ +function EditMediaDialog({ item, tools, onClose, onSettled }: MediaDialogProps) { + const edit = useEditMedia(item, tools, onClose, onSettled) + return ( + + + + + + ) +} + +/** + * Renders the rename of a file, closed the moment Save is pressed, its write marked as the latest one to the file + * across Edit and Rename. + * @param props - The file, what the dialog needs from the screen and what takes the dialog away. + * @returns The dialog. + */ +function RenameMediaDialog({ item, tools, onClose, onSettled }: MediaDialogProps) { + const [name, setName] = useState(() => tools.renames.get(String(item.id)) ?? item.title) + const unreached = __('The name could not be updated.', 'gophenberg') + const send = useRenameSend(item, onClose, { + idOf: (file) => String(file.id), + rename: (file, typed) => { + dropKeptTitle(tools.edits, String(file.id)) + return editInTurn(tools.client, file, { title: typed }, unreached) + }, + refresh: () => tools.refresh().then(onSettled), + unsaved: tools.renames, + }) + return ( + send(name)} + onClose={onClose} + > + + + ) +} + +/** The dialogs the screen holds open on one file, by the action they belong to. */ +const OPENED_DIALOGS = { edit: EditMediaDialog, rename: RenameMediaDialog } + +/** + * Renders the dialog the screen holds open on one file, none while no dialog is open. + * @param props - The dialog, its file and the element it opened from, what it needs from the screen, what takes it away + * and what puts a lost focus back on the element a dialog on a file opened from. + * @returns The dialog, giving the focus back to the element it opened from once it closed and once its write read the + * library again. + */ +export function OpenMediaDialog({ + opened, + tools, + onClose, + onBack, +}: { opened?: OpenedDialog, onBack: (id: number, door: Door) => void } & Omit) { + if (opened === undefined) { + return null + } + const Dialog = OPENED_DIALOGS[opened.action] + const back = () => onBack(opened.item.id, opened.door) + const close = () => { + onClose() + back() + } + return ( + + ) +} + +/** + * Renders the confirm deleting the picked files for good, closing only itself once the run ends. + * @param props - The files, the handler closing the modal and what the action needs from the screen. + * @returns The confirm. + */ +export function DeleteMediaModal({ items, closeModal, tools }: MediaModalProps) { + const close = useCloseWhileOpen(closeModal) + return ( + + tools.removals( + picked, + (item) => inTurn(tools.client, item.id, () => deleteMedia(item.id)), + deleteWords(tools.name, titleOf), + ) + } + > + {deleteQuestion(items, tools.name, titleOf)} + + ) +} diff --git a/frontend/src/media/MediaScreen.tsx b/frontend/src/media/MediaScreen.tsx index f2274cbd..c21bce5b 100644 --- a/frontend/src/media/MediaScreen.tsx +++ b/frontend/src/media/MediaScreen.tsx @@ -2,55 +2,50 @@ import { DropZone, mediaIcon, uploadIcon } from '@gophenberg/frontend-sdk' import { DataViews } from '@gophenberg/frontend-sdk/dataviews' -import type { View } from '@gophenberg/frontend-sdk/dataviews' +import type { Field, View } from '@gophenberg/frontend-sdk/dataviews' import { ErrorNotice, FileButton, ListEmpty, Page, bulkNotes, + offeredSize, + offeredView, paginationOf, runEach, + sizedView, + useListRun, useToaster, + viewNarrows, } from '@gopherium/godmin' -import type { BulkFailure, BulkWords } from '@gopherium/godmin' +import type { BulkFailure, BulkWords, OfferedView } from '@gopherium/godmin' +import { openOnTap, useListView } from '@gopherium/godmin/router' +import type { ListView } from '@gopherium/godmin/router' import { formatNumber } from '@gopherium/gottext' -import { useQuery } from '@tanstack/react-query' +import { keepPreviousData, useQuery } from '@tanstack/react-query' import type { UseQueryResult } from '@tanstack/react-query' import { __, _n, _x, sprintf } from '@wordpress/i18n' -import { useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import type { RefObject } from 'react' +import { focusInDialog, useFocusOnList } from '../lists/focusOnList' +import type { ListSettings } from '../settings/api' import { useListSettings } from '../settings/useListSettings' import './media.css' -import { useMediaActions, useRefreshMedia } from './actions' +import { useMayChange, useMediaActions, useMediaTools, useRefreshMedia, viewFile } from './actions' import { listMedia, mediaQueryKey, uploadMedia } from './api' -import type { MediaItem, MediaPage } from './api' -import { mediaFields } from './fields' +import type { MediaItem, MediaPage, MediaQuery } from './api' +import { MEDIA_SORT, MEDIA_VIEW_GUARD, libraryFields, mediaListing } from './fields' +import { clickedDoor, useFocusBack } from './focusBack' +import type { Door } from './focusBack' +import { OpenMediaDialog } from './MediaModals' +import type { MediaTools, OpenedDialog } from './MediaModals' +/** The width a card of the grid opens at, the one DataViews draws by default. */ const PREVIEW_SIZE = 230 -const EMPTY_PAGE: MediaPage = { items: [], total: 0, perPage: 0 } - -const INITIAL_VIEW: View = { - type: 'grid', - fields: ['dimensions', 'filesize'], - titleField: 'title', - mediaField: 'thumbnail', - search: '', - page: 1, - layout: { previewSize: PREVIEW_SIZE }, -} - -/** - * Returns the kind a view is filtered to. - * @param view - The view the library is shown through. - * @returns The kind to ask for, empty for every kind. - */ -export function filteredKind(view: View): string { - const chosen = view.filters?.find((filter) => filter.field === 'type')?.value - return typeof chosen === 'string' ? chosen : '' -} +/** The items the library holds before its first page arrives. */ +const NO_ITEMS: MediaItem[] = [] /** * Returns the reason a file was not stored, already in the reader's words. @@ -160,7 +155,7 @@ function useUpload(region: RefObject) { toaster.show(toast) } finish(outcome.failures) - if (outcome.done > 0) { + if (outcome.done > 0 && !focusInDialog()) { region.current?.focus() } } @@ -188,45 +183,124 @@ function MediaEmpty({ narrowed }: { narrowed: boolean }) { return } +/** + * Returns the listing request a view of the library stands for. + * @param view - The view the list shows. + * @param settings - The list settings, absent when they could not be read. + * @returns The request, leaving the page size to the server while the settings name none. + */ +function listingOf(view: OfferedView, settings: ListSettings | undefined): MediaQuery { + return { ...mediaListing(view), ...sizedView(view, settings?.list_page_sizes) } +} + +/** + * Holds the view of the library in the address, sized by the settings once they are known, and reads its page. + * @returns The list view, the fields, the view the fields can draw, the settings and the page query. + */ +function useMediaList() { + const settings = useListSettings().data + const fields = useMemo(() => libraryFields(), []) + const list = useListView({ + fields: ['author'], + phoneFields: ['author'], + titleField: 'title', + mediaField: 'thumbnail', + sort: { ...MEDIA_SORT }, + perPage: settings?.list_page_size, + layouts: { wide: 'grid', phone: 'list' }, + layoutSettings: { grid: { layout: { previewSize: PREVIEW_SIZE } } }, + }) + const offered = offeredView(list.view, fields, MEDIA_VIEW_GUARD) + const view = { + ...offered, + perPage: offeredSize(offered.perPage, settings?.list_page_sizes, settings?.list_page_size), + } + const asked = listingOf(view, settings) + const media = useQuery({ + queryKey: [...mediaQueryKey, 'library', asked], + queryFn: () => listMedia(asked), + placeholderData: keepPreviousData, + }) + return { list, fields, view, settings, media } +} + +/** + * Returns the rows, the paging and the page sizes DataViews draws the page of the library with. + * @param page - The page the server served, absent until it arrives. + * @param settings - The list settings, absent when they could not be read. + * @returns The props of the page. + */ +function pageProps(page: MediaPage | undefined, settings: ListSettings | undefined) { + return { + data: page?.items ?? NO_ITEMS, + paginationInfo: paginationOf(page === undefined ? undefined : { total: page.total, limit: page.perPage }), + config: { perPageSizes: settings?.list_page_sizes ?? [] }, + } +} + +/** + * Returns the handler opening Edit on a file a tap landed on, for a file the session may change, and the file itself + * in a new tab otherwise, as View does. + * @param page - The page the list shows, which holds every row a tap can land on. + * @param mayChange - Whether the session may change a file. + * @param onEdit - Opens Edit on a file. + * @returns The handler, given the id of the file tapped. + */ +function openTapped( + page: MediaPage | undefined, + mayChange: (item: MediaItem) => boolean, + onEdit: (item: MediaItem) => void, +) { + return (id: string) => { + const tapped = (page as MediaPage).items.find((item) => String(item.id) === id) as MediaItem + if (mayChange(tapped)) { + onEdit(tapped) + } else { + viewFile(tapped) + } + } +} + /** * Renders the items of the library, or the failure of their read. - * @param props - The query of the page, the view and what changes it, the ticked items and what changes them. + * @param props - The page query, the list view, the view the fields draw, the fields, the settings, what the actions + * need from the screen, whether the session may change a file and the handler opening a dialog on one. * @returns The list, or the failure notice. */ function MediaList(props: { media: UseQueryResult - view: View - onChangeView: (view: View) => void - selection: string[] - onChangeSelection: (selection: string[]) => void + list: ListView + view: OfferedView + fields: Field[] + settings: ListSettings | undefined + tools: MediaTools + mayChange: (item: MediaItem) => boolean + open: (opened: OpenedDialog) => void }) { - const actions = useMediaActions() - const { media, view } = props + const actions = useMediaActions(props.tools, props.mayChange, props.open) + const { media, list, view } = props + const edit = (item: MediaItem, door: Door) => props.open({ action: 'edit', item, door }) if (media.isError) { return {__('The media library could not be loaded.', 'gophenberg')} } - const page = media.data ?? EMPTY_PAGE return ( } + fields={props.fields} actions={actions} view={view} - onChangeView={props.onChangeView} - selection={props.selection} - onChangeSelection={props.onChangeSelection} - isLoading={media.isPending} + onChangeView={list.onChangeView} + defaultLayouts={list.defaultLayouts} + selection={list.selection} + onChangeSelection={openOnTap(list, openTapped(media.data, props.mayChange, (item) => edit(item, 'row')))} + isItemClickable={props.mayChange} + onClickItem={(item: MediaItem) => edit(item, clickedDoor())} + isLoading={media.isFetching} getItemId={(item: MediaItem) => String(item.id)} - searchLabel={__('Search media', 'gophenberg')} - config={{ perPageSizes: [] }} - paginationInfo={paginationOf({ total: page.total, limit: page.perPage })} - defaultLayouts={{ - grid: { fields: ['dimensions', 'filesize'] }, - table: { - fields: ['filename', 'mimeType', 'dimensions', 'filesize', 'date'], - }, - }} - empty={} + searchLabel={sprintf(__('Search %(type)s…', 'gophenberg'), { + type: _x('Media', 'admin section', 'gophenberg').toLowerCase(), + })} /> ) } @@ -236,17 +310,13 @@ function MediaList(props: { * @returns The library screen element. */ export function MediaScreen() { - const settings = useListSettings().data - const [held, setHeld] = useState(INITIAL_VIEW) - const view = { ...held, perPage: held.perPage ?? settings?.list_page_size } - const [selection, setSelection] = useState([]) - const region = useRef(null) + const { list, fields, view, settings, media } = useMediaList() + const { run, region } = useListRun('media', list.onChangeSelection) + const tools = useMediaTools(useFocusOnList(run, region)) + const mayChange = useMayChange() + const [opened, setOpened] = useState() + const focusBack = useFocusBack(media.isFetching, region) const { busy, notice, upload } = useUpload(region) - const kind = filteredKind(view) - const media = useQuery({ - queryKey: [...mediaQueryKey, kind, view.search, view.page, view.perPage], - queryFn: () => listMedia({ type: kind, search: view.search, page: view.page, perPage: view.perPage }), - }) const title = _x('Media', 'admin section', 'gophenberg') const uploadLabel = addLabel() return ( @@ -259,22 +329,27 @@ export function MediaScreen() { {uploadLabel} } + list + listLabel={title} + listRef={region} > -
- {notice === undefined ? null : ( -
- {notice} -
- )} - - -
+ {notice === undefined ? null : ( +
+ {notice} +
+ )} + + + setOpened(undefined)} onBack={focusBack} /> ) } diff --git a/frontend/src/media/actions.tsx b/frontend/src/media/actions.tsx index 37f1849a..6070cb76 100644 --- a/frontend/src/media/actions.tsx +++ b/frontend/src/media/actions.tsx @@ -1,18 +1,20 @@ // SPDX-License-Identifier: Apache-2.0 -import { Button, Notice, Stack, Text, sessionMayChange } from '@gophenberg/frontend-sdk' -import { DataForm } from '@gophenberg/frontend-sdk/dataviews' -import type { Action, RenderModalProps } from '@gophenberg/frontend-sdk/dataviews' -import { formatNumber } from '@gopherium/gottext' +import { sessionMayChange, trashIcon } from '@gophenberg/frontend-sdk' +import type { Action } from '@gophenberg/frontend-sdk/dataviews' +import { useBulkRun, useToaster } from '@gopherium/godmin' +import type { ListRun } from '@gopherium/godmin' import { useSession } from '@gopherium/react-auth' -import { __, _n, sprintf } from '@wordpress/i18n' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useCallback, useMemo, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { __, _x } from '@wordpress/i18n' +import { useCallback, useMemo } from 'react' -import { deleteMedia, describeMedia, mediaQueryKey } from './api' +import { useUnsavedText } from '../lists/unsavedText' +import { mediaQueryKey, mediaSrc } from './api' import type { MediaDescriptions, MediaItem } from './api' -import { describeForm, mediaFields } from './fields' -import { mediaName } from './format' +import { displayFile } from './format' +import { DeleteMediaModal } from './MediaModals' +import type { MediaTools, OpenedDialog } from './MediaModals' /** * Returns the handler that reloads the library. @@ -27,162 +29,92 @@ export function useRefreshMedia(): () => Promise { } /** - * Returns the sentence reporting how a save ended. - * @param kind - The outcome the server answered with. - * @param message - The reason the server gave, when it gave one. - * @returns The sentence to show, empty when the save succeeded. + * Returns the check telling whether the session may change a file. + * @returns The check, true on its own uploads and on every file for a role that may change the work of others. */ -function describeFailure(kind: string, message: string): string { - if (kind === 'stale') { - return __('This item changed while you were describing it. Reload and try again.', 'gophenberg') - } - return kind === 'rejected' ? message : '' +export function useMayChange(): (item: MediaItem) => boolean { + const session = useSession().data + return useCallback((item: MediaItem) => sessionMayChange(session, item.authorId), [session]) } /** - * Renders the form describing one media item. - * @param props - The items acted on and the handler closing the modal. - * @returns The describe form. + * Returns what the actions of the library need from the screen, the same for as long as the screen stays. + * @param list - What the list does around a run. + * @returns The tools. */ -function DescribeForm({ items, closeModal }: RenderModalProps) { - const target = items[0] - const [edits, setEdits] = useState({}) - const [notice, setNotice] = useState('') +export function useMediaTools(list: ListRun): MediaTools { + const client = useQueryClient() + const name = useToaster().name const refresh = useRefreshMedia() - const save = useMutation({ - mutationFn: () => describeMedia(target.id, edits, target.updatedAt), - onSuccess: async (outcome) => { - const failure = describeFailure(outcome.kind, outcome.kind === 'rejected' ? outcome.message : '') - if (failure !== '') { - setNotice(failure) - return - } - setNotice('') - await refresh() - closeModal?.() - }, - onError: () => setNotice(__('The server could not be reached, so nothing was saved.', 'gophenberg')), - }) - return ( - - setEdits((current) => ({ ...current, ...changed }))} - /> - {notice !== '' && ( - - {notice} - - )} - - - - - - ) -} - -/** - * Returns the question asked before media is deleted for good. - * @param items - The items about to be deleted. - * @returns The question to show. - */ -export function deleteQuestion(items: MediaItem[]): string { - if (items.length === 1) { - return sprintf(__('Delete %(title)s for good? This cannot be undone.', 'gophenberg'), { - title: mediaName(items[0]), - }) - } - return sprintf( - _n( - 'Delete this %(count)s item for good? This cannot be undone.', - 'Delete these %(count)s items for good? This cannot be undone.', - items.length, - 'gophenberg', - ), - { count: formatNumber(items.length) }, + const removals = useBulkRun(list, refresh) + const edits = useUnsavedText() + const renames = useUnsavedText() + return useMemo( + () => ({ client, name, refresh, removals, edits, renames }), + [client, edits, name, refresh, removals, renames], ) } /** - * Returns the sentence reporting that a delete did not finish. - * @param items - The items the delete was asked over. - * @returns The sentence to show. + * Opens a file in a new tab, as View does. + * @param item - The file. */ -export function deleteFailure(items: MediaItem[]): string { - return _n('Could not delete that item.', 'Could not delete every item.', items.length, 'gophenberg') +export function viewFile(item: MediaItem) { + window.open(mediaSrc(displayFile(item)), '_blank') } /** - * Renders the confirmation asked for before media is deleted for good. - * @param props - The items acted on and the handler closing the modal. - * @returns The confirmation body. + * Returns the action opening a file in a new tab, a primary one click action offered on every file. + * @returns View. */ -function DeleteConfirm({ items, closeModal }: RenderModalProps) { - const refresh = useRefreshMedia() - const remove = useMutation({ - mutationFn: async () => { - const settled = await Promise.allSettled(items.map((item) => deleteMedia(item.id))) - if (settled.some((outcome) => outcome.status === 'rejected')) { - throw new Error('deleting did not finish') - } - }, - onSuccess: async () => { - await refresh() - closeModal?.() - }, - onError: () => { - void refresh() - }, - }) - return ( - - {deleteQuestion(items)} - {remove.isError && ( - - {deleteFailure(items)} - - )} - - - - - - ) +function viewAction(): Action { + return { + id: 'view', + label: _x('View', 'verb', 'gophenberg'), + isPrimary: true, + callback: ([item]) => viewFile(item), + } } /** - * Returns the actions offered on each media item. - * @returns The row actions. + * Returns the actions offered on each file, View on every file and the others on a file the session may change. + * @param tools - What the actions need from the screen. + * @param mayChange - Whether the session may change a file. + * @param open - Opens Edit or Rename on a file in a dialog the screen holds, which a card moving to another row of the + * grid never takes away. + * @returns The row actions, the same for as long as the screen stays, so a confirm never starts over in a run. */ -export function useMediaActions(): Action[] { - const session = useSession().data - return useMemo(() => { - const mine = (item: MediaItem) => sessionMayChange(session, item.authorId) - return [ +export function useMediaActions( + tools: MediaTools, + mayChange: (item: MediaItem) => boolean, + open: (opened: OpenedDialog) => void, +): Action[] { + return useMemo( + () => [ + viewAction(), + { + id: 'edit', + label: __('Edit…', 'gophenberg'), + isEligible: mayChange, + callback: ([item]) => open({ action: 'edit', item, door: 'actions' }), + }, { - id: 'describe', - label: __('Describe', 'gophenberg'), - isEligible: mine, - RenderModal: DescribeForm, + id: 'rename', + label: __('Rename…', 'gophenberg'), + isEligible: mayChange, + callback: ([item]) => open({ action: 'rename', item, door: 'actions' }), }, { id: 'delete', - label: __('Delete Permanently', 'gophenberg'), + label: __('Permanently delete…', 'gophenberg'), + icon: trashIcon, supportsBulk: true, - isEligible: mine, - RenderModal: DeleteConfirm, + isEligible: mayChange, + hideModalHeader: true, + modalFocusOnMount: 'firstContentElement', + RenderModal: (props) => , }, - ] - }, [session]) + ], + [mayChange, open, tools], + ) } diff --git a/frontend/src/media/api.ts b/frontend/src/media/api.ts index 0279916e..bbe7b79a 100644 --- a/frontend/src/media/api.ts +++ b/frontend/src/media/api.ts @@ -30,6 +30,8 @@ const mediaSchema = z.object({ filesize: z.number().optional(), sizes: z.record(z.string(), subSizeSchema).optional(), author_id: z.string().optional(), + author_name: z.string().optional(), + author_avatar: z.string().optional(), created_at: z.string().optional(), updated_at: z.string().optional(), }) @@ -66,6 +68,8 @@ export interface MediaItem { filesize: number sizes: Record authorId: string + authorName: string + authorAvatar: string createdAt: string updatedAt: string } @@ -77,11 +81,18 @@ export interface MediaPage { } export interface MediaQuery { - type?: string mimes?: string[] search?: string page?: number perPage?: number + orderBy?: string + order?: string + author?: string[] + authorExclude?: string[] + before?: string + after?: string + modifiedBefore?: string + modifiedAfter?: string } export interface MediaDescriptions { @@ -91,13 +102,16 @@ export interface MediaDescriptions { description?: string } -export type DescribeOutcome = - | { kind: 'saved', item: MediaItem } - | { kind: 'stale' } - | { kind: 'rejected', message: string } - export const mediaQueryKey = ['media'] +/** The name the server reads each description under. */ +const DESCRIPTION_KEYS: [keyof MediaDescriptions, string][] = [ + ['title', 'title'], + ['altText', 'alt_text'], + ['caption', 'caption'], + ['description', 'description'], +] + /** * Returns the sub-sizes carried by an API row. * @param sizes - The sub-sizes as the API sent them. @@ -157,6 +171,8 @@ function toMedia(row: z.infer): MediaItem { mimeType: row.mime_type, sizes: toSizes(row.sizes), authorId: row.author_id ?? '', + authorName: row.author_name ?? '', + authorAvatar: row.author_avatar ?? '', createdAt: row.created_at ?? '', updatedAt: row.updated_at ?? '', ...toDescriptions(row), @@ -187,27 +203,51 @@ export function mediaSrc(file: string): string { } /** - * Returns one page of media matching the query. - * @param query - The filters, page and page size to ask for, the server's own page size when it names none. - * @returns The page, the total number of matches and the page size the server used. + * Returns the named parameters a listing request carries, undefined or empty for each one the query leaves out. + * @param query - The filters, sort and page size to ask for. + * @returns The parameters by the names the server reads. */ -export async function listMedia(query: MediaQuery): Promise { - const params = new URLSearchParams() - if (query.perPage !== undefined) { - params.set('per_page', String(query.perPage)) - } - if (query.type) { - params.set('type', query.type) - } - if (query.mimes && query.mimes.length > 0) { - params.set('mime', query.mimes.join(',')) +function namedParams(query: MediaQuery): Record { + return { + per_page: query.perPage?.toString(), + mime: query.mimes?.join(','), + search: query.search, + orderby: query.orderBy, + order: query.order, + author: query.author?.join(','), + author_exclude: query.authorExclude?.join(','), + before: query.before, + after: query.after, + modified_before: query.modifiedBefore, + modified_after: query.modifiedAfter, } - if (query.search) { - params.set('search', query.search) +} + +/** + * Returns the query parameters a listing request carries. + * @param query - The filters, sort, page and page size to ask for. + * @returns The parameters, the first page left unnamed. + */ +function listingParams(query: MediaQuery): URLSearchParams { + const params = new URLSearchParams() + for (const [name, value] of Object.entries(namedParams(query))) { + if (value) { + params.set(name, value) + } } if (query.page && query.page > 1) { params.set('page', String(query.page)) } + return params +} + +/** + * Returns one page of media matching the query. + * @param query - The filters, sort, page and page size to ask for, the server's own page size when it names none. + * @returns The page, the total number of matches and the page size the server used. + */ +export async function listMedia(query: MediaQuery): Promise { + const params = listingParams(query) const response = await fetch(`/api/media?${params}`) if (!response.ok) { throw new Error(`listing media failed with status ${response.status}`) @@ -243,6 +283,24 @@ async function namedMedia(ids: number[]): Promise { return pageSchema.parse(await response.json()).items.map(toMedia) } +/** + * Returns the answer to a request the server accepted, or throws the refusal in the reader's words. + * @param url - Where the request goes. + * @param init - The method, headers and body of the request. + * @param unreached - The words to throw when no answer came. + * @returns The answer. + */ +async function answered(url: string, init: RequestInit, unreached: string): Promise { + const response = await fetch(url, init).catch(() => null) + if (response === null) { + throw new Error(unreached) + } + if (!response.ok) { + throw new Error(await messageFrom(response)) + } + return response +} + /** * Stores an uploaded file in the media library, or throws the reason it was not stored in the reader's words. * @param file - The file the administrator chose. @@ -251,19 +309,13 @@ async function namedMedia(ids: number[]): Promise { export async function uploadMedia(file: File): Promise { const body = new FormData() body.append('file', file, file.name) - const response = await fetch('/api/media', { method: 'POST', body }).catch(() => null) - if (response === null) { - throw new Error(__('The server could not be reached, so nothing was uploaded.', 'gophenberg')) - } - if (!response.ok) { - throw new Error(await messageFrom(response)) - } - return storedItem(response) + const unreached = __('The server could not be reached, so nothing was uploaded.', 'gophenberg') + return storedItem(await answered('/api/media', { method: 'POST', body }, unreached)) } /** - * Returns the item a stored upload answered with, or throws a stand in when the answer cannot be read. - * @param response - The answer the API stored the file with. + * Returns the item an answer of the API carries, or throws a stand in when the answer cannot be read. + * @param response - The answer holding one stored item. * @returns The stored item. */ async function storedItem(response: Response): Promise { @@ -275,51 +327,48 @@ async function storedItem(response: Response): Promise { } /** - * Writes the given descriptions to a media item. - * @param id - The item to write to. - * @param descriptions - The descriptions to change. - * @param version - The updatedAt the descriptions were prepared against. - * @returns What the server made of the write. + * Returns the body of an edit, the version it writes over and each description it changes, under the server's names. + * @param edits - The descriptions the reader changed. + * @param version - The updatedAt the edit writes over. + * @returns The body. */ -export async function describeMedia( - id: number, - descriptions: MediaDescriptions, - version: string, -): Promise { +function editBody(edits: MediaDescriptions, version: string): Record { const body: Record = { updated_at: version } - const wire: [keyof MediaDescriptions, string][] = [ - ['title', 'title'], - ['altText', 'alt_text'], - ['caption', 'caption'], - ['description', 'description'], - ] - for (const [field, key] of wire) { - const value = descriptions[field] + for (const [field, key] of DESCRIPTION_KEYS) { + const value = edits[field] if (value !== undefined) { body[key] = value } } - const response = await fetch(`/api/media/${id}`, { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) - if (response.ok) { - return { kind: 'saved', item: toMedia(mediaSchema.parse(await response.json())) } - } - if (response.status === 409) { - return { kind: 'stale' } - } - return { kind: 'rejected', message: await messageFrom(response) } + return body } /** - * Removes a media item and its files for good. + * Writes the descriptions a reader changed over the version read just before the write, so the newest write wins. + * @param id - The file to write to. + * @param edits - The descriptions the reader changed, the others left as they are. + * @param unreached - The words to throw when no answer came. + * @returns The stored file, or a throw of the reason it was not written in the reader's words. + */ +export async function editMedia(id: number, edits: MediaDescriptions, unreached: string): Promise { + const current = await storedItem(await answered(`/api/media/${id}`, {}, unreached)) + const response = await answered( + `/api/media/${id}`, + { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(editBody(edits, current.updatedAt)), + }, + unreached, + ) + return storedItem(response) +} + +/** + * Removes a media item and its files for good, or throws the reason it stayed in the reader's words. * @param id - The item to delete. */ export async function deleteMedia(id: number): Promise { - const response = await fetch(`/api/media/${id}`, { method: 'DELETE' }) - if (!response.ok) { - throw new Error(`deleting media failed with status ${response.status}`) - } + const unreached = __('The item could not be permanently deleted.', 'gophenberg') + await answered(`/api/media/${id}`, { method: 'DELETE' }, unreached) } diff --git a/frontend/src/media/fields.tsx b/frontend/src/media/fields.tsx index 5fb9a881..aae545e9 100644 --- a/frontend/src/media/fields.tsx +++ b/frontend/src/media/fields.tsx @@ -1,17 +1,65 @@ // SPDX-License-Identifier: Apache-2.0 -import { Text } from '@gophenberg/frontend-sdk' -import type { Field } from '@gophenberg/frontend-sdk/dataviews' +import { + audioIcon, + fileIcon, + Icon, + imageIcon, + Link, + Stack, + Text, + Tooltip, + videoIcon, +} from '@gophenberg/frontend-sdk' +import type { Field, View } from '@gophenberg/frontend-sdk/dataviews' +import type { ViewGuard } from '@gopherium/godmin' +import { getSettings } from '@wordpress/date' import { __, _x } from '@wordpress/i18n' -import { useState } from 'react' +import { useCallback, useState } from 'react' +import { authorField, authorNarrowing, namesAccounts } from '../lists/author' +import { dateNarrowing, isMoment, narrowedBy } from '../lists/narrowing' +import type { Narrowing } from '../lists/narrowing' import { mediaSrc } from './api' -import { formatDate } from '@gopherium/gottext' -import type { MediaItem } from './api' -import { bestSubSizeFile, fileSize, imageDimensions, mediaName } from './format' +import type { MediaItem, MediaQuery } from './api' +import { bestSubSizeFile, fileSize, imageDimensions, isPicture, kindOf, kindPrefix, titleOf } from './format' -// previewWidth is the width a grid tile draws a picture at. -const previewWidth = 230 +/** The longest file name a cell shows with no tooltip, as WordPress cuts one. */ +const FILE_NAME_CUT = 15 + +/** The mark a cell shows for a value a file leaves empty, as WordPress shows one. */ +const EMPTY_MARK = '-' + +/** The order the library opens in, the newest file first, and the one it keeps for a sort it never offers. */ +export const MEDIA_SORT = { field: 'date', direction: 'desc' } as const + +/** The order the library opens in, and how its author and date filters check the value they hold. */ +export const MEDIA_VIEW_GUARD: ViewGuard = { + opening: MEDIA_SORT, + checks: { author: namesAccounts, date: isMoment, modified: isMoment }, +} + +/** The icon a card draws for each WordPress file type group that has its own, keyed by the group. */ +const KIND_ICONS: Record = { + image: imageIcon, + video: videoIcon, + audio: audioIcon, +} + +/** The class of a picture's box at each step of its load, a fade only while it loads. */ +const LOADING_CLASSES = { + instant: 'gophenberg-media__thumbnail', + loading: 'gophenberg-media__thumbnail is-loading', + loaded: 'gophenberg-media__thumbnail is-loaded', +} + +/** The part of a listing request a filter of each field narrows it by, keyed by the field. */ +const narrowings: Record | undefined> = { + author: authorNarrowing, + date: dateNarrowing('before', 'after'), + modified: dateNarrowing('modifiedBefore', 'modifiedAfter'), + media_type: (filter) => ({ mimes: [kindPrefix(String(filter.value))] }), +} /** * Returns the file name of a stored file. @@ -23,167 +71,334 @@ function fileNameOf(file: string): string { } /** - * Renders the picture of a media item, or a stand in naming its file. - * @param props - The row being rendered. + * Returns the file a picture loads for the width a layout draws it at, as WordPress picks one. + * @param item - The picture. + * @param sizes - The width the layout draws pictures at, such as 900px, none outside a layout. + * @returns The smallest stored size at least that wide, the stored file itself when no width is named. + */ +function pictureFile(item: MediaItem, sizes: string | undefined): string { + const target = Number.parseInt(sizes ?? '', 10) + return Number.isNaN(target) ? item.file : bestSubSizeFile(item, target) +} + +/** + * Renders a picture filling its card, faded in while it loads unless the browser already holds it. + * @param props - The picture, the width its layout draws it at and the handler told when its file will not load. + * @returns The picture in its box. + */ +function PictureCell({ item, sizes, onError }: { item: MediaItem, sizes: string | undefined, onError: () => void }) { + const [loading, setLoading] = useState('loading') + const held = useCallback((picture: HTMLImageElement | null) => { + if (picture?.complete) { + setLoading('instant') + } + }, []) + return ( +
+ {item.altText setLoading((step) => (step === 'loading' ? 'loaded' : step))} + onError={onError} + /> +
+ ) +} + +/** + * Renders a file that shows no picture as the icon of its kind above its file name. + * @param props - The file. + * @returns The kind icon and the file name, cut on one line. + */ +function KindCell({ item }: { item: MediaItem }) { + return ( +
+ + +
+ + {fileNameOf(item.file)} + +
+
+
+ ) +} + +/** + * Renders the picture of a media item, or the icon of its kind for a file that is no picture or will not load. + * @param props - The item and the width its layout draws pictures at. * @returns The thumbnail cell. */ -function ThumbnailCell({ item }: { item: MediaItem }) { +function ThumbnailCell({ item, config }: { item: MediaItem, config?: { sizes: string } }) { const [broken, setBroken] = useState(false) - if (item.type !== 'image' || broken) { - return ( -
- {fileNameOf(item.file)} -
- ) + if (broken || !isPicture(item)) { + return } - return ( - {item.altText setBroken(true)} - /> - ) + return setBroken(true)} /> } /** - * Renders the name of a media item. + * Renders the title of a media item as text marked with the item, so the title rule of a layout draws it and the + * focus finds its card again. * @param props - The row being rendered. - * @returns The title cell. + * @returns The title, or (no title). */ function TitleCell({ item }: { item: MediaItem }) { - return {mediaName(item)} + return {titleOf(item)} } /** - * Renders the file a media item is stored in. + * Renders the alt text of a picture, a dash when it holds none. * @param props - The row being rendered. - * @returns The file name cell. + * @returns The alt text cell. */ -function FileNameCell({ item }: { item: MediaItem }) { - return {fileNameOf(item.file)} +function AltTextCell({ item }: { item: MediaItem }) { + return item.altText || EMPTY_MARK } /** - * Renders the content type of a media item. + * Renders the caption of a media item, a dash when it holds none. * @param props - The row being rendered. - * @returns The file type cell. + * @returns The caption cell. */ -function MimeTypeCell({ item }: { item: MediaItem }) { - return {item.mimeType} +function CaptionCell({ item }: { item: MediaItem }) { + return item.caption || EMPTY_MARK } /** - * Renders the pixel dimensions of a picture. + * Renders the description of a media item as bare text, a dash when it holds none, as WordPress renders the caption. * @param props - The row being rendered. - * @returns The dimensions cell. + * @returns The description cell. */ -function DimensionsCell({ item }: { item: MediaItem }) { - return {imageDimensions(item)} +function DescriptionCell({ item }: { item: MediaItem }) { + return item.description || EMPTY_MARK } /** - * Renders the size of a stored file. + * Renders the content type of a media item, a dash when it carries none. * @param props - The row being rendered. - * @returns The file size cell. + * @returns The file type cell. */ -function FileSizeCell({ item }: { item: MediaItem }) { - return {fileSize(item.filesize)} +function MimeTypeCell({ item }: { item: MediaItem }) { + return item.mimeType || EMPTY_MARK } /** - * Renders the day a media item was uploaded. + * Renders the whole file name of a media item, held in a tooltip once a layout may cut it. * @param props - The row being rendered. - * @returns The date cell. - */ -function DateCell({ item }: { item: MediaItem }) { - return {formatDate(item.createdAt)} -} - -export const mediaFields: Field[] = [ - { - id: 'thumbnail', - type: 'media', - label: __('Thumbnail', 'gophenberg'), - render: ThumbnailCell, - enableSorting: false, - enableHiding: false, - }, - { - id: 'title', - label: __('Title', 'gophenberg'), - render: TitleCell, - Edit: { control: 'text' }, - enableSorting: false, - enableHiding: false, - }, - { - id: 'type', - label: _x('Kind', 'media family', 'gophenberg'), - elements: [ - { value: 'image', label: __('Images', 'gophenberg') }, - { value: 'file', label: __('Files', 'gophenberg') }, - ], - filterBy: { operators: ['is'], isPrimary: true }, - enableSorting: false, - readOnly: true, - }, - { - id: 'filename', - label: __('File name', 'gophenberg'), - render: FileNameCell, - enableSorting: false, - readOnly: true, - }, - { - id: 'mimeType', - label: __('File type', 'gophenberg'), - render: MimeTypeCell, - enableSorting: false, - readOnly: true, - }, - { - id: 'dimensions', - label: __('Dimensions', 'gophenberg'), - render: DimensionsCell, - enableSorting: false, - readOnly: true, - }, - { - id: 'filesize', - label: __('File size', 'gophenberg'), - render: FileSizeCell, - enableSorting: false, - readOnly: true, - }, - { - id: 'date', - label: __('Date added', 'gophenberg'), - render: DateCell, - enableSorting: false, + * @returns The file name cell. + */ +function FileNameCell({ item }: { item: MediaItem }) { + const name = fileNameOf(item.file) + const shown = {name} + if (name.length <= FILE_NAME_CUT) { + return shown + } + return ( + + + {name} + + ) +} + +/** + * Returns the help WordPress gives under the alt text box. + * @returns The link to the WAI decision tree, opening in a new tab, and the note about decorative pictures. + */ +function altTextHelp() { + return ( + <> + + {__('Describe the purpose of the image.', 'gophenberg')} + +
+ {__('Leave empty if decorative.', 'gophenberg')} + + ) +} + +/** + * Returns the fields a reader writes about a file, as WordPress lists them. + * @returns The title, the alt text, the caption and the description. + */ +function describingFields(): Field[] { + return [ + { + id: 'title', + type: 'text', + label: __('Title', 'gophenberg'), + placeholder: __('No title', 'gophenberg'), + getValue: ({ item }) => item.title, + render: TitleCell, + enableGlobalSearch: true, + filterBy: false, + }, + { + id: 'altText', + type: 'text', + label: __('Alt text', 'gophenberg'), + description: altTextHelp(), + Edit: { control: 'textarea', rows: 2 }, + isVisible: isPicture, + render: AltTextCell, + enableSorting: false, + filterBy: false, + }, + { + id: 'caption', + type: 'text', + label: __('Caption', 'gophenberg'), + Edit: { control: 'textarea', rows: 2 }, + render: CaptionCell, + enableSorting: false, + filterBy: false, + }, + { + id: 'description', + type: 'text', + label: __('Description', 'gophenberg'), + Edit: { control: 'textarea', rows: 5 }, + render: DescriptionCell, + enableSorting: false, + filterBy: false, + }, + ] +} + +/** + * Returns a moment of a media item as a field, written in the full date and time format the admin booted with. + * @param id - The id of the field. + * @param label - The label of the field. + * @param moment - Reads the moment from an item. + * @returns The field, sorted and filtered before or after a moment. + */ +function momentField(id: string, label: string, moment: (item: MediaItem) => string): Field { + return { + id, + type: 'datetime', + label, + getValue: ({ item }) => moment(item), + format: { datetime: getSettings().formats.datetime, weekStartsOn: getSettings().l10n.startOfWeek }, + filterBy: { operators: ['before', 'after'] }, readOnly: true, - }, - { - id: 'altText', - label: __('Alt text', 'gophenberg'), - Edit: { control: 'textarea', rows: 2 }, - enableSorting: false, - }, - { - id: 'caption', - label: __('Caption', 'gophenberg'), - Edit: { control: 'textarea', rows: 2 }, - enableSorting: false, - }, - { - id: 'description', - label: __('Description', 'gophenberg'), - Edit: { control: 'textarea', rows: 5 }, - enableSorting: false, - }, -] - -export const describeForm = { - fields: ['title', 'altText', 'caption', 'description'], + } +} + +/** + * Returns the fields describing the stored file itself, which no reader changes. + * @returns The file name, the file size, the dimensions and the file type. + */ +function storedFileFields(): Field[] { + return [ + { + id: 'filename', + type: 'text', + label: __('File name', 'gophenberg'), + getValue: ({ item }) => fileNameOf(item.file), + render: FileNameCell, + enableSorting: false, + filterBy: false, + readOnly: true, + }, + { + id: 'filesize', + type: 'text', + label: __('File size', 'gophenberg'), + getValue: ({ item }) => fileSize(item.filesize), + isVisible: (item) => item.filesize > 0, + enableSorting: false, + filterBy: false, + readOnly: true, + }, + { + id: 'dimensions', + type: 'text', + label: __('Dimensions', 'gophenberg'), + getValue: ({ item }) => imageDimensions(item), + isVisible: (item) => item.width > 0 && item.height > 0, + enableSorting: false, + filterBy: false, + readOnly: true, + }, + { + id: 'mimeType', + type: 'text', + label: __('File type', 'gophenberg'), + render: MimeTypeCell, + enableSorting: false, + filterBy: false, + readOnly: true, + }, + ] +} + +/** + * Returns the WordPress attachment fields the library and the editor's media picker list, in the picker's order. + * @returns The thumbnail, the describing fields, both dates, the author and the stored file's fields. + */ +export function mediaFields(): Field[] { + return [ + { + id: 'thumbnail', + type: 'media', + label: __('Thumbnail', 'gophenberg'), + render: ThumbnailCell, + enableSorting: false, + filterBy: false, + }, + ...describingFields(), + momentField('date', __('Date added', 'gophenberg'), (item) => item.createdAt), + momentField('modified', __('Date modified', 'gophenberg'), (item) => item.updatedAt), + { ...authorField(), readOnly: true }, + ...storedFileFields(), + ] +} + +/** + * Returns the fields of the media library, the shared ones and the type filter WordPress's file type groups pick from. + * @returns The shared fields, then Type. + */ +export function libraryFields(): Field[] { + return [ + ...mediaFields(), + { + id: 'media_type', + label: __('Type', 'gophenberg'), + getValue: ({ item }) => kindOf(item.mimeType), + elements: [ + { value: 'image', label: __('Images', 'gophenberg') }, + { value: 'audio', label: _x('Audio', 'file type group', 'gophenberg') }, + { value: 'video', label: _x('Video', 'file type group', 'gophenberg') }, + { value: 'document', label: __('Documents', 'gophenberg') }, + { value: 'archive', label: _x('Archives', 'file type group', 'gophenberg') }, + ], + filterBy: { operators: ['is'] }, + enableSorting: false, + enableHiding: false, + readOnly: true, + }, + ] +} + +/** + * Returns the search, the sort and the filters of a media listing a view stands for, leaving its page to the caller. + * @param view - The view the list shows. + * @returns The part of the listing request the view decides. + */ +export function mediaListing(view: View): MediaQuery { + return { + search: view.search, + orderBy: view.sort?.field, + order: view.sort?.direction, + ...narrowedBy(view.filters ?? [], narrowings), + } } diff --git a/frontend/src/media/focusBack.ts b/frontend/src/media/focusBack.ts new file mode 100644 index 00000000..1e972665 --- /dev/null +++ b/frontend/src/media/focusBack.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useEffect, useRef, useState } from 'react' +import type { RefObject } from 'react' + +/** Finds the card of a grid or the row of a list that holds a file. */ +const HOLDERS = '.dataviews-view-grid__card, .dataviews-view-list__item-wrapper' + +/** Finds each element a dialog on a file opens from, inside the card or the row holding the file. */ +const DOORS = { + actions: '[aria-haspopup="menu"]', + title: '.dataviews-title-field[role="button"]', + picture: '.dataviews-view-grid__media[role="button"]', + row: '.dataviews-view-list__item', +} + +/** The element a dialog on a file opened from: its actions menu, the title or the picture of its card, or its row. */ +export type Door = keyof typeof DOORS + +/** One request to put the focus back on the element a dialog on a file opened from, a new one each time it is asked. */ +interface FocusBack { + /** The file. */ + id: number + /** The element the dialog opened from. */ + door: Door +} + +/** + * Returns the part of a card a click opened a dialog from, read from the focus the click put on it. + * @returns The picture while the focus sits on it, the title otherwise. + */ +export function clickedDoor(): Door { + return (document.activeElement as Element).matches(DOORS.picture) ? 'picture' : 'title' +} + +/** + * Returns the element a dialog on a file opened from, found through the file wherever its card or row now sits. + * @param asked - The file and the door. + * @returns The door, the actions menu of the file once the layout draws no such door, none while the list draws no card + * or row of the file. + */ +function doorOf({ id, door }: FocusBack): HTMLElement | null { + const marked = document.querySelector(`[data-media-item="${id}"]`) + if (marked === null) { + return null + } + const holder = marked.closest(HOLDERS) as Element + return holder.querySelector(DOORS[door]) ?? (holder.querySelector(DOORS.actions) as HTMLElement) +} + +/** + * Returns the handler putting a focus the page lost back on the element a dialog on a file opened from, once the list + * is drawn again, as WordPress's modal gives the focus back to what opened it, and on the list once the file left it. + * @param busy - Whether the list reads the library, its cards then out of reach of the focus. + * @param region - The list region. + * @returns The handler, given the file and the element the dialog opened from. + */ +export function useFocusBack(busy: boolean, region: RefObject): (id: number, door: Door) => void { + const [asked, setAsked] = useState() + const handled = useRef(undefined) + useEffect(() => { + if (busy || asked === handled.current) { + return + } + handled.current = asked + if (document.activeElement === document.body) { + const target = doorOf(asked as FocusBack) ?? (region.current as HTMLElement) + target.focus() + } + }, [asked, busy, region]) + return useCallback((id: number, door: Door) => setAsked({ id, door }), []) +} diff --git a/frontend/src/media/format.ts b/frontend/src/media/format.ts index 2396be21..4798b0e1 100644 --- a/frontend/src/media/format.ts +++ b/frontend/src/media/format.ts @@ -1,32 +1,53 @@ // SPDX-License-Identifier: Apache-2.0 import { formatNumber } from '@gopherium/gottext' +import { __, _x, sprintf } from '@wordpress/i18n' + import type { MediaItem } from './api' -/** The binary steps a file size is reported in. */ -const sizeUnits = [ - { mag: 1024 ** 4, unit: 'TB' }, - { mag: 1024 ** 3, unit: 'GB' }, - { mag: 1024 ** 2, unit: 'MB' }, - { mag: 1024, unit: 'KB' }, - { mag: 1, unit: 'B' }, -] +/** The content type prefix of each WordPress file type group a stored file falls in, keyed by the group. */ +const KIND_PREFIXES: Record = { + image: 'image/', + audio: 'audio/', + video: 'video/', + document: 'application/pdf', + archive: 'application/zip', +} + +/** + * Returns the binary steps a file size is reported in, largest first, read fresh so the loaded catalogue answers. + * @returns Each step in bytes beside its unit symbol. + */ +function sizeUnits(): { mag: number, unit: string }[] { + return [ + { mag: 1024 ** 8, unit: _x('YB', 'unit symbol', 'gophenberg') }, + { mag: 1024 ** 7, unit: _x('ZB', 'unit symbol', 'gophenberg') }, + { mag: 1024 ** 6, unit: _x('EB', 'unit symbol', 'gophenberg') }, + { mag: 1024 ** 5, unit: _x('PB', 'unit symbol', 'gophenberg') }, + { mag: 1024 ** 4, unit: _x('TB', 'unit symbol', 'gophenberg') }, + { mag: 1024 ** 3, unit: _x('GB', 'unit symbol', 'gophenberg') }, + { mag: 1024 ** 2, unit: _x('MB', 'unit symbol', 'gophenberg') }, + { mag: 1024, unit: _x('KB', 'unit symbol', 'gophenberg') }, + { mag: 1, unit: _x('B', 'unit symbol', 'gophenberg') }, + ] +} /** - * Returns a byte count in the largest unit that leaves it above one. + * Returns a byte count in the largest unit that leaves it above one, as WordPress writes a file size. * @param bytes - The size of the file. * @returns The size to show, empty when there is nothing to report. */ export function fileSize(bytes: number): string { - const step = sizeUnits.find(({ mag }) => bytes >= mag) + const step = sizeUnits().find(({ mag }) => bytes >= mag) if (step === undefined) { return '' } - return `${formatNumber(bytes / step.mag, { maximumFractionDigits: 2 })} ${step.unit}` + const amount = formatNumber(bytes / step.mag, { maximumFractionDigits: 2 }) + return sprintf(_x('%1$s %2$s', 'file size', 'gophenberg'), amount, step.unit) } /** - * Returns the pixel dimensions of a picture. + * Returns the pixel dimensions of a picture, as WordPress writes them. * @param item - The media item to measure. * @returns The dimensions to show, empty for a file that carries none. */ @@ -34,16 +55,56 @@ export function imageDimensions(item: MediaItem): string { if (item.width <= 0 || item.height <= 0) { return '' } - return `${item.width} × ${item.height}` + return sprintf(_x('%1$s × %2$s', 'image dimensions', 'gophenberg'), String(item.width), String(item.height)) } /** - * Returns the name a media item is listed under. - * @param item - The media item to name. - * @returns The title, or the file name when the item has no title. + * Returns the WordPress file type group a content type falls in, read from its prefix as WordPress reads it. + * @param mimeType - The content type of the file. + * @returns The group, file for a type no group takes. */ -export function mediaName(item: MediaItem): string { - return item.title === '' ? item.file.slice(item.file.lastIndexOf('/') + 1) : item.title +export function kindOf(mimeType: string): string { + return Object.keys(KIND_PREFIXES).find((kind) => mimeType.startsWith(KIND_PREFIXES[kind])) ?? 'file' +} + +/** + * Returns the content type prefix the server narrows a listing to for a file type group. + * @param kind - The group. + * @returns The prefix. + */ +export function kindPrefix(kind: string): string { + return KIND_PREFIXES[kind] +} + +/** + * Reports whether a media item is a picture, told from its content type as WordPress tells one. + * @param item - The media item. + * @returns Whether its content type is an image type. + */ +export function isPicture(item: MediaItem): boolean { + return kindOf(item.mimeType) === 'image' +} + +/** + * Returns the name a media item is shown under, as WordPress names an untitled item. + * @param item - The media item. + * @returns The title, or (no title) when the item has none. + */ +export function titleOf(item: MediaItem): string { + return item.title === '' ? __('(no title)', 'gophenberg') : item.title +} + +/** + * Returns the sentence a failed edit of a file reads as, WordPress's words for a picture or for any other file. + * @param item - The file edited. + * @param reason - Why the edit failed, in the reader's words. + * @returns The sentence ending on the reason. + */ +export function saveFailure(item: MediaItem, reason: string): string { + if (isPicture(item)) { + return sprintf(__('Could not save image. %s', 'gophenberg'), reason) + } + return sprintf(__('Could not save media. %s', 'gophenberg'), reason) } /** diff --git a/frontend/src/media/media.css b/frontend/src/media/media.css index 9d854c49..be4581ff 100644 --- a/frontend/src/media/media.css +++ b/frontend/src/media/media.css @@ -1,25 +1,112 @@ /* SPDX-License-Identifier: Apache-2.0 */ .gophenberg-media__thumbnail { - width: 100%; - height: 100%; + display: flex; + position: relative; + align-items: center; + block-size: 100%; +} + +@media not (prefers-reduced-motion) { + .gophenberg-media__thumbnail.is-loading img, + .gophenberg-media__thumbnail.is-loaded img { + transition: opacity 0.1s linear; + } +} + +.gophenberg-media__thumbnail.is-loading img { + opacity: 0; +} + +.gophenberg-media__thumbnail.is-loaded img { + opacity: 1; +} + +.gophenberg-media__image { + display: block; + inline-size: 100%; + block-size: 100%; object-fit: cover; } -.gophenberg-media__stand-in { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - padding: var(--wpds-dimension-gap-sm); - overflow: hidden; - background: var(--wpds-color-background-surface-neutral-weak); +.gophenberg-media__stack { + box-sizing: border-box; + inline-size: 100%; + block-size: 100%; + color: var(--wpds-color-foreground-content-neutral-weak); +} + +.gophenberg-media__icon { + fill: currentColor; +} + +.gophenberg-media__name-box { + box-sizing: border-box; + inline-size: 100%; + padding: 0 var(--wpds-dimension-padding-lg); text-align: center; - word-break: break-all; + container-type: inline-size; +} + +.gophenberg-media__filename-text { + display: block; + margin-block-start: var(--wpds-dimension-gap-xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@container (max-width: 90px) { + .gophenberg-media__filename-text { + display: none; + } +} + +.dataviews-view-table .gophenberg-media__filename, +.dataviews-view-grid .gophenberg-media__filename, +.dataviews-view-list .gophenberg-media__filename { + display: inline-block; + max-inline-size: 15ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: top; } .gophenberg-media__notice { white-space: pre-line; } +.components-modal__content:has(.gophenberg-media__edit-fields) { + display: flex; + flex-direction: column; + padding: 0; +} + +.components-modal__content:has(.gophenberg-media__edit-fields) > .components-modal__children-container { + display: flex; + flex-direction: column; + min-height: 0; +} + +.godmin-modal-body:has(> .gophenberg-media__edit-fields) { + min-height: 0; + padding: 0 var(--wpds-dimension-padding-2xl); +} + +.gophenberg-media__edit-fields { + min-height: 0; + margin-inline: calc(-1 * var(--wpds-dimension-padding-2xl)); + padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-2xl) var(--wpds-dimension-padding-2xl); + overflow-y: auto; +} + +.gophenberg-media__edit-fields.is-scrollable:focus-visible { + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); + outline: 2px solid transparent; + outline-offset: -2px; +} + +.components-modal__content:has(.gophenberg-media__edit-fields.has-scrolled-content) .components-modal__header { + border-bottom-color: var(--wpds-color-stroke-surface-neutral); +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 19842879..40ac5de0 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -99,6 +99,7 @@ const newUserRoute = createRoute({ const mediaRoute = createRoute({ getParentRoute: () => framedRoute, path: '/media', + validateSearch: listSearch, component: lazyRouteComponent(() => import('./media/MediaScreen'), 'MediaScreen'), }) diff --git a/frontend/src/test/date-cells.test.tsx b/frontend/src/test/date-cells.test.tsx index ca8e0eec..eddcc3d4 100644 --- a/frontend/src/test/date-cells.test.tsx +++ b/frontend/src/test/date-cells.test.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import { DataViews } from '@gophenberg/frontend-sdk/dataviews' import { http, HttpResponse, server } from '@gophenberg/frontend-sdk/testing' import { LIST_CHROME_DOMAIN, listChromeCatalogFor } from '@gopherium/godmin' import { startListDates } from '@gopherium/godmin/dates' @@ -74,6 +75,8 @@ const item: MediaItem = { filesize: 1, sizes: {}, authorId: '1', + authorName: 'Maria Perez', + authorAvatar: '', createdAt: NOON, updatedAt: NOON, } @@ -115,11 +118,49 @@ test('writes a post date in the words of the list chrome catalogue when its own expect(screen.getByText('Publicado: 5 Ene 2026 08:09')).toBeInTheDocument() }) -test.each(['es-ES', 'en-US'])('shows a media date in the site format to a reader of %s', (reader) => { - rememberFormatLocale('es-ES') - rememberLocale(reader) +/** + * Renders the Date added and Date modified of a media item as a DataViews table draws them. + * @param held - The item to render. + */ +function renderMediaDates(held: MediaItem) { + render( + {}} + paginationInfo={{ totalItems: 1, totalPages: 1 }} + defaultLayouts={{ table: {} }} + getItemId={(row) => String(row.id)} + />, + ) +} + +test('writes both media dates in the full date and time format WordPress media fields use', () => { + startListDates(DEFAULT_LOCALE) + + renderMediaDates({ ...item, updatedAt: WINTER_MORNING }) + + expect(screen.getByText('August 16, 2026 12:00 pm')).toBeInTheDocument() + expect(screen.getByText('January 5, 2026 8:09 am')).toBeInTheDocument() +}) + +test('writes both media dates in the full format of the language the admin booted in', async () => { + setLocaleData(await listChromeCatalogFor('es-ES'), LIST_CHROME_DOMAIN) + onTestFinished(() => resetLocaleData({}, LIST_CHROME_DOMAIN)) + startListDates('es-ES') + + renderMediaDates({ ...item, updatedAt: WINTER_MORNING }) + + expect(screen.getByText('16 de agosto de 2026 12:00')).toBeInTheDocument() + expect(screen.getByText('5 de enero de 2026 08:09')).toBeInTheDocument() +}) + +test('writes no date for a media item that carries no moment', () => { + startListDates(DEFAULT_LOCALE) - renderDate(mediaFields, item) + renderMediaDates({ ...item, createdAt: '', updatedAt: '' }) - expect(screen.getByText('16/08/2026')).toBeInTheDocument() + const cells = [...document.querySelectorAll('.dataviews-view-table tbody td')].map((cell) => cell.textContent) + expect(cells).toEqual(['One', '', '']) }) diff --git a/frontend/src/test/editor-media.test.ts b/frontend/src/test/editor-media.test.ts index 39a172db..cd8578e6 100644 --- a/frontend/src/test/editor-media.test.ts +++ b/frontend/src/test/editor-media.test.ts @@ -232,6 +232,8 @@ test('places the scaled display copy of an oversized picture', () => { }, }, authorId: '', + authorName: '', + authorAvatar: '', createdAt: '', updatedAt: '', }) @@ -255,6 +257,8 @@ test('shapes a plain file without measurements', () => { filesize: 12000, sizes: {}, authorId: '', + authorName: '', + authorAvatar: '', createdAt: '', updatedAt: '', }) diff --git a/frontend/src/test/media-actions.test.tsx b/frontend/src/test/media-actions.test.tsx new file mode 100644 index 00000000..45cd87c2 --- /dev/null +++ b/frontend/src/test/media-actions.test.tsx @@ -0,0 +1,1337 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { http, HttpResponse, server } from '@gophenberg/frontend-sdk/testing' +import { buttonClasses, setViewport, stubLayout } from '@gopherium/godmin/testing' +import { seedSession } from '@gopherium/react-auth/testing' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { resetLocaleData, setLocaleData } from '@wordpress/i18n' +import { beforeAll, beforeEach, expect, onTestFinished, test, vi } from 'vitest' + +import { bulkBar } from './bulkBar' +import { gate } from './gate' +import { adminUser, renderAt } from './render' +import { siteSettings } from './siteSettings' +import { clickDimmedPage, findToast, menuItems, waitBusy } from './typeFixtures' + +beforeAll(async () => { + await import('../media/MediaScreen') +}) + +const FOREIGN_AUTHOR = '019fb000-0000-7000-8000-0000000000ee' + +/** The version the list read each file at. */ +const LISTED_VERSION = '2026-08-12T10:00:00Z' + +/** The version the server holds each file at once another write landed after the list was read. */ +const CURRENT_VERSION = '2026-08-13T08:30:00Z' + +/** An author, who may change only the files it uploaded. */ +const AUTHOR = { ...adminUser, role: 'author', capabilities: [] } + +const HARBOR = { + id: 7, + type: 'image', + file: '2026/08/harbor-at-dawn-from-the-pier.jpg', + title: 'Harbor at dawn', + alt_text: 'Boats at sunrise', + caption: 'Before the market opens', + description: 'From the eastern pier', + mime_type: 'image/jpeg', + width: 1600, + height: 1000, + filesize: 254000, + sizes: {}, + author_id: adminUser.id, + author_name: 'Maria Perez', + author_avatar: '', + created_at: '2026-08-10T10:00:00Z', + updated_at: LISTED_VERSION, +} + +const MANUAL = { + ...HARBOR, + id: 8, + type: 'file', + file: '2026/08/manual.pdf', + title: 'Manual', + alt_text: '', + caption: '', + description: '', + mime_type: 'application/pdf', + width: 0, + height: 0, + filesize: 12000, +} + +/** The reason the server gives for a file the library no longer holds, in the reader's words. */ +const NOT_FOUND = [ + 'This media item is not in the library, and someone may have deleted it.', + 'Reload the page to see what is there now.', +].join(' ') + +/** The cell a card shows the title of its file in, or anything inside it. */ +const TITLE_CELL = '.dataviews-title-field, .dataviews-title-field *' + +/** The files the server holds, by id, changed by every edit and delete it lets through. */ +const stored = new Map>() + +/** The body of every edit the server took. */ +const patched: Record[] = [] + +/** The id of every file whose version was read. */ +const versionReads: number[] = [] + +/** The id of every file deleted. */ +const deleted: number[] = [] + +/** How many times the library was listed. */ +let listings = 0 + +beforeEach(() => { + stored.clear() + stored.set(HARBOR.id, { ...HARBOR }) + stored.set(MANUAL.id, { ...MANUAL }) + patched.length = 0 + versionReads.length = 0 + deleted.length = 0 + listings = 0 + server.use( + http.get('/api/media', () => { + listings += 1 + const items = [...stored.values()] + return HttpResponse.json({ items, total: items.length, per_page: siteSettings.list_page_size }) + }), + http.get('/api/media/:id', ({ params }) => { + versionReads.push(Number(params.id)) + return HttpResponse.json({ ...stored.get(Number(params.id)), updated_at: CURRENT_VERSION }) + }), + http.patch('/api/media/:id', async ({ params, request }) => kept(Number(params.id), await request.json())), + http.delete('/api/media/:id', ({ params }) => { + deleted.push(Number(params.id)) + stored.delete(Number(params.id)) + return new HttpResponse(null, { status: 204 }) + }), + http.get('/api/authors', () => HttpResponse.json({ items: [{ id: adminUser.id, name: 'Maria Perez' }] })), + ) +}) + +/** + * Stores an edit the server took and answers with the file as it now stands. + * @param id - The file edited. + * @param body - The body of the edit. + * @returns The answer. + */ +function kept(id: number, body: unknown): Response { + patched.push(body as Record) + const next = { ...stored.get(id), ...(body as Record) } + stored.set(id, next) + return HttpResponse.json(next) +} + +/** + * Answers a request as the server answers for a file the library no longer holds. + * @returns The refusal. + */ +function gone(): Response { + return HttpResponse.json({ error: 'gone', code: 'media_not_found' }, { status: 404 }) +} + +/** + * Refuses every edit until the returned switch lets them through, as a server that cannot find the file. + * @returns The switch letting edits through. + */ +function refuseEdits(): { allow: () => void } { + let refusing = true + server.use( + http.patch('/api/media/:id', async ({ params, request }) => + refusing ? gone() : kept(Number(params.id), await request.json()), + ), + ) + return { allow: () => (refusing = false) } +} + +/** + * Holds the first edit until the returned gate opens, then refuses it, letting every later edit through. + * @returns The gate the first edit waits on and the count of edits sent so far. + */ +function holdFirstEditThenRefuse() { + const first = gate() + const sent = { writes: 0 } + server.use( + http.patch('/api/media/:id', async ({ params, request }) => { + sent.writes += 1 + if (sent.writes === 1) { + await first.held + return gone() + } + return kept(Number(params.id), await request.json()) + }), + ) + return { first, sent } +} + +/** + * Returns how many milliseconds pass, so a write that does not wait its turn has the time to go out. + * @returns A promise settling once the time has passed. + */ +function aMoment() { + return act(() => new Promise((resolve) => setTimeout(resolve, 50))) +} + +/** Finds the element of a card a dialog on its file opens from. */ +type CardDoor = (card: Element) => HTMLElement | null + +/** Finds the button of a card opening its actions menu. */ +const menuDoor: CardDoor = (card) => card.querySelector('[aria-haspopup="menu"]') + +/** Finds the title of a card a click opens Edit from. */ +const titleDoor: CardDoor = (card) => card.querySelector('.dataviews-title-field[role="button"]') + +/** Finds the picture of a card a click opens Edit from. */ +const pictureDoor: CardDoor = (card) => card.querySelector('.dataviews-view-grid__media[role="button"]') + +/** + * Returns the card of the grid holding the given title. + * @param title - The title the card shows. + * @returns The card. + */ +async function cardHolding(title: string): Promise { + const shown = await screen.findByText(title, { selector: TITLE_CELL }) + return shown.closest('.dataviews-view-grid__card') as HTMLElement +} + +/** + * Returns the queries of the card of the grid holding the given title. + * @param title - The title the card shows. + * @returns The queries bound to the card. + */ +async function cardOf(title: string) { + return within(await cardHolding(title)) +} + +/** + * Opens the actions menu of the card holding the given title. + * @param title - The title the card shows. + */ +async function openCardMenu(title: string) { + const card = await cardOf(title) + await userEvent.click(card.getByRole('button', { name: 'Actions' })) + await screen.findByRole('menu') +} + +/** + * Picks an item of the actions menu of a card and returns the dialog it opens. + * @param title - The title the card shows. + * @param item - The words of the menu item. + * @returns The dialog. + */ +async function openCardDialog(title: string, item: string): Promise { + await openCardMenu(title) + await userEvent.click(await screen.findByRole('menuitem', { name: item })) + return screen.findByRole('dialog') +} + +/** + * Types new text into a box of an open dialog, in place of what it held. + * @param dialog - The dialog. + * @param label - The label of the box. + * @param text - The new text. + */ +async function retype(dialog: HTMLElement, label: string, text: string) { + const box = within(dialog).getByRole('textbox', { name: label }) + await userEvent.clear(box) + await userEvent.type(box, text) +} + +/** + * Presses Save in an open dialog and waits for the dialog to close. + * @param dialog - The dialog. + */ +async function save(dialog: HTMLElement) { + await userEvent.click(within(dialog).getByRole('button', { name: 'Save' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) +} + +/** + * Returns the value each box of an open Edit holds, by its label. + * @param dialog - The Edit dialog. + * @returns The values. + */ +function boxes(dialog: HTMLElement): Record { + const shown = within(dialog).getAllByRole('textbox') as HTMLInputElement[] + return Object.fromEntries(shown.map((box) => [box.labels?.[0]?.textContent ?? '', box.value])) +} + +/** + * Returns the labels of the rows a reader only reads in an open Edit, in order. + * @param dialog - The Edit dialog. + * @returns The labels. + */ +function readOnlyRows(dialog: HTMLElement): (string | null)[] { + return [...dialog.querySelectorAll('.dataforms-layouts-panel__field-label')].map((label) => label.textContent) +} + +test('offers View, Edit…, Rename… and Permanently delete…, in that order, on a file one may change', async () => { + renderAt('/media') + + await openCardMenu('Harbor at dawn') + + expect(menuItems()).toEqual(['View', 'Edit…', 'Rename…', 'Permanently delete…']) +}) + +test('offers an author only View on a file another account uploaded', async () => { + stored.set(HARBOR.id, { ...HARBOR, author_id: FOREIGN_AUTHOR }) + renderAt('/media', AUTHOR) + + await openCardMenu('Harbor at dawn') + + expect(menuItems()).toEqual(['View']) +}) + +test('offers every action on a file another account uploaded once the session may change others work', async () => { + stored.set(HARBOR.id, { ...HARBOR, author_id: FOREIGN_AUTHOR }) + const client = renderAt('/media', AUTHOR) + await openCardMenu('Harbor at dawn') + expect(menuItems()).toEqual(['View']) + await userEvent.keyboard('{Escape}') + await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument()) + + act(() => seedSession(client, adminUser)) + await openCardMenu('Harbor at dawn') + + expect(menuItems()).toEqual(['View', 'Edit…', 'Rename…', 'Permanently delete…']) +}) + +test('opens the file in a new tab from View', async () => { + const opened = vi.spyOn(window, 'open').mockImplementation(() => null) + renderAt('/media') + await openCardMenu('Manual') + + await userEvent.click(screen.getByRole('menuitem', { name: 'View' })) + + expect(opened).toHaveBeenCalledWith('/media/2026/08/manual.pdf', '_blank') +}) + +test('offers View as a button of its own on a phone row, opening the file in a new tab', async () => { + setViewport({ width: 639 }) + const opened = vi.spyOn(window, 'open').mockImplementation(() => null) + renderAt('/media') + + const views = await screen.findAllByRole('button', { name: 'View' }) + await userEvent.click(views[1]) + + expect(views).toHaveLength(2) + expect(opened).toHaveBeenCalledWith('/media/2026/08/manual.pdf', '_blank') +}) + +test('names View in the words the catalogue gives the verb', async () => { + setLocaleData({ '': { 'plural-forms': 'nplurals=2; plural=(n != 1);' }, 'verb\u0004View': ['Open'] }, 'gophenberg') + onTestFinished(() => resetLocaleData({}, 'gophenberg')) + renderAt('/media') + + await openCardMenu('Harbor at dawn') + + expect(menuItems()[0]).toBe('Open') +}) + +test('edits in a medium dialog headed Edit media, the Title box focused', async () => { + stubLayout() + renderAt('/media') + + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + + expect(dialog).toHaveClass('has-size-medium') + expect(within(dialog).getByRole('heading', { name: 'Edit media' })).toBeInTheDocument() + await waitFor(() => expect(within(dialog).getByRole('textbox', { name: 'Title' })).toHaveFocus()) +}) + +test('lays out the four boxes a reader writes in, then the six rows a reader only reads', async () => { + renderAt('/media') + + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + + expect(boxes(dialog)).toEqual({ + Title: 'Harbor at dawn', + 'Alt text': 'Boats at sunrise', + Caption: 'Before the market opens', + Description: 'From the eastern pier', + }) + expect(readOnlyRows(dialog)).toEqual(['Date added', 'Author', 'File name', 'File type', 'File size', 'Dimensions']) + const last = within(dialog).getByRole('textbox', { name: 'Description' }) + const firstRow = dialog.querySelector('.dataforms-layouts-panel__field-label') as HTMLElement + expect(last.compareDocumentPosition(firstRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(within(dialog).queryByRole('button', { name: /^Edit / })).not.toBeInTheDocument() +}) + +test('writes the alt text and the caption in boxes two lines tall, the description five lines tall', async () => { + renderAt('/media') + + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + + const tall = ['Alt text', 'Caption', 'Description'].map((label) => { + const box = within(dialog).getByRole('textbox', { name: label }) + return [box.tagName, box.getAttribute('rows')] + }) + expect(tall).toEqual([ + ['TEXTAREA', '2'], + ['TEXTAREA', '2'], + ['TEXTAREA', '5'], + ]) +}) + +test('shows a long file name whole in Edit', async () => { + renderAt('/media') + + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + + expect(within(dialog).getByText('harbor-at-dawn-from-the-pier.jpg')).toBeInTheDocument() +}) + +test('leaves out the alt text and the dimensions of a file that is no picture', async () => { + renderAt('/media') + + const dialog = await openCardDialog('Manual', 'Edit…') + + expect(Object.keys(boxes(dialog))).toEqual(['Title', 'Caption', 'Description']) + expect(readOnlyRows(dialog)).toEqual(['Date added', 'Author', 'File name', 'File type', 'File size']) +}) + +test('keeps Save on as Edit opens, after a Cancel drawn as accent text, as WordPress draws a row edit', async () => { + renderAt('/media') + + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + + const form = within(dialog).getByRole('textbox', { name: 'Title' }).closest('form') as HTMLElement + expect([...form.classList]).toEqual(['godmin-modal-body']) + const [cancel, submit, ...rest] = within(form).getAllByRole('button') + expect([cancel.textContent, submit.textContent, rest]).toEqual(['Cancel', 'Save', []]) + expect([...cancel.classList]).toEqual(buttonClasses('minimal')) + expect(submit).toHaveAttribute('type', 'submit') + expect(submit).not.toHaveAttribute('aria-disabled', 'true') +}) + +test('scrolls the fields of Edit in a pane above Cancel and Save, as WordPress scrolls its media editor', async () => { + renderAt('/media') + + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + + const title = within(dialog).getByRole('textbox', { name: 'Title' }) + const pane = title.closest('.gophenberg-media__edit-fields') as HTMLElement + expect(within(pane).getByText('Dimensions')).toBeInTheDocument() + expect(within(pane).queryAllByRole('button', { name: /^(Cancel|Save)$/ })).toEqual([]) + expect(getComputedStyle(pane).overflowY).toBe('auto') +}) + +/** + * Makes the fields pane of Edit report fields taller than itself, as a short window lays them out, until the test ends. + */ +function overflowFields() { + const pane = (element: Element) => element.classList.contains('gophenberg-media__edit-fields') + const tall = vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockImplementation(function (this: Element) { + return pane(this) ? 900 : 0 + }) + const shown = vi.spyOn(Element.prototype, 'clientHeight', 'get').mockImplementation(function (this: Element) { + return pane(this) ? 600 : 0 + }) + onTestFinished(() => { + tall.mockRestore() + shown.mockRestore() + }) +} + +/** + * Returns the pane the fields of an open Edit scroll in. + * @param dialog - The Edit dialog. + * @returns The pane. + */ +function fieldsPane(dialog: HTMLElement): HTMLElement { + return within(dialog).getByRole('textbox', { name: 'Title' }).closest('.gophenberg-media__edit-fields') as HTMLElement +} + +/** + * Tells whether the header of an open dialog draws the line the Modal draws under it once its content scrolled. + * @param dialog - The dialog. + * @returns Whether the bottom border of the header takes the colour of a neutral surface stroke. + */ +function headerLine(dialog: HTMLElement): boolean { + const header = dialog.querySelector('.components-modal__header') as HTMLElement + return /^var\(--wpds-color-stroke-surface-neutral\b/.test(getComputedStyle(header).borderBottomColor) +} + +test('names the fields pane of Edit Scrollable section while its fields overflow it, as the Modal does', async () => { + overflowFields() + renderAt('/media') + + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + + const pane = within(dialog).getByRole('document', { name: 'Scrollable section' }) + expect(pane).toBe(fieldsPane(dialog)) + expect(pane).toHaveClass('is-scrollable') +}) + +test('focuses the Title box as Edit opens on overflowing fields, then makes their pane a tab stop', async () => { + stubLayout() + overflowFields() + renderAt('/media') + + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + + await waitFor(() => expect(within(dialog).getByRole('textbox', { name: 'Title' })).toHaveFocus()) + expect(fieldsPane(dialog)).toHaveAttribute('tabindex', '0') +}) + +test('leaves the Edit fields pane unnamed, out of the tab order, no line above, while its fields fit', async () => { + renderAt('/media') + + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + + const pane = fieldsPane(dialog) + expect(pane).not.toHaveAttribute('aria-label') + expect(pane).not.toHaveAttribute('tabindex') + expect(pane).not.toHaveClass('is-scrollable') + expect(headerLine(dialog)).toBe(false) +}) + +test('draws the line under the Edit header once the fields pane scrolled, gone again back at the top', async () => { + overflowFields() + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + const pane = fieldsPane(dialog) + expect(headerLine(dialog)).toBe(false) + + for (const { top, drawn } of [{ top: 40, drawn: true }, { top: 0, drawn: false }]) { + Object.defineProperty(pane, 'scrollTop', { configurable: true, value: top }) + fireEvent.scroll(pane) + expect(headerLine(dialog)).toBe(drawn) + } +}) + +test('names the Edit fields pane once its fields grow past it, watched as the Modal watches content', async () => { + const watched = new Map() + const stopped: ResizeObserverCallback[] = [] + vi.stubGlobal( + 'ResizeObserver', + class { + seen: ResizeObserverCallback + constructor(seen: ResizeObserverCallback) { + this.seen = seen + } + observe(target: Element) { + watched.set(target, this.seen) + } + unobserve() {} + disconnect() { + stopped.push(this.seen) + } + }, + ) + onTestFinished(() => { + vi.unstubAllGlobals() + }) + stubLayout() + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + const pane = fieldsPane(dialog) + const watching = [...watched].find(([target]) => target.parentElement === pane) + const [content, seen] = watching as [Element, ResizeObserverCallback] + const title = within(dialog).getByRole('textbox', { name: 'Title' }) + expect(content).toContainElement(title) + await waitFor(() => expect(title).toHaveFocus()) + expect(pane).not.toHaveAttribute('aria-label') + expect(pane).not.toHaveAttribute('tabindex') + + overflowFields() + act(() => seen([], {} as ResizeObserver)) + + expect(pane).toHaveAttribute('aria-label', 'Scrollable section') + expect(pane).toHaveAttribute('tabindex', '0') + await userEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })) + expect(stopped).toContain(seen) +}) + +test('names the fields pane of Edit in the words the catalogue the Modal reads gives Scrollable section', async () => { + setLocaleData({ 'Scrollable section': ['Scrolling fields'] }) + onTestFinished(() => resetLocaleData({})) + overflowFields() + renderAt('/media') + + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + + expect(fieldsPane(dialog)).toHaveAttribute('aria-label', 'Scrolling fields') +}) + +test('opens the same Edit from a click on the title of a card', async () => { + stubLayout() + renderAt('/media') + const card = await cardOf('Harbor at dawn') + + await userEvent.click(card.getByText('Harbor at dawn')) + + const dialog = await screen.findByRole('dialog', { name: 'Edit media' }) + expect(dialog).toHaveClass('has-size-medium') + expect(boxes(dialog)).toMatchObject({ Title: 'Harbor at dawn', Caption: 'Before the market opens' }) + await waitFor(() => expect(within(dialog).getByRole('textbox', { name: 'Title' })).toHaveFocus()) +}) + +test('closes the Edit opened from a title as Save is pressed, then says Media file updated.', async () => { + renderAt('/media') + await userEvent.click((await cardOf('Harbor at dawn')).getByText('Harbor at dawn')) + const dialog = await screen.findByRole('dialog', { name: 'Edit media' }) + await retype(dialog, 'Title', 'Harbor at noon') + + await save(dialog) + + expect(await findToast('Media file updated.')).toBeInTheDocument() + expect(patched).toEqual([{ updated_at: CURRENT_VERSION, title: 'Harbor at noon' }]) +}) + +test('closes the Edit opened from a title through its Cancel, sending nothing', async () => { + renderAt('/media') + await userEvent.click((await cardOf('Harbor at dawn')).getByText('Harbor at dawn')) + const dialog = await screen.findByRole('dialog', { name: 'Edit media' }) + + await userEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(patched).toEqual([]) +}) + +test('closes Edit as Save is pressed, before the write answers, then says Media file updated.', async () => { + const answer = gate() + server.use( + http.patch('/api/media/:id', async ({ params, request }) => { + const body = await request.json() + await answer.held + return kept(Number(params.id), body) + }), + ) + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + await retype(dialog, 'Title', 'Harbor at noon') + + await save(dialog) + + expect(screen.queryByText('Media file updated.')).not.toBeInTheDocument() + answer.release() + expect(await findToast('Media file updated.')).toBeInTheDocument() + expect(await cardOf('Harbor at noon')).toBeTruthy() +}) + +test('writes over the version read just before the write, sending only the fields the reader changed', async () => { + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + await retype(dialog, 'Title', 'Harbor at noon') + await retype(dialog, 'Caption', 'After the market closes') + + await save(dialog) + + await waitFor(() => + expect(patched).toEqual([ + { updated_at: CURRENT_VERSION, title: 'Harbor at noon', caption: 'After the market closes' }, + ]), + ) + expect(versionReads).toEqual([HARBOR.id]) +}) + +test('sends Edit from Enter in the Title box', async () => { + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + await retype(dialog, 'Title', 'Harbor at noon') + + await userEvent.keyboard('{Enter}') + + await waitFor(() => expect(patched).toEqual([{ updated_at: CURRENT_VERSION, title: 'Harbor at noon' }])) +}) + +test.each([ + { kind: 'a picture', title: 'Harbor at dawn', words: 'Could not save image.' }, + { kind: 'a file that is no picture', title: 'Manual', words: 'Could not save media.' }, +])('says $words with the reason in a toast when the edit of $kind fails', async ({ title, words }) => { + refuseEdits() + renderAt('/media') + const dialog = await openCardDialog(title, 'Edit…') + + await save(dialog) + + expect(await findToast(`${words} ${NOT_FOUND}`)).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() +}) + +test('says the server could not be reached when the network drops an edit', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + server.use(http.patch('/api/media/:id', () => HttpResponse.error())) + renderAt('/media') + const dialog = await openCardDialog('Manual', 'Edit…') + + await save(dialog) + + expect(await findToast('Could not save media. The server could not be reached, so nothing was saved.')).toBeTruthy() +}) + +test('reads the list again after a failed edit, so the card holds the newest version', async () => { + refuseEdits() + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + const before = listings + + await save(dialog) + + await findToast(`Could not save image. ${NOT_FOUND}`) + expect(listings).toBeGreaterThan(before) +}) + +test('offers the fields a failed edit left unsaved when Edit opens again, through a Cancel', async () => { + refuseEdits() + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + await retype(dialog, 'Title', 'Harbor at noon') + await retype(dialog, 'Alt text', 'Boats at noon') + await save(dialog) + await findToast(`Could not save image. ${NOT_FOUND}`) + const kept = await openCardDialog('Harbor at dawn', 'Edit…') + await userEvent.click(within(kept).getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + + const again = await openCardDialog('Harbor at dawn', 'Edit…') + + expect(boxes(again)).toMatchObject({ + Title: 'Harbor at noon', + 'Alt text': 'Boats at noon', + Caption: 'Before the market opens', + }) +}) + +test('offers the fields a failed edit left unsaved only on the file they were typed for', async () => { + refuseEdits() + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + await retype(dialog, 'Caption', 'After the market closes') + await save(dialog) + await findToast(`Could not save image. ${NOT_FOUND}`) + + const other = await openCardDialog('Manual', 'Edit…') + + expect(boxes(other)).toMatchObject({ Caption: '' }) +}) + +test('opens Edit on what a later save stored, dropping what an earlier failed edit kept', async () => { + const edits = refuseEdits() + renderAt('/media') + const failing = await openCardDialog('Harbor at dawn', 'Edit…') + await retype(failing, 'Caption', 'A failed caption') + await save(failing) + await findToast(`Could not save image. ${NOT_FOUND}`) + edits.allow() + const retried = await openCardDialog('Harbor at dawn', 'Edit…') + await retype(retried, 'Caption', 'A saved caption') + await save(retried) + await findToast('Media file updated.') + + const again = await openCardDialog('Harbor at dawn', 'Edit…') + + expect(boxes(again)).toMatchObject({ Caption: 'A saved caption' }) +}) + +test('opens Edit on the saved fields when an earlier edit fails after a later one was sent', async () => { + const { first, sent } = holdFirstEditThenRefuse() + renderAt('/media') + const failing = await openCardDialog('Harbor at dawn', 'Edit…') + await retype(failing, 'Title', 'A failed title') + await save(failing) + await waitFor(() => expect(sent.writes).toBe(1)) + const later = await openCardDialog('Harbor at dawn', 'Edit…') + await retype(later, 'Title', 'A saved title') + await save(later) + + first.release() + + await findToast(`Could not save image. ${NOT_FOUND}`) + await findToast('Media file updated.') + const again = await openCardDialog('A saved title', 'Edit…') + expect(boxes(again)).toMatchObject({ Title: 'A saved title' }) +}) + +test('keeps nothing of an edit that fails after a later rename of the file was sent', async () => { + const { first, sent } = holdFirstEditThenRefuse() + renderAt('/media') + const edit = await openCardDialog('Harbor at dawn', 'Edit…') + await retype(edit, 'Alt text', 'Boats at noon') + await save(edit) + await waitFor(() => expect(sent.writes).toBe(1)) + const rename = await openCardDialog('Harbor at dawn', 'Rename…') + await retype(rename, 'Name', 'Harbor renamed') + await save(rename) + + first.release() + + await findToast(`Could not save image. ${NOT_FOUND}`) + await findToast('Name updated.') + const again = await openCardDialog('Harbor renamed', 'Edit…') + expect(boxes(again)).toMatchObject({ Title: 'Harbor renamed', 'Alt text': 'Boats at sunrise' }) +}) + +test('drops the title a failed edit kept once a rename is sent, keeping its alt text', async () => { + const edits = refuseEdits() + renderAt('/media') + const edit = await openCardDialog('Harbor at dawn', 'Edit…') + await retype(edit, 'Title', 'A failed title') + await retype(edit, 'Alt text', 'Boats at noon') + await save(edit) + await findToast(`Could not save image. ${NOT_FOUND}`) + edits.allow() + const rename = await openCardDialog('Harbor at dawn', 'Rename…') + await retype(rename, 'Name', 'Harbor renamed') + await save(rename) + await findToast('Name updated.') + + const again = await openCardDialog('Harbor renamed', 'Edit…') + + expect(boxes(again)).toMatchObject({ Title: 'Harbor renamed', 'Alt text': 'Boats at noon' }) +}) + +test('drops the name a failed rename kept once an edit of the title is sent', async () => { + const edits = refuseEdits() + renderAt('/media') + const rename = await openCardDialog('Harbor at dawn', 'Rename…') + await retype(rename, 'Name', 'A failed name') + await save(rename) + await findToast(NOT_FOUND) + edits.allow() + const edit = await openCardDialog('Harbor at dawn', 'Edit…') + await retype(edit, 'Title', 'Harbor at noon') + await save(edit) + await findToast('Media file updated.') + + const again = await openCardDialog('Harbor at noon', 'Rename…') + + expect(within(again).getByRole('textbox', { name: 'Name' })).toHaveValue('Harbor at noon') +}) + +test('keeps the name a failed rename kept when an edit leaves the title alone', async () => { + const edits = refuseEdits() + renderAt('/media') + const rename = await openCardDialog('Harbor at dawn', 'Rename…') + await retype(rename, 'Name', 'A failed name') + await save(rename) + await findToast(NOT_FOUND) + edits.allow() + const edit = await openCardDialog('Harbor at dawn', 'Edit…') + await retype(edit, 'Caption', 'After the market closes') + await save(edit) + await findToast('Media file updated.') + + const again = await openCardDialog('Harbor at dawn', 'Rename…') + + expect(within(again).getByRole('textbox', { name: 'Name' })).toHaveValue('A failed name') +}) + +test('renames in a medium dialog headed Rename, its Name box holding the stored title and focused', async () => { + stubLayout() + renderAt('/media') + + const dialog = await openCardDialog('Harbor at dawn', 'Rename…') + + expect(dialog).toHaveClass('has-size-medium') + expect(within(dialog).getByRole('heading', { name: 'Rename' })).toBeInTheDocument() + expect(within(dialog).getByRole('textbox', { name: 'Name' })).toHaveValue('Harbor at dawn') + await waitFor(() => expect(within(dialog).getByRole('textbox', { name: 'Name' })).toHaveFocus()) +}) + +test('keeps Save of Rename off while the Name is blank or spaces, after a Cancel drawn as accent text', async () => { + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Rename…') + const save = within(dialog).getByRole('button', { name: 'Save' }) + expect([...within(dialog).getByRole('button', { name: 'Cancel' }).classList]).toEqual(buttonClasses('minimal')) + expect(save).not.toHaveAttribute('aria-disabled', 'true') + + await retype(dialog, 'Name', ' ') + expect(save).toHaveAttribute('aria-disabled', 'true') + await userEvent.clear(within(dialog).getByRole('textbox', { name: 'Name' })) + + expect(save).toHaveAttribute('aria-disabled', 'true') +}) + +test('holds an empty Name for a file with no title', async () => { + stored.set(HARBOR.id, { ...HARBOR, title: '' }) + renderAt('/media') + + const dialog = await openCardDialog('(no title)', 'Rename…') + + expect(within(dialog).getByRole('textbox', { name: 'Name' })).toHaveValue('') +}) + +test('closes Rename as Save is pressed, writes over the version read first, then says Name updated.', async () => { + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Rename…') + await retype(dialog, 'Name', 'Harbor renamed') + + await save(dialog) + + expect(await findToast('Name updated.')).toBeInTheDocument() + expect(patched).toEqual([{ updated_at: CURRENT_VERSION, title: 'Harbor renamed' }]) + expect(versionReads).toEqual([HARBOR.id]) + expect(await cardOf('Harbor renamed')).toBeTruthy() +}) + +test('shows the reason a refused rename gave in a toast and offers the typed name again', async () => { + refuseEdits() + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Rename…') + await retype(dialog, 'Name', 'Harbor renamed') + await save(dialog) + + expect(await findToast(NOT_FOUND)).toBeInTheDocument() + const again = await openCardDialog('Harbor at dawn', 'Rename…') + expect(within(again).getByRole('textbox', { name: 'Name' })).toHaveValue('Harbor renamed') +}) + +test('says the name could not be updated when the network drops a rename', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + server.use(http.patch('/api/media/:id', () => HttpResponse.error())) + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Rename…') + + await save(dialog) + + expect(await findToast('The name could not be updated.')).toBeInTheDocument() +}) + +test('asks before deleting a file for good under no header, naming it in quotes, Cancel focused', async () => { + stubLayout() + renderAt('/media') + + const dialog = await openCardDialog('Harbor at dawn', 'Permanently delete…') + + expect(within(dialog).getByText('Delete "Harbor at dawn" for good? This cannot be undone.')).toBeInTheDocument() + expect(within(dialog).queryByRole('heading')).not.toBeInTheDocument() + expect(within(dialog).queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() + expect(dialog).toHaveClass('has-size-medium') + const [cancel, confirm] = within(dialog).getAllByRole('button') + expect([cancel.textContent, confirm.textContent]).toEqual(['Cancel', 'Delete permanently']) + expect([...cancel.classList]).toEqual(buttonClasses('minimal')) + await waitFor(() => expect(cancel).toHaveFocus()) + expect(deleted).toEqual([]) +}) + +test('names a file with no title as (no title) in the delete question', async () => { + stored.set(HARBOR.id, { ...HARBOR, title: '' }) + renderAt('/media') + + const dialog = await openCardDialog('(no title)', 'Permanently delete…') + + expect(within(dialog).getByText('Delete "(no title)" for good? This cannot be undone.')).toBeInTheDocument() +}) + +test('cuts a long title in the delete question at the length the site serves', async () => { + server.use(http.get('/api/settings', () => HttpResponse.json({ ...siteSettings, toast_name_length: 5 }))) + renderAt('/media') + + const dialog = await openCardDialog('Harbor at dawn', 'Permanently delete…') + + expect(within(dialog).getByText('Delete "Harbo…" for good? This cannot be undone.')).toBeInTheDocument() +}) + +test('keeps the confirm busy with Cancel greyed while the delete runs, then closes it and names the file', async () => { + const answer = gate() + server.use( + http.delete('/api/media/:id', async ({ params }) => { + await answer.held + deleted.push(Number(params.id)) + stored.delete(Number(params.id)) + return new HttpResponse(null, { status: 204 }) + }), + ) + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Permanently delete…') + + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete permanently' })) + + await waitBusy(dialog, 'Delete permanently') + expect(within(dialog).getByRole('button', { name: 'Cancel' })).toHaveAttribute('aria-disabled', 'true') + answer.release() + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(await findToast('"Harbor at dawn" permanently deleted.')).toBeInTheDocument() + expect(deleted).toEqual([HARBOR.id]) +}) + +/** + * Ticks both files of the library and opens the permanent delete of the bulk bar. + * @returns The confirm dialog. + */ +async function deleteBothTicked(): Promise { + await userEvent.click(await screen.findByRole('checkbox', { name: 'Harbor at dawn' })) + await userEvent.click(screen.getByRole('checkbox', { name: 'Manual' })) + await userEvent.click(bulkBar().getByRole('button', { name: 'Permanently delete…' })) + return screen.findByRole('dialog') +} + +test('deletes every ticked file behind one confirm and counts them', async () => { + renderAt('/media') + + const dialog = await deleteBothTicked() + expect(dialog).toHaveTextContent('Delete 2 items for good? This cannot be undone.') + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete permanently' })) + + expect(await findToast('2 items permanently deleted.')).toBeInTheDocument() + expect(deleted.sort()).toEqual([HARBOR.id, MANUAL.id]) +}) + +test('shows the reason a refused delete gave in a toast, the confirm closed and the card kept', async () => { + server.use(http.delete('/api/media/:id', gone)) + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Permanently delete…') + + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete permanently' })) + + expect(await findToast(NOT_FOUND)).toBeInTheDocument() + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(await cardOf('Harbor at dawn')).toBeTruthy() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() +}) + +test('counts only the files a partly refused bulk delete could not remove, in one toast', async () => { + server.use( + http.delete('/api/media/:id', ({ params }) => { + if (Number(params.id) === HARBOR.id) { + return gone() + } + deleted.push(Number(params.id)) + return new HttpResponse(null, { status: 204 }) + }), + ) + renderAt('/media') + + const dialog = await deleteBothTicked() + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete permanently' })) + + expect(await findToast('1 item could not be permanently deleted.')).toBeInTheDocument() + expect(deleted).toEqual([MANUAL.id]) + expect(document.querySelectorAll('.godmin-toast')).toHaveLength(1) +}) + +/** + * Starts the held permanent delete of a file and closes its busy confirm on the dimmed page. + * @param title - The title the card of the file shows. + * @param answer - Builds the answer the delete gets once the gate opens. + * @returns The gate the delete waits on. + */ +async function deleteThenLeave(title: string, answer: () => Response) { + const held = gate() + server.use( + http.delete('/api/media/:id', async () => { + await held.held + return answer() + }), + ) + renderAt('/media') + const confirm = await openCardDialog(title, 'Permanently delete…') + await userEvent.click(within(confirm).getByRole('button', { name: 'Delete permanently' })) + await waitBusy(confirm, 'Delete permanently') + await clickDimmedPage() + return held +} + +test('leaves a dialog opened on the card after a delete dismissed while busy open once that delete ends', async () => { + const held = await deleteThenLeave('Harbor at dawn', gone) + const edit = await openCardDialog('Harbor at dawn', 'Edit…') + + held.release() + await findToast(NOT_FOUND) + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) + + expect(screen.getByRole('dialog', { name: 'Edit media' })).toBe(edit) +}) + +test('keeps the focus in an Edit opened after a dismissed delete once that delete lands', async () => { + stubLayout() + const held = await deleteThenLeave('Manual', () => { + stored.delete(MANUAL.id) + return new HttpResponse(null, { status: 204 }) + }) + const edit = await openCardDialog('Harbor at dawn', 'Edit…') + const title = within(edit).getByRole('textbox', { name: 'Title' }) + await waitFor(() => expect(title).toHaveFocus()) + + held.release() + await findToast('"Manual" permanently deleted.') + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) + + expect(screen.getByRole('dialog', { name: 'Edit media' })).toBe(edit) + expect(title).toHaveFocus() +}) + +test('lands the focus on the list once a deleted file has left it', async () => { + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Permanently delete…') + + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete permanently' })) + + await findToast('"Harbor at dawn" permanently deleted.') + await waitFor(() => expect(screen.getByRole('region', { name: 'Media' })).toHaveFocus()) +}) + +/** Lists the files the server holds by title, as a library sorted by Title shows them. */ +function listByTitle() { + server.use( + http.get('/api/media', () => { + const items = [...stored.values()].sort((one, other) => String(one.title).localeCompare(String(other.title))) + return HttpResponse.json({ items, total: items.length, per_page: siteSettings.list_page_size }) + }), + ) +} + +test.each([ + { action: 'Edit…', box: 'Title', toast: 'Media file updated.' }, + { action: 'Rename…', box: 'Name', toast: 'Name updated.' }, +])('gives the focus back to the actions menu of the file $action saved, on its card where it moved', async ({ + action, + box, + toast, +}) => { + listByTitle() + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', action) + await retype(dialog, box, 'Zebra crossing') + + await save(dialog) + + await findToast(toast) + const moved = await cardOf('Zebra crossing') + await waitFor(() => expect(moved.getByRole('button', { name: 'Actions' })).toHaveFocus()) + const second = document.querySelectorAll('.dataviews-view-grid__card')[1] + expect(second).toContainElement(document.activeElement as HTMLElement) +}) + +/** A file another account uploaded while an edit was in flight, its title sorting before every other. */ +const ANCHOR = { ...MANUAL, id: 9, file: '2026/08/anchor-chain.pdf', title: 'Anchor chain' } + +/** + * Opens Edit from a part of the card of Harbor at dawn, as a click on it does. + * @param door - Finds the part clicked inside the card. + * @returns The dialog. + */ +async function openFromHarbor(door: CardDoor): Promise { + await userEvent.click(door(await cardHolding('Harbor at dawn')) as HTMLElement) + return screen.findByRole('dialog', { name: 'Edit media' }) +} + +test.each([ + { part: 'title', door: titleDoor }, + { part: 'picture', door: pictureDoor }, +])('gives the focus back to the $part Edit opened from, on its card where it moved, once Save stored it', async ({ + door, +}) => { + listByTitle() + renderAt('/media') + const dialog = await openFromHarbor(door) + await retype(dialog, 'Title', 'Zebra crossing') + + await save(dialog) + + await findToast('Media file updated.') + const moved = await cardHolding('Zebra crossing') + await waitFor(() => expect(door(moved)).toHaveFocus()) + expect(document.querySelectorAll('.dataviews-view-grid__card')[1]).toBe(moved) +}) + +test.each([ + { part: 'actions menu', door: menuDoor, open: () => openCardDialog('Harbor at dawn', 'Edit…') }, + { part: 'title', door: titleDoor, open: () => openFromHarbor(titleDoor) }, +])('gives the focus back to the $part of a file whose edit failed, on its card where it moved', async ({ + door, + open, +}) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + listByTitle() + server.use( + http.patch('/api/media/:id', () => { + stored.set(ANCHOR.id, { ...ANCHOR }) + return HttpResponse.error() + }), + ) + renderAt('/media') + + await save(await open()) + + await findToast('Could not save image. The server could not be reached, so nothing was saved.') + const moved = await cardHolding('Harbor at dawn') + await waitFor(() => expect(door(moved)).toHaveFocus()) + expect(document.querySelectorAll('.dataviews-view-grid__card')[1]).toBe(moved) +}) + +/** Drops a focus the list holds each time the library is read, as a browser drops it from the inert list. */ +function dropFocusWhileReading() { + server.use( + http.get('/api/media', () => { + const focused = document.activeElement as HTMLElement + if (focused.closest('.dataviews-view-list, .dataviews-view-grid') !== null) { + focused.blur() + } + }), + ) +} + +test('gives the focus back to the phone row Edit opened from, where it moved, once Save stored the file', async () => { + setViewport({ width: 639 }) + listByTitle() + dropFocusWhileReading() + renderAt('/media') + await userEvent.click(await screen.findByRole('button', { name: /^Harbor at dawn/ })) + const dialog = await screen.findByRole('dialog', { name: 'Edit media' }) + await retype(dialog, 'Title', 'Zebra crossing') + + await save(dialog) + + await findToast('Media file updated.') + const row = await screen.findByRole('button', { name: /^Zebra crossing/ }) + await waitFor(() => expect(row).toHaveFocus()) + expect(document.querySelectorAll('.dataviews-view-list__item')[1]).toBe(row) +}) + +test('gives the focus back to the phone row Edit opened from once the save failed, the list read again', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + setViewport({ width: 639 }) + dropFocusWhileReading() + server.use(http.patch('/api/media/:id', () => HttpResponse.error())) + renderAt('/media') + const row = await screen.findByRole('button', { name: /^Harbor at dawn/ }) + await userEvent.click(row) + + await save(await screen.findByRole('dialog', { name: 'Edit media' })) + + await findToast('Could not save image. The server could not be reached, so nothing was saved.') + await waitFor(() => expect(row).toHaveFocus()) + expect(listings).toBeGreaterThan(1) +}) + +test('falls back to the actions menu once the door Edit opened from is gone, as a phone row in a grid', async () => { + setViewport({ width: 639 }) + renderAt('/media') + await userEvent.click(await screen.findByRole('button', { name: /^Harbor at dawn/ })) + const dialog = await screen.findByRole('dialog', { name: 'Edit media' }) + act(() => setViewport({ width: 1280 })) + const card = await cardOf('Harbor at dawn') + + await save(dialog) + + await findToast('Media file updated.') + await waitFor(() => expect(card.getByRole('button', { name: 'Actions' })).toHaveFocus()) +}) + +test('lands the focus on the list once the file a save went to left it, as a delete does', async () => { + server.use( + http.get('/api/media', ({ request }) => { + const search = new URL(request.url).searchParams.get('search') ?? '' + const items = [...stored.values()].filter((file) => String(file.title).toLowerCase().includes(search)) + return HttpResponse.json({ items, total: items.length, per_page: siteSettings.list_page_size }) + }), + ) + renderAt('/media?search=harbor') + const dialog = await openCardDialog('Harbor at dawn', 'Rename…') + await retype(dialog, 'Name', 'Zebra crossing') + + await save(dialog) + + await findToast('Name updated.') + await waitFor(() => expect(screen.getByRole('region', { name: 'Media' })).toHaveFocus()) +}) + +test('leaves the focus in a dialog opened while a save of another file was still in flight', async () => { + stubLayout() + const answer = gate() + server.use( + http.patch('/api/media/:id', async ({ params, request }) => { + const body = await request.json() + await answer.held + return kept(Number(params.id), body) + }), + ) + renderAt('/media') + await save(await openCardDialog('Harbor at dawn', 'Edit…')) + const rename = await openCardDialog('Manual', 'Rename…') + const name = within(rename).getByRole('textbox', { name: 'Name' }) + await waitFor(() => expect(name).toHaveFocus()) + + answer.release() + await findToast('Media file updated.') + await aMoment() + + expect(name).toHaveFocus() +}) + +test('sends a delete of a file only once an edit of it still in flight has answered', async () => { + const edit = gate() + const heard: string[] = [] + server.use( + http.patch('/api/media/:id', async ({ params, request }) => { + heard.push('edit sent') + const body = await request.json() + await edit.held + heard.push('edit answered') + return kept(Number(params.id), body) + }), + http.delete('/api/media/:id', () => { + heard.push('delete sent') + return new HttpResponse(null, { status: 204 }) + }), + ) + renderAt('/media') + await save(await openCardDialog('Harbor at dawn', 'Edit…')) + await waitFor(() => expect(heard).toEqual(['edit sent'])) + + const confirm = await openCardDialog('Harbor at dawn', 'Permanently delete…') + await userEvent.click(within(confirm).getByRole('button', { name: 'Delete permanently' })) + await aMoment() + edit.release() + + await waitFor(() => expect(heard).toEqual(['edit sent', 'edit answered', 'delete sent'])) +}) + +test('sends an edit of one file while an edit of another file is still in flight', async () => { + const first = gate() + const sent: number[] = [] + server.use( + http.patch('/api/media/:id', async ({ params, request }) => { + sent.push(Number(params.id)) + const body = await request.json() + await (Number(params.id) === HARBOR.id ? first.held : Promise.resolve()) + return kept(Number(params.id), body) + }), + ) + renderAt('/media') + await save(await openCardDialog('Harbor at dawn', 'Edit…')) + await waitFor(() => expect(sent).toEqual([HARBOR.id])) + + await save(await openCardDialog('Manual', 'Edit…')) + + await waitFor(() => expect(sent).toEqual([HARBOR.id, MANUAL.id])) + first.release() +}) + +test('reads the version for a rename only once an edit of the file still in flight has answered', async () => { + const edit = gate() + const heard: string[] = [] + server.use( + http.get('/api/media/:id', ({ params }) => { + heard.push('version read') + return HttpResponse.json({ ...stored.get(Number(params.id)), updated_at: CURRENT_VERSION }) + }), + http.patch('/api/media/:id', async ({ params, request }) => { + heard.push('write sent') + const body = await request.json() + await (heard.length === 2 ? edit.held : Promise.resolve()) + return kept(Number(params.id), body) + }), + ) + renderAt('/media') + await save(await openCardDialog('Harbor at dawn', 'Edit…')) + await waitFor(() => expect(heard).toEqual(['version read', 'write sent'])) + + await save(await openCardDialog('Harbor at dawn', 'Rename…')) + await aMoment() + expect(heard).toEqual(['version read', 'write sent']) + edit.release() + + await waitFor(() => expect(heard).toEqual(['version read', 'write sent', 'version read', 'write sent'])) +}) diff --git a/frontend/src/test/media-api.test.ts b/frontend/src/test/media-api.test.ts index 682afb44..5d1fd168 100644 --- a/frontend/src/test/media-api.test.ts +++ b/frontend/src/test/media-api.test.ts @@ -5,7 +5,7 @@ import { expect, test } from 'vitest' import { deleteMedia, - describeMedia, + editMedia, listMedia, listMediaByIDs, mediaSrc, @@ -45,22 +45,76 @@ const HARBOR = { updated_at: '2026-08-10T10:00:00Z', } -test('lists media with the query it was given', async () => { - let asked = '' +const OWN_AUTHOR = '019fb000-0000-7000-8000-0000000000ff' + +const FOREIGN_AUTHOR = '019fb000-0000-7000-8000-0000000000ee' + +/** + * Serves one listing page and records the parameters the request carried. + * @param rows - The rows the page holds. + * @returns The parameters of the last request, filled once it arrives. + */ +function recordedListing(rows: unknown[] = [HARBOR]): { params: URLSearchParams } { + const seen = { params: new URLSearchParams() } server.use( http.get('/api/media', ({ request }) => { - asked = new URL(request.url).search - return HttpResponse.json({ items: [HARBOR], total: 1, per_page: 10 }) + seen.params = new URL(request.url).searchParams + return HttpResponse.json({ items: rows, total: rows.length, per_page: 10 }) }), ) + return seen +} - const page = await listMedia({ type: 'image', search: 'harbor', page: 2 }) +test('lists media with the query it was given', async () => { + const seen = recordedListing() + + const page = await listMedia({ mimes: ['image/'], search: 'harbor', page: 2 }) expect(page.total).toBe(1) expect(page.items[0].title).toBe('Harbor at dawn') - expect(asked).toContain('type=image') - expect(asked).toContain('search=harbor') - expect(asked).toContain('page=2') + expect(Object.fromEntries(seen.params)).toEqual({ mime: 'image/', search: 'harbor', page: '2' }) +}) + +test('asks for the sort, the authors and both dates a list narrows by, under the names the server reads', async () => { + const seen = recordedListing() + + await listMedia({ + orderBy: 'title', + order: 'asc', + author: [OWN_AUTHOR, FOREIGN_AUTHOR], + authorExclude: [FOREIGN_AUTHOR], + before: '2026-08-16T00:00:00.000Z', + after: '2026-08-01T00:00:00.000Z', + modifiedBefore: '2026-09-16T00:00:00.000Z', + modifiedAfter: '2026-09-01T00:00:00.000Z', + }) + + expect(Object.fromEntries(seen.params)).toEqual({ + orderby: 'title', + order: 'asc', + author: `${OWN_AUTHOR},${FOREIGN_AUTHOR}`, + author_exclude: FOREIGN_AUTHOR, + before: '2026-08-16T00:00:00.000Z', + after: '2026-08-01T00:00:00.000Z', + modified_before: '2026-09-16T00:00:00.000Z', + modified_after: '2026-09-01T00:00:00.000Z', + }) +}) + +test('asks for no author and no kind when a list keeps or leaves out none', async () => { + const seen = recordedListing() + + await listMedia({ author: [], authorExclude: [], mimes: [] }) + + expect([...seen.params.keys()]).toEqual([]) +}) + +test('reads the name and the avatar of the account that uploaded each listed file', async () => { + recordedListing([{ ...HARBOR, author_name: 'Maria Perez', author_avatar: 'https://example.com/maria.png' }]) + + const page = await listMedia({}) + + expect(page.items[0]).toMatchObject({ authorName: 'Maria Perez', authorAvatar: 'https://example.com/maria.png' }) }) test('asks for the first page without naming it', async () => { @@ -197,6 +251,8 @@ test('fills in what a sparse media row leaves out', async () => { filesize: 0, sizes: {}, authorId: '', + authorName: '', + authorAvatar: '', createdAt: '', updatedAt: '', }) @@ -271,39 +327,102 @@ test('throws a stand in, never the words of a schema, when a stored upload answe ) }) -test('saves descriptions against the version it read', async () => { - let sent: Record = {} +/** The version of the harbor picture the server holds once another write landed after the list was read. */ +const CURRENT_VERSION = '2026-08-12T09:00:00Z' + +/** The reason the server gives for a file the library no longer holds, in the reader's words. */ +const NOT_FOUND = [ + 'This media item is not in the library, and someone may have deleted it.', + 'Reload the page to see what is there now.', +].join(' ') + +/** + * Answers a request as the server answers for a file the library no longer holds. + * @returns The refusal. + */ +function gone(): Response { + return HttpResponse.json({ error: 'gone', code: 'media_not_found' }, { status: 404 }) +} + +/** + * Serves the harbor picture at its current version and records the body of every edit sent to it. + * @returns The bodies, filled as requests arrive. + */ +function recordEdits(): Record[] { + const sent: Record[] = [] server.use( + http.get('/api/media/7', () => HttpResponse.json({ ...HARBOR, updated_at: CURRENT_VERSION })), http.patch('/api/media/7', async ({ request }) => { - sent = (await request.json()) as Record - return HttpResponse.json({ ...HARBOR, title: 'Harbor at noon' }) + const body = (await request.json()) as Record + sent.push(body) + return HttpResponse.json({ ...HARBOR, ...body }) }), ) + return sent +} - const outcome = await describeMedia(7, { title: 'Harbor at noon' }, HARBOR.updated_at) +test('edits a file over the version read just before the write, sending only the fields it was given', async () => { + const sent = recordEdits() - expect(outcome).toEqual({ kind: 'saved', item: expect.objectContaining({ title: 'Harbor at noon' }) }) - expect(sent).toEqual({ title: 'Harbor at noon', updated_at: HARBOR.updated_at }) + const saved = await editMedia(7, { title: 'Harbor at noon', caption: '' }, 'unreached') + + expect(sent).toEqual([{ updated_at: CURRENT_VERSION, title: 'Harbor at noon', caption: '' }]) + expect(saved).toMatchObject({ id: 7, title: 'Harbor at noon' }) }) -test('reports an edit the server found stale', async () => { - server.use(http.patch('/api/media/7', () => HttpResponse.json({}, { status: 409 }))) +test('sends the alt text and the description under the names the server reads', async () => { + const sent = recordEdits() - const outcome = await describeMedia(7, { title: 'Harbor at noon' }, HARBOR.updated_at) + await editMedia(7, { altText: 'Boats at noon', description: 'From the western pier' }, 'unreached') - expect(outcome.kind).toBe('stale') + expect(sent).toEqual([ + { updated_at: CURRENT_VERSION, alt_text: 'Boats at noon', description: 'From the western pier' }, + ]) }) -test('carries the reason an edit was refused', async () => { +test('sends an edit as JSON', async () => { + let sentAs: string | null = null server.use( - http.patch('/api/media/7', () => - HttpResponse.json({ error: 'that is not a media item' }, { status: 404 }), - ), + http.get('/api/media/7', () => HttpResponse.json({ ...HARBOR, updated_at: CURRENT_VERSION })), + http.patch('/api/media/7', ({ request }) => { + sentAs = request.headers.get('content-type') + return HttpResponse.json(HARBOR) + }), ) - const outcome = await describeMedia(7, { title: 'x' }, HARBOR.updated_at) + await editMedia(7, { title: 'Harbor at noon' }, 'unreached') - expect(outcome).toEqual({ kind: 'rejected', message: 'that is not a media item' }) + expect(sentAs).toBe('application/json') +}) + +test.each([ + { step: 'the read of the version', method: http.get }, + { step: 'the write', method: http.patch }, +])('throws in the reader words the reason $step was refused with', async ({ method }) => { + recordEdits() + server.use(method('/api/media/7', gone)) + + await expect(editMedia(7, { title: 'Harbor at noon' }, 'unreached')).rejects.toThrow(NOT_FOUND) +}) + +test.each([ + { step: 'the read of the version', method: http.get }, + { step: 'the write', method: http.patch }, +])('throws the words it was given when $step never reached the server', async ({ method }) => { + recordEdits() + server.use(method('/api/media/7', () => HttpResponse.error())) + + await expect(editMedia(7, { title: 'Harbor at noon' }, 'The name could not be updated.')).rejects.toThrow( + /^The name could not be updated\.$/, + ) +}) + +test('throws a stand in, never the words of a schema, when the version read answers a misshapen item', async () => { + server.use(http.get('/api/media/7', () => HttpResponse.json({ id: 'seven' }))) + + await expect(editMedia(7, { title: 'Harbor at noon' }, 'unreached')).rejects.toThrow( + /^Something went wrong\. Try again\.$/, + ) }) test('deletes a media item', async () => { @@ -320,10 +439,16 @@ test('deletes a media item', async () => { expect(asked).toBe('/api/media/7') }) -test('reports a delete that failed', async () => { - server.use(http.delete('/api/media/7', () => HttpResponse.json({}, { status: 500 }))) +test('throws in the reader words the reason a delete was refused with', async () => { + server.use(http.delete('/api/media/7', gone)) + + await expect(deleteMedia(7)).rejects.toThrow(NOT_FOUND) +}) + +test('throws the words of a permanent delete in the posts list when a delete never reached the server', async () => { + server.use(http.delete('/api/media/7', () => HttpResponse.error())) - await expect(deleteMedia(7)).rejects.toThrow(/500/) + await expect(deleteMedia(7)).rejects.toThrow(/^The item could not be permanently deleted\.$/) }) test('addresses a stored file under the public media prefix', () => { diff --git a/frontend/src/test/media-fields.test.ts b/frontend/src/test/media-fields.test.ts deleted file mode 100644 index d21bda30..00000000 --- a/frontend/src/test/media-fields.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import { expect, test } from 'vitest' - -import { bestSubSizeFile, fileSize, imageDimensions, mediaName } from '../media/format' -import type { MediaItem } from '../media/api' - -/** - * Returns a media item carrying the given overrides. - * @param overrides - The fields to set on the item. - * @returns The item to describe. - */ -function item(overrides: Partial = {}): MediaItem { - return { - id: 1, - type: 'image', - file: '2026/08/harbor.jpg', - title: 'Harbor at dawn', - altText: '', - caption: '', - description: '', - mimeType: 'image/jpeg', - width: 1600, - height: 1000, - filesize: 254000, - sizes: {}, - authorId: '', - createdAt: '', - updatedAt: '', - ...overrides, - } -} - -test('humanizes a file size in binary steps', () => { - expect(fileSize(512)).toBe('512 B') - expect(fileSize(51200)).toBe('50 KB') - expect(fileSize(5 * 1024 * 1024)).toBe('5 MB') - expect(fileSize(2.5 * 1024 * 1024 * 1024)).toBe('2,5 GB') - expect(fileSize(1024)).toBe('1 KB') -}) - -test('rounds a file size to two decimals', () => { - expect(fileSize(3.14159 * 1024 * 1024)).toBe('3,14 MB') -}) - -test('says nothing about a size it was never told', () => { - expect(fileSize(0)).toBe('') - expect(fileSize(0.5)).toBe('') -}) - -test('formats the dimensions of a picture', () => { - expect(imageDimensions(item())).toBe('1600 × 1000') -}) - -test('says nothing about the dimensions of a file that has none', () => { - expect(imageDimensions(item({ width: 0, height: 0 }))).toBe('') - expect(imageDimensions(item({ height: 0 }))).toBe('') -}) - -test('names a media item by its title', () => { - expect(mediaName(item())).toBe('Harbor at dawn') -}) - -test('names a media item that has no title by its file', () => { - expect(mediaName(item({ title: '' }))).toBe('harbor.jpg') -}) - -test('picks the sub-size closest above the width a grid asks for', () => { - const sized = item({ - sizes: { - thumbnail: { file: 'a-150.jpg', width: 150, height: 150, mimeType: 'image/jpeg', filesize: 1 }, - medium: { file: 'a-300.jpg', width: 300, height: 200, mimeType: 'image/jpeg', filesize: 2 }, - large: { file: 'a-1024.jpg', width: 1024, height: 640, mimeType: 'image/jpeg', filesize: 3 }, - }, - }) - - expect(bestSubSizeFile(sized, 200)).toBe('a-300.jpg') - expect(bestSubSizeFile(sized, 150)).toBe('a-150.jpg') -}) - -test('falls back to the largest sub-size when every one is too small', () => { - const sized = item({ - sizes: { - thumbnail: { file: 'a-150.jpg', width: 150, height: 150, mimeType: 'image/jpeg', filesize: 1 }, - medium: { file: 'a-300.jpg', width: 300, height: 200, mimeType: 'image/jpeg', filesize: 2 }, - }, - }) - - expect(bestSubSizeFile(sized, 4000)).toBe('a-300.jpg') -}) - -test('falls back to the stored file when there are no sub-sizes', () => { - expect(bestSubSizeFile(item(), 200)).toBe('2026/08/harbor.jpg') -}) diff --git a/frontend/src/test/media-fields.test.tsx b/frontend/src/test/media-fields.test.tsx new file mode 100644 index 00000000..ac5ad6ca --- /dev/null +++ b/frontend/src/test/media-fields.test.tsx @@ -0,0 +1,470 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { audioIcon, fileIcon, Icon, imageIcon, videoIcon } from '@gophenberg/frontend-sdk' +import type { Field } from '@gophenberg/frontend-sdk/dataviews' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { resetLocaleData, setLocaleData } from '@wordpress/i18n' +import type { ComponentType, ReactNode } from 'react' +import { expect, onTestFinished, test, vi } from 'vitest' + +import '../media/media.css' +import type { MediaItem } from '../media/api' +import { libraryFields, mediaFields } from '../media/fields' +import { bestSubSizeFile, fileSize, imageDimensions } from '../media/format' + +/** + * Returns a media item carrying the given overrides. + * @param overrides - The fields to set on the item. + * @returns The item to describe. + */ +function item(overrides: Partial = {}): MediaItem { + return { + id: 1, + type: 'image', + file: '2026/08/harbor.jpg', + title: 'Harbor at dawn', + altText: '', + caption: '', + description: '', + mimeType: 'image/jpeg', + width: 1600, + height: 1000, + filesize: 254000, + sizes: {}, + authorId: '', + authorName: '', + authorAvatar: '', + createdAt: '', + updatedAt: '', + ...overrides, + } +} + +/** A picture stored at three widths besides its own. */ +const SIZED = item({ + sizes: { + thumbnail: { file: 'a-150.jpg', width: 150, height: 150, mimeType: 'image/jpeg', filesize: 1 }, + medium: { file: 'a-300.jpg', width: 300, height: 200, mimeType: 'image/jpeg', filesize: 2 }, + large: { file: 'a-1024.jpg', width: 1024, height: 640, mimeType: 'image/jpeg', filesize: 3 }, + }, +}) + +/** + * Returns the field of the shared list with the given id. + * @param id - The id of the field. + * @returns The field. + */ +function fieldOf(id: string): Field { + return mediaFields().find((field) => field.id === id) as Field +} + +/** + * Renders the cell a field draws for an item, as a DataViews layout hands it the size it draws pictures at. + * @param id - The id of the field. + * @param held - The item the cell draws. + * @param sizes - The width the layout draws pictures at, none outside a layout. + * @returns The element the cell was drawn into. + */ +function renderCell(id: string, held: MediaItem, sizes?: string): HTMLElement { + const Cell = fieldOf(id).render as ComponentType<{ item: MediaItem, config?: { sizes: string } }> + return render().container +} + +/** + * Returns the shapes an icon draws. + * @param icon - The icon. + * @returns The markup inside its svg. + */ +function drawnIcon(icon: typeof fileIcon): string { + const { container, unmount } = render() + const markup = container.querySelector('svg')?.innerHTML + unmount() + return markup ?? '' +} + +/** + * Asserts that a cell draws the kind icon at the 24px WordPress draws it. + * @param cell - The cell. + * @param icon - The icon it should draw. + */ +function expectKindIcon(cell: HTMLElement, icon: typeof fileIcon) { + const drawn = cell.querySelector('svg.gophenberg-media__icon') + + expect(drawn?.innerHTML).toBe(drawnIcon(icon)) + expect(drawn).toHaveAttribute('width', '24') +} + +/** + * Loads one catalogue for the admin's own words until the test ends. + * @param messages - The translations, keyed by context and message. + */ +function speaking(messages: Record) { + setLocaleData({ '': { 'plural-forms': 'nplurals=2; plural=(n != 1);' }, ...messages }, 'gophenberg') + onTestFinished(() => resetLocaleData({}, 'gophenberg')) +} + +/** The id and label of every WordPress attachment field, in the order its media picker lists them. */ +const SHARED = [ + ['thumbnail', 'Thumbnail'], + ['title', 'Title'], + ['altText', 'Alt text'], + ['caption', 'Caption'], + ['description', 'Description'], + ['date', 'Date added'], + ['modified', 'Date modified'], + ['author', 'Author'], + ['filename', 'File name'], + ['filesize', 'File size'], + ['dimensions', 'Dimensions'], + ['mimeType', 'File type'], +] + +test('holds the WordPress attachment fields in the order its media picker lists them', () => { + expect(mediaFields().map((field) => [field.id, field.label])).toEqual(SHARED) +}) + +test('adds the Type filter to the library fields alone, under the id WordPress filters media by', () => { + expect(libraryFields().map((field) => [field.id, field.label])).toEqual([...SHARED, ['media_type', 'Type']]) + expect(mediaFields().some((field) => field.id === 'media_type')).toBe(false) +}) + +test('lets a reader change only the title, the alt text, the caption and the description', () => { + const editable = libraryFields().filter((field) => field.readOnly !== true && field.type !== 'media') + + expect(editable.map((field) => field.id)).toEqual(['title', 'altText', 'caption', 'description']) +}) + +test('declares every field a reader writes in or reads about the stored file as text, the title alone searched', () => { + const texts = ['title', 'altText', 'caption', 'description', 'filename', 'filesize', 'dimensions', 'mimeType'] + + expect(texts.map((id) => fieldOf(id).type)).toEqual(texts.map(() => 'text')) + expect(mediaFields().filter((field) => field.enableGlobalSearch === true).map((field) => field.id)).toEqual(['title']) +}) + +test('filters by the dates before or after a moment, by the author and by the kind alone', () => { + const filtered = libraryFields().filter((field) => field.filterBy !== false) + + expect(filtered.map((field) => [field.id, field.filterBy])).toEqual([ + ['date', { operators: ['before', 'after'] }], + ['modified', { operators: ['before', 'after'] }], + ['author', { operators: ['isAny', 'isNone'] }], + ['media_type', { operators: ['is'] }], + ]) +}) + +test('reads an untitled item as an empty title under the No title placeholder, so the Edit box starts empty', () => { + const title = fieldOf('title') + + expect(title.getValue?.({ item: item({ title: '' }) })).toBe('') + expect(title.placeholder).toBe('No title') +}) + +test('draws a title as text marked with the file, (no title) when untitled, so the bold title rule applies', () => { + expect(renderCell('title', item()).innerHTML).toBe('Harbor at dawn') + expect(renderCell('title', item({ title: '' })).innerHTML).toBe('(no title)') +}) + +test.each([ + { shape: 'a picture', held: item({ id: 7 }) }, + { shape: 'a file that is no picture', held: item({ id: 7, type: 'file', mimeType: 'application/pdf' }) }, +])('marks the thumbnail of $shape with the file, so the focus finds its card again', ({ held }) => { + const cell = renderCell('thumbnail', held, '900px') + + expect(cell.querySelectorAll('[data-media-item="7"]')).toHaveLength(1) +}) + +test('offers alt text on a picture alone', () => { + const alt = fieldOf('altText') + + expect(alt.isVisible?.(item({ mimeType: 'image/png' }))).toBe(true) + expect(alt.isVisible?.(item({ type: 'file', mimeType: 'application/pdf' }))).toBe(false) + expect(alt.isVisible?.(item({ type: 'file', mimeType: 'video/mp4' }))).toBe(false) +}) + +test('helps with alt text in WordPress words, the WAI decision tree opening in a new tab', () => { + const { container } = render(<>{fieldOf('altText').description as ReactNode}) + + const link = screen.getByRole('link', { name: /Describe the purpose of the image\./ }) + expect(link).toHaveAttribute('href', 'https://www.w3.org/WAI/tutorials/images/decision-tree/') + expect(link).toHaveAttribute('target', '_blank') + expect(container).toHaveTextContent(/Leave empty if decorative\.$/) +}) + +test.each(['altText', 'caption', 'description', 'mimeType'])('draws a dash for an empty %s', (id) => { + const empty = item({ altText: '', caption: '', description: '', mimeType: '' }) + + expect(renderCell(id, empty)).toHaveTextContent(/^-$/) +}) + +test.each([ + ['altText', 'Boats at sunrise'], + ['caption', 'Before the market opens'], + ['description', 'From the eastern pier'], + ['mimeType', 'image/jpeg'], +])('draws the %s a file holds', (id, held) => { + const filled = item({ + altText: 'Boats at sunrise', + caption: 'Before the market opens', + description: 'From the eastern pier', + }) + + expect(renderCell(id, filled)).toHaveTextContent(held) +}) + +test('draws the description as bare text, as the caption, so a card cuts it with the ellipsis of every value', () => { + const pier = 'From the eastern pier' + expect(renderCell('description', item({ description: pier })).innerHTML).toBe(pier) + expect(renderCell('description', item()).innerHTML).toBe('-') + expect(renderCell('caption', item({ caption: 'Before the market opens' })).innerHTML).toBe('Before the market opens') +}) + +test('writes a file size in the WordPress words of a size and its unit', () => { + speaking({ 'file size\u0004%1$s %2$s': ['%2$s: %1$s'], 'unit symbol\u0004KB': ['kio'] }) + + expect(fileSize(1536)).toBe('kio: 1,5') +}) + +test('writes every other binary step in the words of its unit symbol', () => { + speaking({ + 'unit symbol\u0004B': ['byte'], + 'unit symbol\u0004MB': ['MiB'], + 'unit symbol\u0004GB': ['GiB'], + 'unit symbol\u0004TB': ['TiB'], + 'unit symbol\u0004PB': ['PiB'], + 'unit symbol\u0004EB': ['EiB'], + 'unit symbol\u0004ZB': ['ZiB'], + 'unit symbol\u0004YB': ['YiB'], + }) + + expect([0, 2, 3, 4, 5, 6, 7, 8].map((step) => fileSize(1024 ** step))).toEqual([ + '1 byte', + '1 MiB', + '1 GiB', + '1 TiB', + '1 PiB', + '1 EiB', + '1 ZiB', + '1 YiB', + ]) +}) + +test.each([ + { bytes: 512, written: '512 B' }, + { bytes: 51200, written: '50 KB' }, + { bytes: 5 * 1024 ** 2, written: '5 MB' }, + { bytes: 2.5 * 1024 ** 3, written: '2,5 GB' }, + { bytes: 1024 ** 4, written: '1 TB' }, + { bytes: 3 * 1024 ** 5, written: '3 PB' }, + { bytes: 1024 ** 6, written: '1 EB' }, + { bytes: 1024 ** 7, written: '1 ZB' }, + { bytes: 2 * 1024 ** 8, written: '2 YB' }, + { bytes: 3.14159 * 1024 ** 2, written: '3,14 MB' }, + { bytes: 1000, written: '1.000 B' }, +])('writes $written in the binary steps WordPress reports, through the site number format', ({ bytes, written }) => { + expect(fileSize(bytes)).toBe(written) +}) + +test('says nothing about a size it was never told', () => { + expect(fileSize(0)).toBe('') + expect(fileSize(0.5)).toBe('') +}) + +test('shows the size of a file only once it is known', () => { + const size = fieldOf('filesize') + + expect(size.getValue?.({ item: item() })).toBe('248,05 KB') + expect(size.isVisible?.(item())).toBe(true) + expect(size.getValue?.({ item: item({ filesize: 0 }) })).toBe('') + expect(size.isVisible?.(item({ filesize: 0 }))).toBe(false) +}) + +test('writes the dimensions of a picture in the WordPress words of image dimensions', () => { + speaking({ 'image dimensions\u0004%1$s × %2$s': ['%1$s by %2$s'] }) + + expect(imageDimensions(item())).toBe('1600 by 1000') +}) + +test('shows the dimensions of a file only when it carries both sides', () => { + const dimensions = fieldOf('dimensions') + + expect(dimensions.getValue?.({ item: item() })).toBe('1600 × 1000') + expect(dimensions.isVisible?.(item())).toBe(true) + expect(dimensions.getValue?.({ item: item({ height: 0 }) })).toBe('') + expect(dimensions.isVisible?.(item({ height: 0 }))).toBe(false) + expect(dimensions.isVisible?.(item({ width: 0 }))).toBe(false) +}) + +test('reads the file name of a stored file without its folders', () => { + expect(fieldOf('filename').getValue?.({ item: item() })).toBe('harbor.jpg') +}) + +test('writes a file name of 15 characters whole, with no tooltip to hover', () => { + const cell = renderCell('filename', item({ file: '2026/08/harbor-dawn.jpg' })) + + expect(cell.innerHTML).toBe('harbor-dawn.jpg') +}) + +test('keeps a longer file name whole in the page and shows it in a tooltip on hover', async () => { + renderCell('filename', item({ file: '2026/08/harbor-at-dawn.jpg' })) + const name = screen.getByText('harbor-at-dawn.jpg') + + await userEvent.hover(name) + + expect(name).toHaveClass('gophenberg-media__filename') + await waitFor(() => expect(name).toHaveAttribute('data-popup-open')) + const popup = document.querySelector('[data-base-ui-portal]') as HTMLElement + expect(within(popup).getByText('harbor-at-dawn.jpg')).toBeInTheDocument() +}) + +/** + * Returns the width the browser reads 15 characters of the page font as. + * @returns The computed width. + */ +function fifteenCharacters(): string { + const measure = document.createElement('span') + measure.style.maxInlineSize = '15ch' + document.body.append(measure) + const width = getComputedStyle(measure).maxInlineSize + measure.remove() + return width +} + +test('cuts a file name at 15 characters on one line inside a DataViews table, grid or list alone', () => { + for (const layout of ['dataviews-view-table', 'dataviews-view-grid', 'dataviews-view-list']) { + const { container } = render( +
+ harbor-at-dawn.jpg +
, + ) + const cut = getComputedStyle(container.querySelector('span') as HTMLElement) + + expect([cut.maxInlineSize, cut.overflow, cut.textOverflow, cut.whiteSpace]).toEqual([ + fifteenCharacters(), + 'hidden', + 'ellipsis', + 'nowrap', + ]) + } + const { container } = render(harbor-at-dawn.jpg) + expect(getComputedStyle(container.querySelector('span') as HTMLElement).whiteSpace).not.toBe('nowrap') +}) + +test('draws a picture from the smallest stored size at least as wide as the layout draws it', () => { + expect(renderCell('thumbnail', SIZED, '900px').querySelector('img')).toHaveAttribute('src', '/media/a-1024.jpg') + expect(renderCell('thumbnail', SIZED, '52px').querySelector('img')).toHaveAttribute('src', '/media/a-150.jpg') + expect(renderCell('thumbnail', SIZED, '4000px').querySelector('img')).toHaveAttribute('src', '/media/a-1024.jpg') +}) + +test('draws the stored file itself when no layout names a size', () => { + expect(renderCell('thumbnail', SIZED).querySelector('img')).toHaveAttribute('src', '/media/2026/08/harbor.jpg') +}) + +test('loads a picture lazily, covering its box', () => { + const picture = renderCell('thumbnail', SIZED, '900px').querySelector('img') as HTMLImageElement + + expect(picture).toHaveAttribute('loading', 'lazy') + expect(getComputedStyle(picture).objectFit).toBe('cover') +}) + +test('fades a picture in only while it loads', () => { + const box = renderCell('thumbnail', SIZED, '900px').firstElementChild as HTMLElement + + expect(box).toHaveClass('is-loading') + fireEvent.load(box.querySelector('img') as HTMLImageElement) + + expect(box).toHaveClass('is-loaded') + expect(box).not.toHaveClass('is-loading') +}) + +test('shows a picture the browser already holds at once, with no fade', () => { + vi.spyOn(HTMLImageElement.prototype, 'complete', 'get').mockReturnValue(true) + + const box = renderCell('thumbnail', SIZED, '900px').firstElementChild as HTMLElement + fireEvent.load(box.querySelector('img') as HTMLImageElement) + + expect([...box.classList]).toEqual(['gophenberg-media__thumbnail']) +}) + +test('names a picture by its alt text, else by its title', () => { + expect(renderCell('thumbnail', item({ altText: 'Boats at sunrise' }), '900px').querySelector('img')).toHaveAttribute( + 'alt', + 'Boats at sunrise', + ) + expect(renderCell('thumbnail', item(), '900px').querySelector('img')).toHaveAttribute('alt', 'Harbor at dawn') +}) + +test.each([ + ['application/pdf', 'manual.pdf', fileIcon], + ['video/mp4', 'harbor-tour.mp4', videoIcon], + ['audio/mpeg', 'interview.mp3', audioIcon], + ['application/octet-stream', 'notes.bin', fileIcon], +])('draws the kind icon of a %s file and its whole file name, no picture', (mimeType, name, icon) => { + const cell = renderCell('thumbnail', item({ type: 'file', mimeType, file: `2026/08/${name}` }), '900px') + + expect(cell.querySelector('img')).toBeNull() + expectKindIcon(cell, icon) + expect(cell.querySelector('.gophenberg-media__filename-text')).toHaveTextContent(name) +}) + +test('draws the picture icon and the file name for a picture whose file will not load', () => { + const cell = renderCell('thumbnail', SIZED, '900px') + + fireEvent.error(cell.querySelector('img') as HTMLImageElement) + + expect(cell.querySelector('img')).toBeNull() + expectKindIcon(cell, imageIcon) + expect(cell.querySelector('.gophenberg-media__filename-text')).toHaveTextContent('harbor.jpg') +}) + +test.each([ + ['image/png', 'image'], + ['video/webm', 'video'], + ['audio/flac', 'audio'], + ['application/pdf', 'document'], + ['application/zip', 'archive'], + ['text/plain', 'file'], +])('files a %s file under the %s kind', (mimeType, kind) => { + const field = libraryFields().find((held) => held.id === 'media_type') as Field + + expect(field.getValue?.({ item: item({ mimeType }) })).toBe(kind) +}) + +test('offers the WordPress file type groups as the kinds, Images, Audio, Video, Documents and Archives', () => { + const field = libraryFields().find((held) => held.id === 'media_type') as Field + + expect(field.elements).toEqual([ + { value: 'image', label: 'Images' }, + { value: 'audio', label: 'Audio' }, + { value: 'video', label: 'Video' }, + { value: 'document', label: 'Documents' }, + { value: 'archive', label: 'Archives' }, + ]) +}) + +test('reads Type in the words of the catalogue, and its Audio, Video and Archives groups in their contexts', () => { + speaking({ + Type: ['Family'], + 'file type group\u0004Audio': ['Sound'], + 'file type group\u0004Video': ['Film'], + 'file type group\u0004Archives': ['Bundles'], + }) + + const field = libraryFields().find((held) => held.id === 'media_type') as Field + + expect(field.label).toBe('Family') + expect(field.elements?.map((element) => element.label)).toEqual(['Images', 'Sound', 'Film', 'Documents', 'Bundles']) +}) + +test('picks the sub-size closest above the width a grid asks for', () => { + expect(bestSubSizeFile(SIZED, 200)).toBe('a-300.jpg') + expect(bestSubSizeFile(SIZED, 150)).toBe('a-150.jpg') +}) + +test('falls back to the largest sub-size when every one is too small', () => { + expect(bestSubSizeFile(SIZED, 4000)).toBe('a-1024.jpg') +}) + +test('falls back to the stored file when there are no sub-sizes', () => { + expect(bestSubSizeFile(item(), 200)).toBe('2026/08/harbor.jpg') +}) diff --git a/frontend/src/test/media-inserter.test.ts b/frontend/src/test/media-inserter.test.ts index e446d7b0..98b529e4 100644 --- a/frontend/src/test/media-inserter.test.ts +++ b/frontend/src/test/media-inserter.test.ts @@ -103,6 +103,8 @@ test('shapes a stored item the way the inserter reads it', () => { }, }, authorId: '', + authorName: '', + authorAvatar: '', createdAt: '', updatedAt: '', }) @@ -140,6 +142,8 @@ test('places the scaled display copy of an oversized picture', () => { }, }, authorId: '', + authorName: '', + authorAvatar: '', createdAt: '', updatedAt: '', }) @@ -162,6 +166,8 @@ test('previews a stored item that carries no sub-sizes from its own file', () => filesize: 900, sizes: {}, authorId: '', + authorName: '', + authorAvatar: '', createdAt: '', updatedAt: '', }) diff --git a/frontend/src/test/media-library.test.tsx b/frontend/src/test/media-library.test.tsx index 3bc3a262..74333a00 100644 --- a/frontend/src/test/media-library.test.tsx +++ b/frontend/src/test/media-library.test.tsx @@ -1,12 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 import { http, HttpResponse, server } from '@gophenberg/frontend-sdk/testing' -import { screen, waitFor, within } from '@testing-library/react' +import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { beforeEach, expect, test, vi } from 'vitest' +import { resetLocaleData, setLocaleData } from '@wordpress/i18n' +import { beforeEach, expect, onTestFinished, test, vi } from 'vitest' -import { deleteFailure, deleteQuestion } from '../media/actions' -import { filteredKind } from '../media/MediaScreen' import { renderAt } from './render' import { siteSettings } from './siteSettings' @@ -170,18 +169,6 @@ test('says under the Media title what the library holds', async () => { expect(await screen.findByText('Manage the images and files this site holds.')).toHaveClass('godmin-page__subtitle') }) -test('says no media item was found when the kind filter narrows the library to nothing', async () => { - renderAt('/media') - await screen.findByText('Harbor at dawn') - listing([]) - - await userEvent.click(screen.getByRole('button', { name: /Kind/ })) - await userEvent.click(await screen.findByRole('option', { name: 'Files' })) - - expect(await screen.findByText('No media items found.')).toBeInTheDocument() - expect(screen.queryByText('Add one with Add Media File.')).not.toBeInTheDocument() -}) - test('draws the library in the list region of the page, named Media', async () => { renderAt('/media') @@ -203,6 +190,19 @@ test('lays a drop zone reading Drop files to upload over the list region', async expect(zone).toHaveTextContent('Drop files to upload') }) +test('words the drop zone through the admin catalogue', async () => { + setLocaleData( + { '': { 'plural-forms': 'nplurals=2; plural=(n != 1);' }, 'Drop files to upload': ['Release files to upload'] }, + 'gophenberg', + ) + onTestFinished(() => resetLocaleData({}, 'gophenberg')) + renderAt('/media') + + const region = await screen.findByRole('region', { name: 'Media' }) + + expect(region.querySelector(':scope > .godmin-list-overlay')).toHaveTextContent('Release files to upload') +}) + test('ignores a drop that carries no file, while the drop after it carrying one uploads it', async () => { let uploaded = 0 server.use( @@ -229,161 +229,3 @@ test('ignores a drop that carries no file, while the drop after it carrying one expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) -test('deletes a media item for good after a confirmation', async () => { - let deleted = 0 - server.use( - http.delete('/api/media/7', () => { - deleted += 1 - return new HttpResponse(null, { status: 204 }) - }), - ) - renderAt('/media') - await screen.findByText('Harbor at dawn') - - await userEvent.click(screen.getAllByRole('button', { name: /actions/i })[0]) - await userEvent.click(await screen.findByRole('menuitem', { name: /delete permanently/i })) - await userEvent.click(await screen.findByRole('button', { name: /delete permanently/i })) - - await waitFor(() => expect(deleted).toBe(1)) -}) - -test('reports a delete that failed', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - server.use(http.delete('/api/media/7', () => HttpResponse.json({}, { status: 500 }))) - renderAt('/media') - await screen.findByText('Harbor at dawn') - - await userEvent.click(screen.getAllByRole('button', { name: /actions/i })[0]) - await userEvent.click(await screen.findByRole('menuitem', { name: /delete permanently/i })) - await userEvent.click(await screen.findByRole('button', { name: /delete permanently/i })) - - expect(await screen.findByRole('alert')).toHaveTextContent(/could not delete/i) -}) - -test('saves the descriptions of a media item', async () => { - let sent: Record = {} - server.use( - http.patch('/api/media/7', async ({ request }) => { - sent = (await request.json()) as Record - return HttpResponse.json({ ...HARBOR, title: 'Harbor at noon' }) - }), - ) - renderAt('/media') - await screen.findByText('Harbor at dawn') - - await userEvent.click(screen.getAllByRole('button', { name: /actions/i })[0]) - await userEvent.click(await screen.findByRole('menuitem', { name: /describe/i })) - const title = await screen.findByLabelText('Title') - await userEvent.clear(title) - await userEvent.type(title, 'Harbor at noon') - await userEvent.click(screen.getByRole('button', { name: /save/i })) - - await waitFor(() => expect(sent.title).toBe('Harbor at noon')) -}) - -test('reports descriptions the server found stale', async () => { - server.use(http.patch('/api/media/7', () => HttpResponse.json({}, { status: 409 }))) - renderAt('/media') - await screen.findByText('Harbor at dawn') - - await userEvent.click(screen.getAllByRole('button', { name: /actions/i })[0]) - await userEvent.click(await screen.findByRole('menuitem', { name: /describe/i })) - await userEvent.click(await screen.findByRole('button', { name: /save/i })) - - expect(await screen.findByRole('alert')).toHaveTextContent(/changed while/i) -}) - -test('reports descriptions that never reached the server', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - server.use(http.patch('/api/media/7', () => HttpResponse.error())) - renderAt('/media') - await screen.findByText('Harbor at dawn') - - await userEvent.click(screen.getAllByRole('button', { name: /actions/i })[0]) - await userEvent.click(await screen.findByRole('menuitem', { name: /describe/i })) - await userEvent.click(await screen.findByRole('button', { name: /save/i })) - - expect(await screen.findByRole('alert')).toHaveTextContent(/could not be reached/i) -}) - -test('reports descriptions the server refused', async () => { - server.use( - http.patch('/api/media/7', () => - HttpResponse.json({ error: 'that is not a media item' }, { status: 404 }), - ), - ) - renderAt('/media') - await screen.findByText('Harbor at dawn') - - await userEvent.click(screen.getAllByRole('button', { name: /actions/i })[0]) - await userEvent.click(await screen.findByRole('menuitem', { name: /describe/i })) - await userEvent.click(await screen.findByRole('button', { name: /save/i })) - - expect(await screen.findByRole('alert')).toHaveTextContent('that is not a media item') -}) - -test('shows the size and shape of a picture', async () => { - renderAt('/media') - - expect(await screen.findByText('1600 × 1000')).toBeInTheDocument() - expect(screen.getByText('248,05 KB')).toBeInTheDocument() -}) - -test('says nothing about the shape of a file that has none', async () => { - listing([MANUAL]) - renderAt('/media') - - expect(await screen.findByText('11,72 KB')).toBeInTheDocument() - expect(screen.queryByText(/×/)).not.toBeInTheDocument() -}) - -test('describes every stored file when the library is laid out as a table', async () => { - renderAt('/media') - await screen.findByText('Harbor at dawn') - - await userEvent.click(screen.getByRole('button', { name: /layout/i })) - await userEvent.click(await screen.findByRole('menuitemradio', { name: /table/i })) - - expect(await screen.findByText('harbor.jpg')).toBeInTheDocument() - expect(screen.getByText('image/jpeg')).toBeInTheDocument() - expect(screen.getByText('10/08/2026')).toBeInTheDocument() -}) - -test('dates a media item that carries no timestamp', async () => { - listing([{ ...MANUAL, created_at: undefined }]) - renderAt('/media') - await screen.findByText('Manual') - - await userEvent.click(screen.getByRole('button', { name: /layout/i })) - await userEvent.click(await screen.findByRole('menuitemradio', { name: /table/i })) - - expect(await screen.findAllByText('manual.pdf')).not.toHaveLength(0) -}) - -test('asks for every kind until a kind is filtered for', () => { - const view = { type: 'grid', fields: [] } as unknown as Parameters[0] - - expect(filteredKind(view)).toBe('') - expect(filteredKind({ ...view, filters: [{ field: 'type', operator: 'is', value: 'image' }] })).toBe( - 'image', - ) - expect(filteredKind({ ...view, filters: [{ field: 'type', operator: 'is', value: 7 }] })).toBe('') -}) - -test('asks about every item a delete was asked over', () => { - const one = [{ title: 'Harbor at dawn', file: 'a.jpg' }] as Parameters[0] - const two = [...one, { title: 'Manual', file: 'b.pdf' }] as Parameters[0] - - expect(deleteQuestion(one)).toContain('Delete Harbor at dawn for good?') - expect(deleteQuestion(two)).toContain('Delete these 2 items for good?') - expect(deleteFailure(one)).toBe('Could not delete that item.') - expect(deleteFailure(two)).toBe('Could not delete every item.') -}) - -test('groups the count a delete asks about in the site format', () => { - const many = Array.from({ length: 1234 }, () => ({ title: 'Manual', file: 'b.pdf' })) as Parameters< - typeof deleteQuestion - >[0] - - expect(deleteQuestion(many)).toContain('Delete these 1.234 items for good?') -}) diff --git a/frontend/src/test/media-list-view.test.tsx b/frontend/src/test/media-list-view.test.tsx new file mode 100644 index 00000000..abd028c8 --- /dev/null +++ b/frontend/src/test/media-list-view.test.tsx @@ -0,0 +1,525 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { http, HttpResponse, server } from '@gophenberg/frontend-sdk/testing' +import { setViewport } from '@gopherium/godmin/testing' +import { act, cleanup, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { resetLocaleData, setLocaleData } from '@wordpress/i18n' +import { beforeAll, beforeEach, expect, onTestFinished, test, vi } from 'vitest' + +import { gate } from './gate' +import { adminUser, renderAt, renderRoutedAt } from './render' +import { siteSettings } from './siteSettings' + +beforeAll(async () => { + await import('../media/MediaScreen') +}) + +const OWN_AUTHOR = '019fb000-0000-7000-8000-0000000000ff' + +const FOREIGN_AUTHOR = '019fb000-0000-7000-8000-0000000000ee' + +const HARBOR = { + id: 7, + type: 'image', + file: '2026/08/harbor.jpg', + title: 'Harbor at dawn', + alt_text: 'Boats at sunrise', + mime_type: 'image/jpeg', + width: 1600, + height: 1000, + filesize: 254000, + sizes: {}, + author_id: OWN_AUTHOR, + author_name: 'Maria Perez', + author_avatar: 'https://example.com/maria.png', + created_at: '2026-08-10T10:00:00Z', + updated_at: '2026-08-12T10:00:00Z', +} + +const MANUAL = { + ...HARBOR, + id: 8, + type: 'file', + file: '2026/08/manual.pdf', + title: 'Manual', + alt_text: '', + mime_type: 'application/pdf', + width: 0, + height: 0, + author_avatar: '', +} + +/** An author, who may change only the files it uploaded, none of the ones this library holds. */ +const AUTHOR = { ...adminUser, role: 'author', capabilities: [] } + +/** The queries bound to one card of the grid. */ +type Card = ReturnType + +const asked: URLSearchParams[] = [] + +/** + * Serves the given rows as every page of the library, recording what each request asked for. + * @param items - The rows each page holds. + * @param total - The number of matches the API reports. + */ +function serveRows(items: unknown[], total = items.length) { + server.use( + http.get('/api/media', ({ request }) => { + asked.push(new URL(request.url).searchParams) + return HttpResponse.json({ items, total, per_page: siteSettings.list_page_size }) + }), + ) +} + +beforeEach(() => { + asked.length = 0 + serveRows([HARBOR, MANUAL]) + server.use( + http.get('/api/authors', () => + HttpResponse.json({ + items: [ + { id: FOREIGN_AUTHOR, name: 'Foreign Author' }, + { id: OWN_AUTHOR, name: 'Maria Perez' }, + ], + }), + ), + ) +}) + +/** + * Returns the address of the library narrowed by the given filters. + * @param filters - The filters the address holds. + * @returns The address. + */ +function narrowedAt(filters: unknown[]): string { + return `/media?filters=${encodeURIComponent(JSON.stringify(filters))}` +} + +/** + * Returns the parameters the latest request for the library carried. + * @returns The parameters. + */ +function lastAsked(): Record { + return Object.fromEntries(asked.at(-1) ?? new URLSearchParams()) +} + +/** + * Returns the card of the grid holding the given title. + * @param title - The title the card shows. + * @returns The queries bound to the card. + */ +async function cardOf(title: string) { + const shown = await screen.findByText(title, { selector: '.dataviews-title-field *, .dataviews-title-field' }) + return within(shown.closest('.dataviews-view-grid__card') as HTMLElement) +} + +test('asks for the search, sort, page and filters the address holds', async () => { + const filters = [{ field: 'media_type', operator: 'is', value: 'image' }] + renderAt(`${narrowedAt(filters)}&search=harbor&sort=title&order=asc&page=2`) + + await screen.findByText('Harbor at dawn') + + expect(Object.fromEntries(asked[0])).toEqual({ + search: 'harbor', + orderby: 'title', + order: 'asc', + page: '2', + per_page: '20', + mime: 'image/', + }) +}) + +test('keeps the sort a reader picks in the address, so the list opens on it again', async () => { + const { router } = renderRoutedAt('/media') + await screen.findByText('Harbor at dawn') + await userEvent.click(screen.getByRole('button', { name: 'View options' })) + + await userEvent.selectOptions(await screen.findByRole('combobox', { name: 'Sort by' }), 'title') + await waitFor(() => expect(router.state.location.search).toMatchObject({ sort: 'title' })) + const kept = router.state.location.searchStr + cleanup() + asked.length = 0 + renderAt(`/media${kept}`) + + await screen.findByText('Harbor at dawn') + expect(asked[0].get('orderby')).toBe('title') +}) + +test('draws a grid of cards from 640px, with no Layout control', async () => { + setViewport({ width: 640 }) + renderAt('/media') + + await screen.findByText('Harbor at dawn') + + expect(document.querySelector('.dataviews-view-grid')).not.toBeNull() + expect(screen.queryByRole('button', { name: /layout/i })).not.toBeInTheDocument() +}) + +test('draws a plain list below 640px, with no Layout control', async () => { + setViewport({ width: 639 }) + renderAt('/media') + + await screen.findByText('Harbor at dawn') + + expect(document.querySelector('.dataviews-view-list')).not.toBeNull() + expect(document.querySelector('.dataviews-view-grid')).toBeNull() + expect(screen.queryByRole('button', { name: /layout/i })).not.toBeInTheDocument() +}) + +test('shows on a card the title and the author with the avatar, and nothing else under it', async () => { + renderAt('/media') + + const card = await cardOf('Harbor at dawn') + + const author = card.getByText('Maria Perez').closest('.gophenberg-author') as HTMLElement + expect(author.querySelector('.gophenberg-author__avatar')).not.toBeNull() + expect(card.queryByText('1600 × 1000')).not.toBeInTheDocument() + expect(card.queryByText('harbor.jpg')).not.toBeInTheDocument() +}) + +test('shows the author beside the title on a phone row', async () => { + setViewport({ width: 639 }) + renderAt('/media') + + await screen.findByText('Harbor at dawn') + + const rows = within(document.querySelector('.dataviews-view-list') as HTMLElement) + expect(rows.getAllByText('Maria Perez')).toHaveLength(2) +}) + +test('sorts by Title, Date added, Date modified and Author alone', async () => { + renderAt('/media') + await screen.findByText('Harbor at dawn') + + await userEvent.click(screen.getByRole('button', { name: 'View options' })) + + const sorts = within(await screen.findByRole('combobox', { name: 'Sort by' })).getAllByRole('option') + expect(sorts.map((option) => option.textContent)).toEqual(['Title', 'Date added', 'Date modified', 'Author']) +}) + +test.each([ + { field: 'title', from: 'date' }, + { field: 'date', from: 'title' }, + { field: 'modified', from: 'title' }, + { field: 'author', from: 'title' }, +])('asks the server to sort by $field, then in the order the reader picks', async ({ field, from }) => { + renderAt(`/media?sort=${from}&order=asc`) + await screen.findByText('Harbor at dawn') + await userEvent.click(screen.getByRole('button', { name: 'View options' })) + + await userEvent.selectOptions(await screen.findByRole('combobox', { name: 'Sort by' }), field) + await waitFor(() => expect(lastAsked()).toMatchObject({ orderby: field, order: 'asc' })) + await userEvent.click(screen.getByRole('radio', { name: 'Sort descending' })) + + await waitFor(() => expect(lastAsked()).toMatchObject({ orderby: field, order: 'desc' })) + expect(asked[0].get('orderby')).toBe(from) +}) + +test('opens on Date added, newest first', async () => { + renderAt('/media') + + await screen.findByText('Harbor at dawn') + + expect(lastAsked()).toMatchObject({ orderby: 'date', order: 'desc' }) +}) + +test('offers the attachment fields WordPress shows under View options, and no Type, which only filters', async () => { + renderAt('/media') + await screen.findByText('Harbor at dawn') + + await userEvent.click(screen.getByRole('button', { name: 'View options' })) + + const properties = await screen.findByText('Properties') + const listed = [...(properties.parentElement as HTMLElement).querySelectorAll('[role="listitem"]')] + expect(listed.map((property) => property.textContent)).toEqual([ + 'Title', + 'Thumbnail', + 'Alt text', + 'Author', + 'Caption', + 'Date added', + 'Date modified', + 'Description', + 'Dimensions', + 'File name', + 'File size', + 'File type', + ]) +}) + +test('offers Author, Date added, Date modified and Type as filters, none shown before a reader adds it', async () => { + renderAt('/media') + await screen.findByText('Harbor at dawn') + + expect(screen.queryByRole('button', { name: /^Type/ })).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Add filter' })) + + const offered = await screen.findAllByRole('menuitem') + expect(offered.map((item) => item.textContent)).toEqual(['Author', 'Date added', 'Date modified', 'Type']) +}) + +test('writes the type a reader picks into the address under the id WordPress filters media by', async () => { + const { router } = renderRoutedAt('/media') + await screen.findByText('Harbor at dawn') + await userEvent.click(screen.getByRole('button', { name: 'Add filter' })) + await userEvent.click(await screen.findByRole('menuitem', { name: 'Type' })) + + await userEvent.click(await screen.findByRole('option', { name: 'Documents' })) + + await waitFor(() => + expect(router.state.location.search).toMatchObject({ + filters: [{ field: 'media_type', operator: 'is', value: 'document' }], + }), + ) + await waitFor(() => expect(lastAsked()).toMatchObject({ mime: 'application/pdf' })) +}) + +test.each([ + { value: 'image', mime: 'image/' }, + { value: 'audio', mime: 'audio/' }, + { value: 'video', mime: 'video/' }, + { value: 'document', mime: 'application/pdf' }, + { value: 'archive', mime: 'application/zip' }, +])('asks for the $value kind as the content type prefix $mime, never as a type', async ({ value, mime }) => { + renderAt(narrowedAt([{ field: 'media_type', operator: 'is', value }])) + + await screen.findByText('Harbor at dawn') + + expect(asked[0].get('mime')).toBe(mime) + expect(asked[0].has('type')).toBe(false) +}) + +test.each([ + { operator: 'isAny', key: 'author' }, + { operator: 'isNone', key: 'author_exclude' }, +])('asks for the authors of an author filter under $key', async ({ operator, key }) => { + renderAt(narrowedAt([{ field: 'author', operator, value: [OWN_AUTHOR, FOREIGN_AUTHOR] }])) + + await screen.findByText('Harbor at dawn') + + expect(asked[0].get(key)).toBe(`${OWN_AUTHOR},${FOREIGN_AUTHOR}`) +}) + +test.each([ + { field: 'date', operator: 'before', key: 'before' }, + { field: 'date', operator: 'after', key: 'after' }, + { field: 'modified', operator: 'before', key: 'modified_before' }, + { field: 'modified', operator: 'after', key: 'modified_after' }, +])('asks for a $field $operator filter under $key', async ({ field, operator, key }) => { + renderAt(narrowedAt([{ field, operator, value: '2026-08-16T12:00:00.000Z' }])) + + await screen.findByText('Harbor at dawn') + + expect(asked[0].get(key)).toBe('2026-08-16T12:00:00.000Z') +}) + +test.each([ + { shape: 'a sort the list never offers', address: '/media?sort=filesize&order=asc' }, + { + shape: 'a filter on a field the list never filters by', + address: narrowedAt([{ field: 'title', operator: 'is', value: 'Harbor' }]), + }, + { + shape: 'a kind outside the groups', + address: narrowedAt([{ field: 'media_type', operator: 'is', value: 'spreadsheet' }]), + }, + { + shape: 'a date that is no moment', + address: narrowedAt([{ field: 'modified', operator: 'after', value: 'someday' }]), + }, + { + shape: 'an author that is no account', + address: narrowedAt([{ field: 'author', operator: 'isAny', value: ['nope'] }]), + }, +])('drops $shape and opens on Date added, newest first', async ({ address }) => { + renderAt(`${address}&search=harbor`) + + await screen.findByText('Harbor at dawn') + + expect(Object.fromEntries(asked[0])).toEqual({ search: 'harbor', orderby: 'date', order: 'desc', per_page: '20' }) +}) + +test('offers as page sizes the ones the settings name', async () => { + renderAt('/media') + await screen.findByText('Harbor at dawn') + + await userEvent.click(screen.getByRole('button', { name: 'View options' })) + + const sizes = within(await screen.findByRole('radiogroup', { name: 'Items per page' })) + expect(sizes.getAllByRole('radio').map((size) => size.textContent)).toEqual(['10', '20', '50', '100']) + expect(sizes.getByRole('radio', { name: '20' })).toBeChecked() +}) + +test('leaves the page size to the server and offers no sizes when the settings could not be read', async () => { + server.use(http.get('/api/settings', () => HttpResponse.json({}, { status: 500 }))) + renderAt('/media?perPage=50') + await screen.findByText('Harbor at dawn') + + await userEvent.click(screen.getByRole('button', { name: 'View options' })) + + expect(await screen.findByRole('combobox', { name: 'Sort by' })).toBeInTheDocument() + expect(screen.queryByRole('radiogroup', { name: 'Items per page' })).not.toBeInTheDocument() + expect(asked.every((search) => !search.has('per_page'))).toBe(true) +}) + +test('asks the page size the settings open on for an address naming one they do not offer', async () => { + renderAt('/media?perPage=7') + + await screen.findByText('Harbor at dawn') + + expect(asked.map((search) => search.get('per_page'))).toEqual(['20']) +}) + +test('asks for a page size the reader picks among the offered ones', async () => { + renderAt('/media') + await screen.findByText('Harbor at dawn') + await userEvent.click(screen.getByRole('button', { name: 'View options' })) + + const sizes = within(await screen.findByRole('radiogroup', { name: 'Items per page' })) + await userEvent.click(sizes.getByRole('radio', { name: '50' })) + + await waitFor(() => expect(lastAsked()).toMatchObject({ per_page: '50' })) +}) + +test('searches media through a box reading Search media…', async () => { + renderAt('/media') + await screen.findByText('Harbor at dawn') + + await userEvent.type(screen.getByRole('searchbox', { name: 'Search media…' }), 'harbor') + + await waitFor(() => expect(lastAsked()).toMatchObject({ search: 'harbor' })) +}) + +test('writes only the search into the address while the list keeps the order it opens on', async () => { + const { router } = renderRoutedAt('/media') + await screen.findByText('Harbor at dawn') + + await userEvent.type(screen.getByRole('searchbox', { name: 'Search media…' }), 'harbor') + + await waitFor(() => expect(router.state.location.search).toEqual({ search: 'harbor' })) +}) + +test('names the search box after the library in the words the catalogue gives its admin section', async () => { + setLocaleData( + { '': { 'plural-forms': 'nplurals=2; plural=(n != 1);' }, 'admin section\u0004Media': ['Files'] }, + 'gophenberg', + ) + onTestFinished(() => resetLocaleData({}, 'gophenberg')) + renderAt('/media') + + await screen.findByText('Harbor at dawn') + + expect(screen.getByRole('searchbox', { name: 'Search files…' })).toBeInTheDocument() +}) + +test('keeps the cards of a page and its page count while the next one loads', async () => { + serveRows([HARBOR], 45) + renderAt('/media') + await screen.findByText('Harbor at dawn') + const next = gate() + let asking = false + server.use( + http.get('/api/media', async () => { + asking = true + await next.held + return HttpResponse.json({ items: [MANUAL], total: 45, per_page: 20 }) + }), + ) + + await userEvent.click(screen.getByRole('button', { name: 'Next page' })) + await waitFor(() => expect(asking).toBe(true)) + + expect(screen.getByText('Harbor at dawn')).toBeInTheDocument() + const pages = within(screen.getByRole('combobox', { name: 'Current page' })).getAllByRole('option') + expect(pages.map((page) => page.textContent)).toEqual(['1', '2', '3']) + next.release() + expect(await screen.findByText('Manual')).toBeInTheDocument() +}) + +test('opens Edit from a tap on a phone row of the page on show while the next page loads', async () => { + setViewport({ width: 639 }) + serveRows([HARBOR], 45) + renderAt('/media') + await screen.findByText('Harbor at dawn') + const next = gate() + server.use( + http.get('/api/media', async () => { + await next.held + return HttpResponse.json({ items: [MANUAL], total: 45, per_page: 20 }) + }), + ) + await userEvent.click(screen.getByRole('button', { name: 'Next page' })) + + await userEvent.click(await screen.findByRole('button', { name: /^Harbor at dawn/ })) + + expect(await screen.findByRole('dialog', { name: 'Edit media' })).toBeInTheDocument() + next.release() +}) + +test('says the library holds nothing yet, and how to add a file, when nothing narrows it', async () => { + serveRows([]) + renderAt('/media') + + expect(await screen.findByText('No media items yet.')).toBeInTheDocument() + expect(screen.getByText('Add one with Add Media File.')).toBeInTheDocument() +}) + +test.each([ + { part: 'title', click: (card: Card) => card.getByText('Harbor at dawn') }, + { part: 'picture', click: (card: Card) => card.getByAltText('Boats at sunrise') }, +])('opens Edit from the $part of a file the reader may change', async ({ click }) => { + renderAt('/media') + + await userEvent.click(click(await cardOf('Harbor at dawn'))) + + expect(await screen.findByRole('dialog', { name: 'Edit media' })).toBeInTheDocument() +}) + +test('draws the title of a file another account uploaded as plain text for an author, opening nothing', async () => { + renderAt('/media', AUTHOR) + const card = await cardOf('Harbor at dawn') + + await userEvent.click(card.getByText('Harbor at dawn')) + await userEvent.click(card.getByAltText('Boats at sunrise')) + + expect(card.queryByRole('button', { name: 'Harbor at dawn' })).not.toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() +}) + +test('opens Edit from a tap on a phone row of a file the reader may change', async () => { + setViewport({ width: 639 }) + renderAt('/media') + + await userEvent.click(await screen.findByRole('button', { name: /^Harbor at dawn/ })) + + expect(await screen.findByRole('dialog', { name: 'Edit media' })).toBeInTheDocument() +}) + +test('opens a file another account uploaded in a new tab from a tap on its phone row, as View does', async () => { + setViewport({ width: 639 }) + const opened = vi.spyOn(window, 'open').mockImplementation(() => null) + renderAt('/media', AUTHOR) + + await userEvent.click(await screen.findByRole('button', { name: /^Manual/ })) + + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) + expect(opened).toHaveBeenCalledExactlyOnceWith('/media/2026/08/manual.pdf', '_blank') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() +}) + +test.each([ + { how: 'a search', address: '/media?search=lighthouse' }, + { how: 'an author filter', address: narrowedAt([{ field: 'author', operator: 'isAny', value: [FOREIGN_AUTHOR] }]) }, + { + how: 'a date modified filter', + address: narrowedAt([{ field: 'modified', operator: 'before', value: '2026-01-01T00:00:00.000Z' }]), + }, + { how: 'a kind filter', address: narrowedAt([{ field: 'media_type', operator: 'is', value: 'archive' }]) }, +])('says no media item was found when $how narrows the library to nothing', async ({ address }) => { + serveRows([]) + renderAt(address) + + expect(await screen.findByText('No media items found.')).toBeInTheDocument() + expect(screen.queryByText('Add one with Add Media File.')).not.toBeInTheDocument() +}) diff --git a/frontend/src/test/media-picker.test.tsx b/frontend/src/test/media-picker.test.tsx index 8918af1e..36a4c2d4 100644 --- a/frontend/src/test/media-picker.test.tsx +++ b/frontend/src/test/media-picker.test.tsx @@ -9,6 +9,7 @@ import { beforeEach, expect, test, vi } from 'vitest' import { allowedMimes, MediaLibraryPicker, selectionFrom } from '../media/MediaLibraryPicker' import { SiteFormatGate } from '../settings/SiteFormatGate' +import { gate } from './gate' import { siteSettings } from './siteSettings' const HARBOR = { @@ -142,46 +143,131 @@ test('counts its pages by the size the server served', async () => { expect(pages.map((page) => page.textContent)).toEqual(['1', '2', '3']) }) -test('asks only for the kinds the block accepts', async () => { - let asked = '' +const OWN_AUTHOR = '019fb000-0000-7000-8000-0000000000ff' + +/** + * Returns the sort control of View options, drawn inside the picker's dialog where a reader reaches it. + * @returns The Sort by control. + */ +function sortControl(): Promise { + return screen.findByRole('combobox', { name: 'Sort by' }) +} + +/** + * Serves the library and the authors, recording what each request for the library asked for. + * @returns The parameters of every request so far. + */ +function recording(): URLSearchParams[] { + const asked: URLSearchParams[] = [] server.use( http.get('/api/media', ({ request }) => { - asked = new URL(request.url).searchParams.get('mime') ?? '' - return HttpResponse.json({ items: [HARBOR], total: 1, per_page: siteSettings.picker_page_size }) + asked.push(new URL(request.url).searchParams) + return HttpResponse.json({ items: [HARBOR, CLIFF], total: 2, per_page: siteSettings.picker_page_size }) }), + http.get('/api/authors', () => HttpResponse.json({ items: [{ id: OWN_AUTHOR, name: 'Maria Perez' }] })), ) + return asked +} + +test.each([ + { accepts: ['video'], mime: 'video/', name: 'only for the kinds the block accepts' }, + { accepts: ['image', 'video'], mime: 'image/,video/', name: 'for each kind a mixed block accepts' }, +])('asks $name', async ({ accepts, mime }) => { + const asked = recording() - await openPicker({ allowedTypes: ['video'] }) + await openPicker({ allowedTypes: accepts }) - await waitFor(() => expect(asked).toBe('video/')) + await waitFor(() => expect(asked.at(-1)?.get('mime')).toBe(mime)) }) -test('asks for each kind a mixed block accepts', async () => { - let asked = '' - server.use( - http.get('/api/media', ({ request }) => { - asked = new URL(request.url).searchParams.get('mime') ?? '' - return HttpResponse.json({ items: [HARBOR], total: 1, per_page: siteSettings.picker_page_size }) - }), - ) +test('asks for every kind when the block accepts anything', async () => { + const asked = recording() - await openPicker({ allowedTypes: ['image', 'video'] }) + await openPicker() - await waitFor(() => expect(asked).toBe('image/,video/')) + await waitFor(() => expect(asked.at(-1)?.has('mime')).toBe(false)) }) -test('asks for every kind when the block accepts anything', async () => { - let asked = '' +test('offers Author, Date added and Date modified as filters, and no Type, as the WordPress picker does', async () => { + await openPicker() + await screen.findByText('Harbor at dawn') + + await userEvent.click(screen.getByRole('button', { name: 'Add filter' })) + + const offered = await screen.findAllByRole('menuitem') + expect(offered.map((item) => item.textContent)).toEqual(['Author', 'Date added', 'Date modified']) +}) + +test('sorts by Title, Date added, Date modified and Author alone', async () => { + await openPicker() + await screen.findByText('Harbor at dawn') + + await userEvent.click(screen.getByRole('button', { name: 'View options' })) + + const sorts = within(await sortControl()).getAllByRole('option') + expect(sorts.map((option) => option.textContent)).toEqual(['Title', 'Date added', 'Date modified', 'Author']) +}) + +test('offers no page sizes, the picker showing the size the site sets for it', async () => { + await openPicker() + await screen.findByText('Harbor at dawn') + + await userEvent.click(screen.getByRole('button', { name: 'View options' })) + + expect(await sortControl()).toBeInTheDocument() + expect(screen.queryByRole('radiogroup', { name: 'Items per page' })).not.toBeInTheDocument() +}) + +test('asks again with the sort a reader picks once the first page arrived, beside the block kinds', async () => { + const asked = recording() + await openPicker({ allowedTypes: ['image'] }) + await screen.findByText('Harbor at dawn') + await userEvent.click(screen.getByRole('button', { name: 'View options' })) + + await userEvent.selectOptions(await sortControl(), 'modified') + + await waitFor(() => expect(asked.at(-1)?.get('orderby')).toBe('modified')) + expect(asked.at(-1)?.get('mime')).toBe('image/') + expect(asked.at(-1)?.get('per_page')).toBe(String(siteSettings.picker_page_size)) +}) + +test('asks again with the author a reader picks once the first page arrived', async () => { + const asked = recording() + await openPicker({ allowedTypes: ['image'] }) + await screen.findByText('Harbor at dawn') + await userEvent.click(screen.getByRole('button', { name: 'Add filter' })) + await userEvent.click(await screen.findByRole('menuitem', { name: 'Author' })) + + await userEvent.click(await screen.findByRole('option', { name: 'Maria Perez' })) + + await waitFor(() => expect(asked.at(-1)?.get('author')).toBe(OWN_AUTHOR)) + expect(asked.at(-1)?.get('mime')).toBe('image/') +}) + +test('keeps the cards and the page count while the sort a reader picks loads', async () => { server.use( - http.get('/api/media', ({ request }) => { - asked = new URL(request.url).search - return HttpResponse.json({ items: [HARBOR], total: 1, per_page: siteSettings.picker_page_size }) + http.get('/api/media', () => HttpResponse.json({ items: [HARBOR, CLIFF], total: 25, per_page: 10 })), + ) + await openPicker() + await screen.findByText('Harbor at dawn') + const sorted = gate() + let asking = false + server.use( + http.get('/api/media', async () => { + asking = true + await sorted.held + return HttpResponse.json({ items: [CLIFF, HARBOR], total: 25, per_page: 10 }) }), ) + await userEvent.click(screen.getByRole('button', { name: 'View options' })) - await openPicker() + await userEvent.selectOptions(await sortControl(), 'title') + await waitFor(() => expect(asking).toBe(true)) - await waitFor(() => expect(asked).not.toContain('mime=')) + expect(screen.getByText('Harbor at dawn')).toBeInTheDocument() + const pages = within(screen.getByRole('combobox', { name: 'Current page' })).getAllByRole('option') + expect(pages.map((page) => page.textContent)).toEqual(['1', '2', '3']) + sorted.release() }) test('offers what the block already holds as the selection', async () => { diff --git a/frontend/src/test/media-upload.test.tsx b/frontend/src/test/media-upload.test.tsx index cdd466a2..e31af907 100644 --- a/frontend/src/test/media-upload.test.tsx +++ b/frontend/src/test/media-upload.test.tsx @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 import { http, HttpResponse, server } from '@gophenberg/frontend-sdk/testing' -import { buttonClasses } from '@gopherium/godmin/testing' -import { screen, waitFor, within } from '@testing-library/react' +import { buttonClasses, stubLayout } from '@gopherium/godmin/testing' +import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, expect, test, vi } from 'vitest' @@ -174,6 +174,16 @@ test('reports the reason the one file chosen was refused, and raises no toast', expect(document.querySelector('.godmin-toast')).toBeNull() }) +test('leaves the focus off the library when no file chosen landed', async () => { + storing() + renderAt('/media') + + await choose(file('notes.txt')) + + await within(screen.getByRole('region', { name: 'Media' })).findByRole('alert') + expect(screen.getByRole('region', { name: 'Media' })).not.toHaveFocus() +}) + test('reports an upload that never reached the server', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) server.use(http.post('/api/media', () => HttpResponse.error())) @@ -227,6 +237,113 @@ test('reloads the library and moves the focus to it once a file landed', async ( await waitFor(() => expect(screen.getByRole('region', { name: 'Media' })).toHaveFocus()) }) +test('leaves the focus in a dialog opened while the upload ran once a file landed', async () => { + stubLayout() + const held = gate() + server.use( + http.post('/api/media', async () => { + await held.held + return HttpResponse.json(HARBOR, { status: 201 }) + }), + ) + renderAt('/media') + await choose(file('harbor.jpg')) + await userEvent.click(screen.getByRole('button', { name: 'Actions' })) + await userEvent.click(await screen.findByRole('menuitem', { name: 'Edit…' })) + const title = within(await screen.findByRole('dialog')).getByRole('textbox', { name: 'Title' }) + await waitFor(() => expect(title).toHaveFocus()) + + held.release() + await screen.findByText('"harbor.jpg" uploaded.', { selector: '.godmin-toast' }) + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) + + expect(title).toHaveFocus() +}) + +const MARKET = { ...HARBOR, id: 9, file: '2026/08/market.jpg', title: 'Market at noon' } + +/** + * Shows the library while an upload holds back a file that lands before its one card. + * @returns The gate the upload waits on. + */ +async function uploadHeldBack() { + stubLayout() + const held = gate() + const listed = [HARBOR] + server.use( + http.get('/api/media', () => + HttpResponse.json({ items: listed, total: listed.length, per_page: siteSettings.list_page_size }), + ), + http.post('/api/media', async () => { + await held.held + listed.unshift(MARKET) + return HttpResponse.json(MARKET, { status: 201 }) + }), + ) + renderAt('/media') + await choose(file('market.jpg')) + return held +} + +/** + * Opens a dialog from the menu of the one card while an upload holds back a file that lands before that card. + * @param item - The words of the menu item. + * @returns The dialog and the gate the upload waits on. + */ +async function openWhileUploading(item: string) { + const held = await uploadHeldBack() + await userEvent.click(screen.getByRole('button', { name: 'Actions' })) + await userEvent.click(await screen.findByRole('menuitem', { name: item })) + return { dialog: await screen.findByRole('dialog'), held } +} + +test.each([ + { item: 'Edit…', box: 'Title' }, + { item: 'Rename…', box: 'Name' }, +])('keeps the $item a card menu opened, its typed text and its focus, once an upload lands a file before it', async ({ + item, + box, +}) => { + const { dialog, held } = await openWhileUploading(item) + const typed = within(dialog).getByRole('textbox', { name: box }) + await waitFor(() => expect(typed).toHaveFocus()) + await userEvent.type(typed, ' in the fog') + + held.release() + await screen.findByText('Market at noon') + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) + + expect(screen.getByRole('dialog')).toBe(dialog) + expect(typed).toHaveValue('Harbor at dawn in the fog') + expect(typed).toHaveFocus() +}) + +test('focuses the card menu an upload moved while its dialog was open, once Cancel closes the dialog', async () => { + const { dialog, held } = await openWhileUploading('Rename…') + held.release() + await screen.findByText('Market at noon') + + await userEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })) + + const harbor = (await screen.findByText('Harbor at dawn')).closest('.dataviews-view-grid__card') as HTMLElement + await waitFor(() => expect(within(harbor).getByRole('button', { name: 'Actions' })).toHaveFocus()) + expect(document.querySelectorAll('.dataviews-view-grid__card')[1]).toBe(harbor) +}) + +test('focuses the card title an upload moved while its Edit was open, once Cancel closes the dialog', async () => { + const held = await uploadHeldBack() + await userEvent.click(screen.getByText('Harbor at dawn')) + const dialog = await screen.findByRole('dialog', { name: 'Edit media' }) + held.release() + await screen.findByText('Market at noon') + + await userEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })) + + const harbor = (await screen.findByText('Harbor at dawn')).closest('.dataviews-view-grid__card') as HTMLElement + await waitFor(() => expect(harbor.querySelector('.dataviews-title-field')).toHaveFocus()) + expect(document.querySelectorAll('.dataviews-view-grid__card')[1]).toBe(harbor) +}) + test('uploads every file dropped on the library', async () => { const sent = storing() renderAt('/media') diff --git a/frontend/src/test/media-words.test.ts b/frontend/src/test/media-words.test.ts new file mode 100644 index 00000000..e945c336 --- /dev/null +++ b/frontend/src/test/media-words.test.ts @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect, test } from 'vitest' + +import { repositoryRoot } from '../../scripts/config.ts' + +/** + * Returns the compiled catalogue the admin ships for a language. + * @param locale - The language, such as es-ES. + * @returns The answers, keyed by the context and the message. + */ +function shipped(locale: string): Record { + return JSON.parse(readFileSync(join(repositoryRoot(), 'frontend', 'src', 'languages', `${locale}.json`), 'utf8')) +} + +/** The words WordPress's own Spanish and French catalogues give the strings the media library shows. */ +const WORDPRESS = [ + { key: 'Type', es: 'Tipo', fr: 'Type' }, + { key: 'Thumbnail', es: 'Miniatura', fr: 'Miniature' }, + { key: 'Title', es: 'Título', fr: 'Titre' }, + { key: 'No title', es: 'Sin título', fr: 'Aucun titre' }, + { key: '(no title)', es: '(sin título)', fr: '(pas de titre)' }, + { key: 'Alt text', es: 'Texto alternativo', fr: 'Texte alternatif' }, + { key: 'Caption', es: 'Leyenda', fr: 'Légende' }, + { key: 'Description', es: 'Descripción', fr: 'Description' }, + { key: 'Date added', es: 'Fecha de inclusión', fr: 'Date d’ajout' }, + { key: 'Date modified', es: 'Fecha de modificación', fr: 'Date de modification' }, + { key: 'Author', es: 'Autor', fr: 'Auteur/autrice' }, + { key: 'File name', es: 'Nombre del archivo', fr: 'Nom de fichier' }, + { key: 'File size', es: 'Tamaño del archivo', fr: 'Taille du fichier' }, + { key: 'Dimensions', es: 'Dimensiones', fr: 'Dimensions' }, + { key: 'File type', es: 'Tipo de archivo', fr: 'Type de fichier' }, + { key: 'Images', es: 'Imágenes', fr: 'Images' }, + { key: 'file type group\u0004Audio', es: 'Audio', fr: 'Sons' }, + { key: 'file type group\u0004Video', es: 'Vídeo', fr: 'Vidéos' }, + { key: 'Documents', es: 'Documentos', fr: 'Documents' }, + { key: 'file type group\u0004Archives', es: 'Archivos', fr: 'Archives' }, + { + key: 'Describe the purpose of the image.', + es: 'Describe el propósito de la imagen.', + fr: 'Décrivez la fonction de l’image.', + }, + { + key: 'Leave empty if decorative.', + es: 'Déjalo vacío si es decorativo.', + fr: 'Laissez vide si l’image est décorative', + }, + { key: 'file size\u0004%1$s %2$s', es: '%1$s %2$s', fr: '%1$s %2$s' }, + { key: 'image dimensions\u0004%1$s × %2$s', es: '%1$s × %2$s', fr: '%1$s par %2$s' }, + { key: 'unit symbol\u0004B', es: 'B', fr: 'o' }, + { key: 'unit symbol\u0004KB', es: 'KB', fr: 'Ko' }, + { key: 'unit symbol\u0004MB', es: 'MB', fr: 'Mo' }, + { key: 'unit symbol\u0004GB', es: 'GB', fr: 'Go' }, + { key: 'verb\u0004View', es: 'Ver', fr: 'Voir' }, + { key: 'Rename…', es: 'Renombrar…', fr: 'Renommer…' }, + { key: 'Permanently delete…', es: 'Eliminar permanentemente…', fr: 'Supprimer définitivement…' }, + { key: 'Edit media', es: 'Editar los medios', fr: 'Modifier le média' }, + { key: 'Rename', es: 'Renombrar', fr: 'Renommer' }, + { key: 'Name', es: 'Nombre', fr: 'Nom' }, + { key: 'Save', es: 'Guardar', fr: 'Enregistrer' }, + { key: 'Cancel', es: 'Cancelar', fr: 'Annuler' }, + { key: 'Delete permanently', es: 'Borrar permanentemente', fr: 'Supprimer définitivement' }, + { key: 'Media file updated.', es: 'Archivo de medios actualizado.', fr: 'Fichier média mis à jour.' }, + { + key: 'Could not save image. %s', + es: 'No se ha podido guardar la imagen. %s', + fr: 'Impossible d’enregistrer le média. %s', + }, + { + key: 'Could not save media. %s', + es: 'No se ha podido guardar el archivo multimedia. %s', + fr: 'Impossible d’enregistrer le média. %s', + }, + { key: '"%s" permanently deleted.', es: '«%s» borrado permanentemente.', fr: 'Suppression définitive de « %s ».' }, + { key: 'Search media', es: 'Buscar medios', fr: 'Rechercher des médias' }, + { key: 'No media items found.', es: 'No se han encontrado archivos de medios.', fr: 'Aucun média n’a été trouvé.' }, + { key: 'Add Media File', es: 'Añadir medios', fr: 'Ajouter un fichier média' }, + { + key: 'Drop files to upload', + es: 'Arrastra los archivos para subirlos', + fr: 'Déposez vos fichiers pour les téléverser', + }, + { key: 'Media Library', es: 'Biblioteca de medios', fr: 'Médiathèque' }, + { key: 'Select', es: 'Seleccionar', fr: 'Sélectionner' }, + { key: 'admin section\u0004Media', es: 'Medios', fr: 'Médias' }, +] + +/** The rows above, each named by its message and the context it is read in. */ +const NAMED = WORDPRESS.map((row) => ({ ...row, named: row.key.split('\u0004').reverse().join(' as ') })) + +test.each(NAMED)('ships $named in the Spanish and the French WordPress gives it', ({ key, es, fr }) => { + expect([shipped('es-ES')[key], shipped('fr-FR')[key]]).toEqual([[es], [fr]]) +}) diff --git a/frontend/src/test/ownership-screens.test.tsx b/frontend/src/test/ownership-screens.test.tsx index f2aec320..b2389588 100644 --- a/frontend/src/test/ownership-screens.test.tsx +++ b/frontend/src/test/ownership-screens.test.tsx @@ -90,11 +90,12 @@ function serveMedia(authorId: string) { /** * Serves the stored post and its empty autosave for the editor. * @param authorId - The account the stored post belongs to. + * @param fields - The stored post's fields that differ from the fixture. */ -function serveEditor(authorId: string) { +function serveEditor(authorId: string, fields: Record = {}) { server.use( http.get(`/api/content/${storedPost.id}`, () => - HttpResponse.json({ ...storedPost, author_id: authorId }), + HttpResponse.json({ ...storedPost, ...fields, author_id: authorId }), ), http.get(`/api/content/${storedPost.id}/autosave`, () => HttpResponse.json({}, { status: 404 }), @@ -145,19 +146,7 @@ test('opens a post another account wrote as a reading view', async () => { }) test('reads an untitled foreign post under a stand in title, its excerpt beside it', async () => { - server.use( - http.get(`/api/content/${storedPost.id}`, () => - HttpResponse.json({ - ...storedPost, - title: '', - excerpt: 'A few words about it.', - author_id: FOREIGN, - }), - ), - http.get(`/api/content/${storedPost.id}/autosave`, () => - HttpResponse.json({}, { status: 404 }), - ), - ) + serveEditor(FOREIGN, { title: '', excerpt: 'A few words about it.' }) renderAt(`/content/post/${storedPost.id}/edit`, maria) expect(await screen.findByText('(no title)')).toBeInTheDocument() @@ -165,14 +154,7 @@ test('reads an untitled foreign post under a stand in title, its excerpt beside }) test('reads a trashed post another account wrote as a foreign post', async () => { - server.use( - http.get(`/api/content/${storedPost.id}`, () => - HttpResponse.json({ ...storedPost, status: 'trash', author_id: FOREIGN }), - ), - http.get(`/api/content/${storedPost.id}/autosave`, () => - HttpResponse.json({}, { status: 404 }), - ), - ) + serveEditor(FOREIGN, { status: 'trash' }) renderAt(`/content/post/${storedPost.id}/edit`, maria) expect(await screen.findByText(/another account wrote this/i)).toBeInTheDocument() @@ -181,14 +163,7 @@ test('reads a trashed post another account wrote as a foreign post', async () => }) test('offers an editor a restore on a trashed post another account wrote', async () => { - server.use( - http.get(`/api/content/${storedPost.id}`, () => - HttpResponse.json({ ...storedPost, status: 'trash', author_id: FOREIGN }), - ), - http.get(`/api/content/${storedPost.id}/autosave`, () => - HttpResponse.json({}, { status: 404 }), - ), - ) + serveEditor(FOREIGN, { status: 'trash' }) renderAt(`/content/post/${storedPost.id}/edit`, grace) expect(await screen.findByText(/This item is in the trash/)).toBeInTheDocument() @@ -209,35 +184,37 @@ test('opens a foreign post as the editor for an editor', async () => { expect(await screen.findByRole('textbox', { name: 'Title' })).toBeInTheDocument() }) -test('offers an author no controls on media another account uploaded', async () => { - serveMedia(FOREIGN) - renderAt('/media', maria) - await screen.findByText('Harbor at dawn') +/** Every action the media library offers on a file the session may change, in order. */ +const EVERY_MEDIA_ACTION = ['View', 'Edit…', 'Rename…', 'Permanently delete…'] +/** + * Opens the actions menu of the one listed file and returns the words of its items. + * @returns The words, in order. + */ +async function mediaMenu(): Promise<(string | null)[]> { + await screen.findByText('Harbor at dawn') await userEvent.click(screen.getByRole('button', { name: 'Actions' })) + await screen.findByRole('menuitem', { name: 'View' }) + return screen.getAllByRole('menuitem').map((item) => item.textContent) +} - expect(screen.queryByRole('menuitem', { name: 'Describe' })).toBeNull() - expect(screen.queryByRole('menuitem', { name: 'Delete Permanently' })).toBeNull() +test('offers an author only a view of media another account uploaded', async () => { + serveMedia(FOREIGN) + renderAt('/media', maria) + + expect(await mediaMenu()).toEqual(['View']) }) test('offers an editor every action on media another account uploaded', async () => { serveMedia(FOREIGN) renderAt('/media', grace) - await screen.findByText('Harbor at dawn') - await userEvent.click(screen.getByRole('button', { name: 'Actions' })) - - expect(await screen.findByRole('menuitem', { name: 'Describe' })).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: 'Delete Permanently' })).toBeInTheDocument() + expect(await mediaMenu()).toEqual(EVERY_MEDIA_ACTION) }) test('offers an author every action on its own upload', async () => { serveMedia(maria.id) renderAt('/media', maria) - await screen.findByText('Harbor at dawn') - - await userEvent.click(screen.getByRole('button', { name: 'Actions' })) - expect(await screen.findByRole('menuitem', { name: 'Describe' })).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: 'Delete Permanently' })).toBeInTheDocument() + expect(await mediaMenu()).toEqual(EVERY_MEDIA_ACTION) }) From 16d3abe5d0310f07066dd83efa60bd4f0081f6ad Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 10:52:09 +0100 Subject: [PATCH 07/13] test(e2e): drive the media library list, dialogs and focus at desktop and phone sizes --- test/e2e/tests/fit.spec.ts | 1 + test/e2e/tests/media.spec.ts | 561 ++++++++++++++++++++++++++++++++--- 2 files changed, 521 insertions(+), 41 deletions(-) diff --git a/test/e2e/tests/fit.spec.ts b/test/e2e/tests/fit.spec.ts index 9ad59490..8302d4e1 100644 --- a/test/e2e/tests/fit.spec.ts +++ b/test/e2e/tests/fit.spec.ts @@ -121,6 +121,7 @@ test('seeds a hostile post and fits every screen to a phone', async ({ page }) = '/admin/content/page', '/admin/content/category', '/admin/content-types', + '/admin/media', '/admin/users', '/admin/users/new', ] diff --git a/test/e2e/tests/media.spec.ts b/test/e2e/tests/media.spec.ts index 2162f822..020e2c8a 100644 --- a/test/e2e/tests/media.spec.ts +++ b/test/e2e/tests/media.spec.ts @@ -1,11 +1,20 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, test } from '@playwright/test' -import type { Page } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' + +import { credentials } from '../env' const RUN = Math.random().toString(36).slice(2, 8) const PICTURE = `harbor-e2e-${RUN}` const POST_TITLE = `A post carrying a picture ${RUN}` +const LIBRARY = `library-e2e-${RUN}` + +const PHONE = { width: 390, height: 844 } + +const LAPTOP = { width: 1280, height: 900 } + +const DESKTOP = { width: 1920, height: 1080 } // PHOTO_BASE64 is an eight by eight JPEG painted for this suite. const PHOTO_BASE64 = @@ -51,6 +60,127 @@ test.afterEach(async ({ page }) => { } }) +/** + * Stores the painted photo through the API the admin uses, its title taken from its file name. + * @param page - The page whose session uploads the photo. + * @param title - The title the photo takes, its file name less the extension. + * @returns The id of the stored photo. + */ +async function stored(page: Page, title: string): Promise { + const answer = await page.request.post('/api/media', { multipart: { file: photo(`${title}.jpg`) } }) + expect(answer.status()).toBe(201) + return ((await answer.json()) as { id: number }).id +} + +/** + * Opens a new post in the editor, keeping its id for the clean up. + * @param page - The page to drive. + */ +async function newPost(page: Page) { + await page.goto('/admin/content/post') + await page.getByRole('main').getByRole('button', { name: 'Add Post', exact: true }).click() + await expect(page.getByRole('textbox', { name: 'Title' })).toBeVisible() + const postId = page.url().match(/content\/[a-z-]+\/([0-9a-f-]+)\/edit/)?.[1] + if (postId !== undefined) { + createdPosts.push(postId) + } +} + +/** + * Adds an image block to the open post. + * @param page - The page holding the editor. + */ +async function addImageBlock(page: Page) { + await canvas(page).getByRole('document', { name: 'Add default block' }).click() + await page.keyboard.type('/image') + await page.keyboard.press('Enter') +} + +/** + * Opens a new post, adds an image block and opens the media library picker from it. + * @param page - The page to drive. + * @returns The picker dialog. + */ +async function openPicker(page: Page): Promise { + await newPost(page) + await addImageBlock(page) + await canvas(page).getByRole('button', { name: 'Media Library' }).click() + return page.getByRole('dialog', { name: 'Media Library' }) +} + +/** + * Returns the card of the library grid showing a title, matched whole. + * @param page - The page holding the library. + * @param title - The title the card shows. + * @returns The card locator. + */ +function cardOf(page: Page, title: string): Locator { + return page.locator('.dataviews-view-grid__card').filter({ has: page.getByText(title, { exact: true }) }) +} + +/** + * Opens the library narrowed to this run's files and the actions menu of the card showing a title. + * @param page - The page to drive. + * @param title - The title the card shows. + */ +async function openCardMenu(page: Page, title: string) { + await page.goto(`/admin/media?search=${LIBRARY}`) + await cardOf(page, title).getByRole('button', { name: 'Actions' }).click() +} + +/** + * Holds every listing of the library for 800ms from now on, as a real server over a network answers. + * @param page - The page whose listings are held. + */ +async function slowListings(page: Page) { + await page.route('**/api/media?*', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 800)) + await route.continue() + }) +} + +/** How fast the server lists the library again after a save, at once from localhost or held as over a network. */ +const LISTINGS = [ + { speed: 'slow', hold: slowListings }, + { speed: 'quick', hold: async () => {} }, +] + +/** + * Returns the box of the open modal frame once its opening animation ends, rounded to the pixel. + * @param page - The page holding the modal. + * @param side - The edge or size of the box to read. + * @returns The poll that reads it until it holds still. + */ +function frameBox(page: Page, side: 'width' | 'bottom') { + return expect.poll(async () => { + const box = await page.locator('.components-modal__frame').boundingBox() + return Math.round(side === 'width' ? (box?.width ?? 0) : (box?.y ?? 0) + (box?.height ?? 0)) + }) +} + +/** + * Returns the id of the account the page is signed in as. + * @param page - The signed in page. + * @returns The account id. + */ +async function sessionId(page: Page): Promise { + const answer = await page.request.get('/api/auth/session') + return ((await answer.json()) as { id: string }).id +} + +/** + * Waits for the request listing the library that carries every given parameter. + * @param page - The page that sends it. + * @param wanted - The parameters and the values the request must carry. + * @returns The wait. + */ +function listingAsking(page: Page, wanted: Record) { + return page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/media' && Object.entries(wanted).every(([key, value]) => url.searchParams.get(key) === value) + }) +} + test('uploads two pictures chosen at once, counts them in a toast and serves them publicly', async ({ page }) => { await page.goto('/admin/media') await expect(page.getByRole('heading', { name: 'Media' })).toBeVisible() @@ -98,25 +228,10 @@ test('uploads a picture dropped on the library, over a zone reading Drop files t test('places a picture already in the library through the media library picker', async ({ page, }) => { - const uploaded = await page.request.post('/api/media', { - multipart: { file: photo(`${PICTURE}-stored.jpg`) }, - }) - expect(uploaded.status()).toBe(201) + await stored(page, `${PICTURE}-stored`) - await page.goto('/admin/content/post') - await page.getByRole('main').getByRole('button', { name: 'Add Post', exact: true }).click() - await expect(page.getByRole('textbox', { name: 'Title' })).toBeVisible() - const postId = page.url().match(/content\/[a-z-]+\/([0-9a-f-]+)\/edit/)?.[1] - if (postId !== undefined) { - createdPosts.push(postId) - } - - await canvas(page).getByRole('document', { name: 'Add default block' }).click() - await page.keyboard.type('/image') - await page.keyboard.press('Enter') - await canvas(page).getByRole('button', { name: 'Media Library' }).click() + const library = await openPicker(page) - const library = page.getByRole('dialog') await expect(library.getByText(`${PICTURE}-stored`)).toBeVisible() await library.getByText(`${PICTURE}-stored`).click() await library.getByRole('button', { name: 'Select' }).click() @@ -125,20 +240,8 @@ test('places a picture already in the library through the media library picker', }) test('frames the media library search box when an image block opens the library', async ({ page }) => { - await page.goto('/admin/content/post') - await page.getByRole('main').getByRole('button', { name: 'Add Post', exact: true }).click() - await expect(page.getByRole('textbox', { name: 'Title' })).toBeVisible() - const postId = page.url().match(/content\/[a-z-]+\/([0-9a-f-]+)\/edit/)?.[1] - if (postId !== undefined) { - createdPosts.push(postId) - } + const library = await openPicker(page) - await canvas(page).getByRole('document', { name: 'Add default block' }).click() - await page.keyboard.type('/image') - await page.keyboard.press('Enter') - await canvas(page).getByRole('button', { name: 'Media Library' }).click() - - const library = page.getByRole('dialog') await library.getByRole('searchbox', { name: 'Search media' }).focus() const frame = library.locator('.components-input-control__backdrop') @@ -147,18 +250,11 @@ test('frames the media library search box when an image block opens the library' }) test('places an uploaded picture in a post and publishes it', async ({ page }) => { - await page.goto('/admin/content/post') - await page.getByRole('main').getByRole('button', { name: 'Add Post', exact: true }).click() - await expect(page.getByRole('textbox', { name: 'Title' })).toBeVisible() - const postId = page.url().match(/content\/[a-z-]+\/([0-9a-f-]+)\/edit/)?.[1] - if (postId !== undefined) { - createdPosts.push(postId) - } + await newPost(page) + const postId = createdPosts.at(-1) await page.getByRole('textbox', { name: 'Title' }).fill(POST_TITLE) - await canvas(page).getByRole('document', { name: 'Add default block' }).click() - await page.keyboard.type('/image') - await page.keyboard.press('Enter') + await addImageBlock(page) const chooserOpens = page.waitForEvent('filechooser') await canvas(page).getByRole('button', { name: 'Upload' }).click() const chooser = await chooserOpens @@ -186,3 +282,386 @@ test('places an uploaded picture in a post and publishes it', async ({ page }) = const served = await page.request.get(src as string) expect(served.status()).toBe(200) }) + +test('lists the library as a grid of cards, each title bold above the name of who uploaded it', async ({ page }) => { + const title = `${LIBRARY}-grid` + await stored(page, title) + await page.setViewportSize(LAPTOP) + + await page.goto(`/admin/media?search=${LIBRARY}`) + + const card = cardOf(page, title) + await expect(card).toBeVisible() + await expect(card.locator('.dataviews-title-field')).toHaveCSS('font-weight', '600') + await expect(card.getByText(credentials.name, { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: /layout/i })).toHaveCount(0) +}) + +test('keeps a sort and a Type filter the reader picks across a reload', async ({ page }) => { + const title = `${LIBRARY}-kept` + await stored(page, title) + await page.goto(`/admin/media?search=${LIBRARY}`) + await page.getByRole('button', { name: 'View options' }).click() + await page.getByRole('combobox', { name: 'Sort by' }).selectOption('title') + await page.keyboard.press('Escape') + await page.getByRole('button', { name: 'Add filter' }).click() + await page.getByRole('menuitem', { name: 'Type', exact: true }).click() + await page.getByRole('option', { name: 'Images' }).click() + await expect(page).toHaveURL(/sort=title/) + await expect(page).toHaveURL(/media_type/) + + await page.reload() + + await expect(cardOf(page, title)).toBeVisible() + await page.getByRole('button', { name: 'Filter', exact: true }).click() + await expect(page.locator('.dataviews-filters__summary-chip')).toContainText('Images') + await page.getByRole('button', { name: 'View options' }).click() + await expect(page.getByRole('combobox', { name: 'Sort by' })).toHaveValue('title') +}) + +test('opens Edit media from the title of a card in a 560 pixel dialog that saves and says so', async ({ page }) => { + const title = `${LIBRARY}-edited` + const id = await stored(page, title) + await page.setViewportSize(LAPTOP) + await page.goto(`/admin/media?search=${LIBRARY}`) + + await cardOf(page, title).getByText(title, { exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Edit media' }) + await expect(dialog.getByRole('textbox', { name: 'Title' })).toBeFocused() + await frameBox(page, 'width').toBe(560) + await dialog.getByRole('textbox', { name: 'Alt text' }).fill('Boats leaving the harbor') + await dialog.getByRole('button', { name: 'Save' }).click() + + await expect(dialog).toHaveCount(0) + await expect(page.locator('.godmin-toast', { hasText: 'Media file updated.' })).toBeVisible() + const read = await page.request.get(`/api/media/${id}`) + expect(((await read.json()) as { alt_text: string }).alt_text).toBe('Boats leaving the harbor') +}) + +test('renames a file from its card menu in a 560 pixel dialog, the Name box focused', async ({ page }) => { + const title = `${LIBRARY}-named` + await stored(page, title) + await openCardMenu(page, title) + + await page.getByRole('menuitem', { name: 'Rename…' }).click() + const dialog = page.getByRole('dialog', { name: 'Rename' }) + await expect(dialog.getByRole('textbox', { name: 'Name' })).toBeFocused() + await frameBox(page, 'width').toBe(560) + await dialog.getByRole('textbox', { name: 'Name' }).fill(`${LIBRARY}-renamed`) + await dialog.getByRole('button', { name: 'Save' }).click() + + await expect(dialog).toHaveCount(0) + await expect(page.locator('.godmin-toast', { hasText: 'Name updated.' })).toBeVisible() + await expect(cardOf(page, `${LIBRARY}-renamed`)).toBeVisible() +}) + +test('gives the focus back to the card menu once a dialog it opened is closed', async ({ page }) => { + const title = `${LIBRARY}-returned` + await stored(page, title) + await openCardMenu(page, title) + + await page.getByRole('menuitem', { name: 'Edit…' }).click() + const dialog = page.getByRole('dialog', { name: 'Edit media' }) + await expect(dialog.getByRole('textbox', { name: 'Title' })).toBeFocused() + await dialog.getByRole('button', { name: 'Cancel' }).click() + + await expect(dialog).toHaveCount(0) + await expect(cardOf(page, title).getByRole('button', { name: 'Actions' })).toBeFocused() +}) + +test('gives the focus back to the card menu once Edit saves and a slow server lists the library again', async ({ + page, +}) => { + const title = `${LIBRARY}-saved` + await stored(page, title) + await openCardMenu(page, title) + + await page.getByRole('menuitem', { name: 'Edit…' }).click() + const dialog = page.getByRole('dialog', { name: 'Edit media' }) + await dialog.getByRole('textbox', { name: 'Alt text' }).fill('Boats leaving the harbor') + await slowListings(page) + await dialog.getByRole('button', { name: 'Save' }).click() + + await expect(page.locator('.godmin-toast', { hasText: 'Media file updated.' })).toBeVisible() + await expect(cardOf(page, title).getByRole('button', { name: 'Actions' })).toBeFocused() +}) + +for (const { speed, hold } of LISTINGS) { + test(`gives the focus back to the card title Edit media opened from once it saves and a ${speed} server lists again`, async ({ + page, + }) => { + const title = `${LIBRARY}-title-${speed}` + await stored(page, title) + await page.setViewportSize(LAPTOP) + await page.goto(`/admin/media?search=${LIBRARY}`) + const door = cardOf(page, title).locator('.dataviews-title-field') + + await door.click() + const dialog = page.getByRole('dialog', { name: 'Edit media' }) + await dialog.getByRole('textbox', { name: 'Alt text' }).fill('Boats leaving the harbor') + await hold(page) + await dialog.getByRole('button', { name: 'Save' }).click() + + await expect(page.locator('.godmin-toast', { hasText: 'Media file updated.' })).toBeVisible() + await expect(door).toBeFocused() + }) +} + +test('scrolls the fields of Edit media at 1280 by 900 from the keyboard, through a pane named Scrollable section', async ({ + page, +}) => { + const title = `${LIBRARY}-scrolled` + await stored(page, title) + await page.setViewportSize(LAPTOP) + await openCardMenu(page, title) + await page.getByRole('menuitem', { name: 'Edit…' }).click() + const dialog = page.getByRole('dialog', { name: 'Edit media' }) + const pane = dialog.locator('.gophenberg-media__edit-fields') + const dimensions = pane.getByText('Dimensions', { exact: true }) + const header = dialog.locator('.components-modal__header') + await dialog.getByRole('textbox', { name: 'Description' }).focus() + await expect(dimensions).not.toBeInViewport() + await expect(header).toHaveCSS('border-bottom-color', 'rgba(0, 0, 0, 0)') + + for (const stop of ['Cancel', 'Save', 'Close']) { + await page.keyboard.press('Tab') + await expect(dialog.getByRole('button', { name: stop })).toBeFocused() + } + await page.keyboard.press('Tab') + await expect(dialog.getByRole('document', { name: 'Scrollable section' })).toBeFocused() + await expect(pane).toHaveCSS('box-shadow', /inset$/) + await page.keyboard.press('End') + + await expect(dimensions).toBeInViewport() + await expect(header).not.toHaveCSS('border-bottom-color', 'rgba(0, 0, 0, 0)') + await expect.poll(() => gapUnder(dimensions)).toBeGreaterThanOrEqual(24) +}) + +/** + * Measures the space between the bottom of a field and the bottom edge of the Edit pane it scrolls in. + * @param field - A field inside the pane. + * @returns The gap in CSS pixels. + */ +function gapUnder(field: Locator): Promise { + return field.evaluate((element) => { + const pane = element.closest('.gophenberg-media__edit-fields') as Element + return Math.round(pane.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom) + }) +} + +for (const size of [LAPTOP, DESKTOP]) { + test(`keeps Cancel and Save of Edit media in view at ${size.width} by ${size.height}, the fields scrolling above them`, async ({ + page, + }) => { + const title = `${LIBRARY}-tall-${size.width}` + await stored(page, title) + await page.setViewportSize(size) + await openCardMenu(page, title) + + await page.getByRole('menuitem', { name: 'Edit…' }).click() + const dialog = page.getByRole('dialog', { name: 'Edit media' }) + await expect(dialog.getByRole('textbox', { name: 'Title' })).toBeFocused() + + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeInViewport({ ratio: 1 }) + await expect(dialog.getByRole('button', { name: 'Save' })).toBeInViewport({ ratio: 1 }) + }) +} + +test('deletes a file behind a headerless 560 pixel confirm, Cancel focused', async ({ page }) => { + const title = `${LIBRARY}-deleted` + await stored(page, title) + await openCardMenu(page, title) + + await page.getByRole('menuitem', { name: 'Permanently delete…' }).click() + const dialog = page.getByRole('dialog') + await expect(dialog.getByText(`Delete "${title}" for good? This cannot be undone.`)).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused() + await expect(dialog.getByRole('heading')).toHaveCount(0) + await frameBox(page, 'width').toBe(560) + await dialog.getByRole('button', { name: 'Delete permanently' }).click() + + await expect(page.locator('.godmin-toast', { hasText: `"${title}" permanently deleted.` })).toBeVisible() + await expect(cardOf(page, title)).toHaveCount(0) +}) + +test('keeps a Rename opened on the last card of a row, with what was typed, once an upload lands a file before it', async ({ + page, +}) => { + for (const at of ['a', 'b', 'c', 'd', 'e', 'f']) { + await stored(page, `${LIBRARY}-row-${at}`) + } + await page.setViewportSize(LAPTOP) + await page.goto(`/admin/media?search=${LIBRARY}`) + const last = page.locator('.dataviews-view-grid__row').first().locator('.dataviews-view-grid__card').last() + const title = (await last.locator('.dataviews-title-field').textContent()) as string + let release = () => {} + const held = new Promise((resolve) => { + release = resolve + }) + await page.route('**/api/media', async (route) => { + if (route.request().method() === 'POST') { + await held + } + await route.continue() + }) + const chooser = page.waitForEvent('filechooser') + await page.getByRole('main').getByRole('button', { name: 'Add Media File' }).click() + await (await chooser).setFiles(photo(`${LIBRARY}-row-landed.jpg`)) + await last.getByRole('button', { name: 'Actions' }).click() + await page.getByRole('menuitem', { name: 'Rename…' }).click() + const name = page.getByRole('dialog', { name: 'Rename' }).getByRole('textbox', { name: 'Name' }) + await expect(name).toBeFocused() + await name.press('End') + await name.pressSequentially(' kept') + + release() + await expect(page.locator('.godmin-toast', { hasText: `"${LIBRARY}-row-landed.jpg" uploaded.` })).toBeVisible() + + await expect(name).toHaveValue(`${title} kept`) + await expect(name).toBeFocused() +}) + +test('narrows and sorts the media library picker with the pointer, then places the picked picture', async ({ + page, +}) => { + const title = `${LIBRARY}-pointed` + await stored(page, title) + const library = await openPicker(page) + await expect(library.getByText(title, { exact: true })).toBeVisible() + + await library.getByRole('button', { name: 'Add filter' }).click() + await page.getByRole('menuitem', { name: 'Author' }).click() + const narrowed = listingAsking(page, { author: await sessionId(page) }) + await library.getByRole('option', { name: credentials.name }).click() + await narrowed + await page.keyboard.press('Escape') + await library.getByRole('button', { name: 'View options' }).click() + const sorted = listingAsking(page, { orderby: 'modified' }) + await library.getByRole('combobox', { name: 'Sort by' }).selectOption('modified') + await sorted + await page.keyboard.press('Escape') + await library.getByText(title, { exact: true }).click() + await library.getByRole('button', { name: 'Select' }).click() + + await expect(library).toHaveCount(0) + await expect(canvas(page).locator('img[src^="/media/"]')).toBeVisible() +}) + +test('searches, narrows, sorts and places from the media library picker by keyboard, each popover inside it', async ({ + page, +}) => { + const title = `${LIBRARY}-typed` + await stored(page, title) + const library = await openPicker(page) + await library.getByRole('searchbox', { name: 'Search media' }).focus() + await page.keyboard.type(title) + await expect(library.getByText(title, { exact: true })).toBeVisible() + + await library.getByRole('button', { name: 'Add filter' }).focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('menuitem', { name: 'Author' })).toBeFocused() + await page.keyboard.press('Enter') + await expect(library.getByRole('combobox', { name: 'Conditions' })).toBeFocused() + const authors = library.getByRole('listbox', { name: 'List of: Author' }) + await expect(async () => { + await page.keyboard.press('Tab') + await expect(authors).toBeFocused({ timeout: 500 }) + }).toPass() + const mine = authors.getByRole('option', { name: credentials.name }) + for (let step = 0; step < 10 && (await mine.getAttribute('data-active-item')) === null; step++) { + await page.keyboard.press('ArrowDown') + } + const narrowed = listingAsking(page, { author: await sessionId(page) }) + await page.keyboard.press('Enter') + await narrowed + await expect(authors).toBeFocused() + await page.keyboard.press('Escape') + await library.getByRole('button', { name: 'View options' }).focus() + await page.keyboard.press('Enter') + await expect(library.getByRole('combobox', { name: 'Sort by' })).toBeFocused() + const sorted = page.waitForResponse((answer) => new URL(answer.url()).searchParams.has('orderby')) + await page.keyboard.press('ArrowDown') + await sorted + await page.keyboard.press('Escape') + await expect(library.getByRole('button', { name: 'View options' })).toBeFocused() + await expect(library.getByText(title, { exact: true })).toBeVisible() + await library.getByRole('button', { name: 'Reset', exact: true }).focus() + await page.keyboard.press('Tab') + await page.keyboard.press('Space') + await page.keyboard.press('Tab') + await expect(library.getByRole('button', { name: 'Select' })).toBeFocused() + await page.keyboard.press('Enter') + + await expect(library).toHaveCount(0) + await expect(canvas(page).locator('img[src^="/media/"]')).toBeVisible() +}) + +test.describe('on a phone', () => { + test.use({ viewport: PHONE, isMobile: true, hasTouch: true }) + + test('lists the library one file per row and opens Edit media as a sheet from a tap on a row', async ({ page }) => { + const title = `${LIBRARY}-phone` + await stored(page, title) + + await page.goto(`/admin/media?search=${LIBRARY}`) + + await expect(page.locator('.dataviews-view-list')).toBeVisible() + await expect(page.locator('.dataviews-view-grid')).toHaveCount(0) + await page.getByRole('button', { name: new RegExp(`^${title}`) }).tap() + await expect(page.getByRole('dialog', { name: 'Edit media' })).toBeVisible() + await frameBox(page, 'width').toBe(PHONE.width) + await frameBox(page, 'bottom').toBe(PHONE.height) + }) + + test('keeps Cancel and Save of Edit media in view in the sheet, the fields scrolling above them', async ({ page }) => { + const title = `${LIBRARY}-sheet` + await stored(page, title) + await page.goto(`/admin/media?search=${LIBRARY}`) + + await page.getByRole('button', { name: new RegExp(`^${title}`) }).tap() + const dialog = page.getByRole('dialog', { name: 'Edit media' }) + await frameBox(page, 'bottom').toBe(PHONE.height) + + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeInViewport({ ratio: 1 }) + await expect(dialog.getByRole('button', { name: 'Save' })).toBeInViewport({ ratio: 1 }) + }) + + for (const { speed, hold } of LISTINGS) { + test(`gives the focus back to the tapped row once Edit media saves and a ${speed} server lists again`, async ({ + page, + }) => { + const title = `${LIBRARY}-tap-${speed}` + await stored(page, title) + await page.goto(`/admin/media?search=${LIBRARY}`) + const door = page.getByRole('button', { name: new RegExp(`^${title}`) }) + + await door.tap() + const dialog = page.getByRole('dialog', { name: 'Edit media' }) + await dialog.getByRole('textbox', { name: 'Alt text' }).fill('Boats leaving the harbor') + await hold(page) + await dialog.getByRole('button', { name: 'Save' }).tap() + + await expect(page.locator('.godmin-toast', { hasText: 'Media file updated.' })).toBeVisible() + await expect(door).toBeFocused() + }) + } + + test('gives the focus back to the tapped row once Edit media fails to save and a slow server lists again', async ({ + page, + }) => { + const title = `${LIBRARY}-tap-failed` + await stored(page, title) + await page.goto(`/admin/media?search=${LIBRARY}`) + const door = page.getByRole('button', { name: new RegExp(`^${title}`) }) + + await door.tap() + const dialog = page.getByRole('dialog', { name: 'Edit media' }) + await dialog.getByRole('textbox', { name: 'Alt text' }).fill('Boats leaving the harbor') + await page.route('**/api/media/*', (route) => (route.request().method() === 'PATCH' ? route.abort() : route.continue())) + await slowListings(page) + await dialog.getByRole('button', { name: 'Save' }).tap() + + await expect(page.locator('.godmin-toast', { hasText: 'Could not save image.' })).toBeVisible() + await expect(door).toBeFocused() + }) +}) From fc53480f4af2a1259623759f1f95d788b6f54ca9 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 10:52:10 +0100 Subject: [PATCH 08/13] chore(i18n): add the media library words in spanish and french --- frontend/src/languages/es-ES.json | 2 +- frontend/src/languages/fr-FR.json | 2 +- internal/i18n/catalogs/es-ES.json | 2 +- internal/i18n/catalogs/fr-FR.json | 2 +- languages/es-ES.po | 151 +++++++++++++++++++++------ languages/fr-FR.po | 168 ++++++++++++++++++++++-------- languages/gophenberg.pot | 110 ++++++++++++++----- 7 files changed, 328 insertions(+), 109 deletions(-) diff --git a/frontend/src/languages/es-ES.json b/frontend/src/languages/es-ES.json index d26fa461..491c9dac 100644 --- a/frontend/src/languages/es-ES.json +++ b/frontend/src/languages/es-ES.json @@ -1 +1 @@ -{"":{"lang":"es","plural-forms":"nplurals=3; plural=((n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2));"}," and ":[" y "]," or ":[" o "],"\"%s\" activated.":["Se ha activado «%s»."],"\"%s\" deactivated.":["Se ha desactivado «%s»."],"\"%s\" has been restored.":["«%s» se ha restaurado."],"\"%s\" moved to the trash.":["«%s» trasladado a la papelera."],"\"%s\" permanently deleted.":["«%s» borrado permanentemente."],"\"%s\" successfully created.":["«%s» creado correctamente."],"\"%s\" uploaded.":["«%s» subido."],"%(field)s address":["Dirección de %(field)s"],"%(field)s cannot move inside itself. Pick a container that is not one of its own.":["%(field)s no puede moverse dentro de sí mismo. Elige un contenedor que no sea uno de los suyos."],"%(field)s declared.":["%(field)s declarado."],"%(field)s deleted.":["%(field)s borrado."],"%(field)s does not match the format its field asks for. Check it and save again.":["%(field)s no tiene el formato que su campo requiere. Revísalo y guarda de nuevo."],"%(field)s goes no higher than %(limit)s. Lower the value and save again.":["%(field)s no pasa de %(limit)s. Baja el valor y guarda de nuevo."],"%(field)s goes no lower than %(limit)s. Raise the value and save again.":["%(field)s no baja de %(limit)s. Sube el valor y guarda de nuevo."],"%(field)s holds no value of its own, so it cannot be required. Make it optional.":["%(field)s no guarda ningún valor propio, así que no puede ser obligatorio. Hazlo opcional."],"%(field)s is optional again.":["%(field)s vuelve a ser opcional."],"%(field)s is required now.":["%(field)s ahora es obligatorio."],"%(field)s moved.":["%(field)s movido."],"%(field)s names the same one twice. Remove the repeat and save again.":["%(field)s nombra el mismo dos veces. Quita la repetición y guarda de nuevo."],"%(field)s needs at least %(limit)s files. Add one and save again.":["%(field)s necesita al menos %(limit)s archivos. Añade uno y guarda de nuevo."],"%(field)s needs at least %(limit)s rows. Add one and save again.":["%(field)s necesita al menos %(limit)s filas. Añade una y guarda de nuevo."],"%(field)s only takes one of its listed choices. Pick one and save again.":["%(field)s solo admite una de las opciones de su lista. Elige una y guarda de nuevo."],"%(field)s reads a relation field that is not there. Pick a group and a relation field it holds.":["%(field)s lee un campo de relación que no existe. Elige un grupo y un campo de relación que contenga."],"%(field)s reads a relation pointing at %(type)s, so it would list nothing here. Pick a relation pointing at the items this group is shown on.":["%(field)s lee una relación que apunta a %(type)s, así que aquí no mostraría nada. Elige una relación que apunte a los elementos donde se muestra este grupo."],"%(field)s renamed.":["%(field)s renombrado."],"%(field)s settled.":["%(field)s ajustado."],"%(field)s shows by its rules now.":["%(field)s ahora se muestra según sus reglas."],"%(field)s stands in the list.":["%(field)s aparece en la lista."],"%(field)s stays out of the list.":["%(field)s se queda fuera de la lista."],"%(field)s takes at most %(limit)s files. Remove one and save again.":["%(field)s acepta como mucho %(limit)s archivos. Quita uno y guarda de nuevo."],"%(field)s takes at most %(limit)s letters. Shorten what you wrote and save again.":["%(field)s acepta como mucho %(limit)s letras. Acorta lo que has escrito y guarda de nuevo."],"%(field)s takes at most %(limit)s rows. Remove one and save again.":["%(field)s admite como mucho %(limit)s filas. Quita una y guarda de nuevo."],"%(field)s title":["Título de %(field)s"],"%(file)s is larger than one upload may be.":["%(file)s es más grande de lo que una subida permite."],"%(file)s is not a file this block accepts.":["%(file)s no es un archivo que este bloque acepte."],"%(file)s: %(reason)s":["%(file)s: %(reason)s"],"%(group)s created.":["%(group)s creado."],"%(group)s now appears where you said.":["%(group)s ya aparece donde has dicho."],"%(group)s removed.":["%(group)s eliminado."],"%(group)s updated.":["%(group)s actualizado."],"%(key)s is served by %(winner)s, so %(loser)s does not show it on %(where)s.":["%(key)s lo sirve %(winner)s, así que %(loser)s no lo muestra en %(where)s."],"%(name)s %(version)s is serving the public site.":["%(name)s %(version)s está publicado en la web."],"%(name)s belongs to the site now.":["%(name)s ya pertenece al sitio."],"%(name)s came from the %(plugin)s plugin, which no longer declares it.":["%(name)s vino del plugin %(plugin)s, que ya no lo declara."],"%(name)s cannot be used as a theme name. Name the zip file after the theme, with no dot at the start and no slashes in it.":["%(name)s no se puede usar como nombre de tema. Nombra el archivo zip como el tema, sin punto al principio y sin barras."],"%(name)s moves to /%(word)s. Every address of both types changes.":["El tipo %(name)s pasa a /%(word)s. Cambian todas las direcciones de ambos tipos."],"%(name)s would move to /%(word)s, which Gophenberg keeps for itself. Change its plural name and try again.":["El tipo %(name)s pasaría a /%(word)s, que Gophenberg reserva para sí mismo. Cambia su nombre en plural e inténtalo de nuevo."],"%(source)s is %(value)s":["%(source)s es %(value)s"],"%(theme)s failed to start, so the built-in renderer is serving.":["%(theme)s no pudo arrancar, así que el renderizador ha tomado el relevo."],"%(theme)s is now serving the public site.":["%(theme)s ya sirve el sitio público."],"%(theme)s is serving the public site.":["%(theme)s sirve el sitio público."],"%(theme)s is still starting, so the built-in renderer is serving.":["%(theme)s todavía está arrancando, así que el renderizador ha tomado el relevo."],"%(theme)s was installed.":["%(theme)s se ha instalado."],"%(theme)s will not load, so the built-in renderer is serving.":["%(theme)s no se carga, así que el renderizador ha tomado el relevo."],"%(type)s answers on a new address, and stored links move with it.":["%(type)s responde en una dirección nueva, y los enlaces guardados se mueven con ella."],"%(type)s becomes the type the site opens on, and the old one takes an address.":["%(type)s pasa a ser el tipo con el que abre el sitio, y el anterior toma una dirección."],"%(type)s keeps nesting while %(items)s of its items sit inside another. Move those to the top level first, the ones in the trash included.":["%(type)s sigue anidando mientras %(items)s de sus elementos están dentro de otro. Muévelos primero al nivel superior, incluidos los de la papelera."],"%(type)s keeps nesting, because some of its items sit inside another.":["%(type)s sigue anidando, porque algunos de sus elementos están dentro de otro."],"%(type)s will answer at the root.":["El tipo %(type)s responderá en la raíz."],"%s content type activated.":["Se ha activado %s tipo de contenido.","Se han activado %s de tipos de contenido.","Se han activado %s tipos de contenido."],"%s content type could not be activated.":["No se ha podido activar %s tipo de contenido.","No se han podido activar %s de tipos de contenido.","No se han podido activar %s tipos de contenido."],"%s content type could not be deactivated.":["No se ha podido desactivar %s tipo de contenido.","No se han podido desactivar %s de tipos de contenido.","No se han podido desactivar %s tipos de contenido."],"%s content type could not be permanently deleted.":["No se ha podido borrar permanentemente %s tipo de contenido.","No se han podido borrar permanentemente %s de tipos de contenido.","No se han podido borrar permanentemente %s tipos de contenido."],"%s content type deactivated.":["Se ha desactivado %s tipo de contenido.","Se han desactivado %s de tipos de contenido.","Se han desactivado %s tipos de contenido."],"%s content type permanently deleted.":["Se ha borrado permanentemente %s tipo de contenido.","Se han borrado permanentemente %s de tipos de contenido.","Se han borrado permanentemente %s tipos de contenido."],"%s item could not be moved to the trash.":["No se ha podido mover %s elemento a la papelera.","No se han podido mover %s de elementos a la papelera.","No se han podido mover %s elementos a la papelera."],"%s item could not be permanently deleted.":["No se ha podido borrar permanentemente %s elemento.","No se han podido borrar permanentemente %s de elementos.","No se han podido borrar permanentemente %s elementos."],"%s item could not be restored.":["No se ha podido restaurar %s elemento.","No se han podido restaurar %s de elementos.","No se han podido restaurar %s elementos."],"%s item moved to the trash.":["%s elemento movido a la papelera.","%s de elementos movidos a la papelera.","%s elementos movidos a la papelera."],"%s item permanently deleted.":["%s elemento borrado permanentemente.","%s de elementos borrados permanentemente.","%s elementos borrados permanentemente."],"%s item stays in the trash.":["%s elemento sigue en la papelera.","%s de elementos siguen en la papelera.","%s elementos siguen en la papelera."],"%s page has been restored.":["Se ha restaurado %s página.","Se han restaurado %s de páginas.","Se han restaurado %s páginas."],"%s post has been restored.":["Se ha restaurado %s entrada.","Se han restaurado %s de entradas.","Se han restaurado %s entradas."],"(no title)":["(sin título)"],"A %(kind)s field does not take a setting called %(setting)s. Remove it and try again.":["Un campo %(kind)s no acepta un ajuste llamado %(setting)s. Quítalo y prueba de nuevo."],"A Linked from field stands on its own, not inside a section or a repeater. Declare %(field)s beside the container.":["Un campo Enlazado desde va por su cuenta, no dentro de una sección ni de un repetidor. Declara %(field)s junto al contenedor."],"A content type needs a singular and a plural name, like Guide and Guides. Fill both in and try again.":["Un tipo de contenido necesita un nombre en singular y otro en plural, como Guía y Guías. Rellena ambos e inténtalo de nuevo."],"A descriptive summary of the content type.":["Un resumen descriptivo del tipo de contenido."],"A field group key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the group in the file so its key comes out in that shape.":["La clave de un grupo de campos tiene que empezar por una letra minúscula y después llevar solo letras minúsculas, números y guiones. Cambia el nombre del grupo en el archivo para que su clave salga con esa forma."],"A field key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the field so its key comes out in that shape.":["La clave de un campo tiene que empezar por una letra minúscula y contener solo letras minúsculas, números y guiones. Renombra el campo para que su clave tenga esa forma."],"A field needs a name. Type one and try again.":["El campo necesita un nombre. Escribe uno e inténtalo de nuevo."],"A field with that name already exists on the items this group reaches. Pick another name.":["Ya existe un campo con ese nombre en los elementos que este grupo alcanza. Elige otro nombre."],"A flexible content field holds layouts, and %(field)s is not one. Add a layout first, then declare the field inside that layout.":["Un campo de contenido flexible contiene diseños, y %(field)s no lo es. Añade primero un diseño y después declara el campo dentro de ese diseño."],"A higher quality stores larger files, and it applies to pictures uploaded from now on.":["Una calidad más alta almacena archivos más grandes, y se aplica a las imágenes que subas a partir de ahora."],"A layout stands inside a flexible content field only. Put %(field)s inside one of those instead.":["Un diseño solo va dentro de un campo de contenido flexible. Pon %(field)s dentro de uno de ellos."],"A layout stands inside a flexible content field, never on its own. Declare a flexible content field first, then add %(field)s inside it.":["Un diseño va dentro de un campo de contenido flexible, nunca por su cuenta. Declara primero un campo de contenido flexible y después añade %(field)s dentro."],"A listing carries between 1 and %(max)s items, so %(value)s is not a size Gophenberg takes. Pick a number inside that range.":["Un listado muestra entre 1 y %(max)s elementos, así que %(value)s no es un tamaño que Gophenberg acepte. Elige un número dentro de ese rango."],"A relation field has to say which content type it points at. Pick that type and try again.":["Un campo de relación tiene que indicar a qué tipo de contenido apunta. Elige ese tipo e inténtalo de nuevo."],"A reload shows the admin in the language you chose.":["Al recargar verás el panel de administración en el idioma que has elegido."],"A route word has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change it and try again.":["La palabra de ruta de la URL tiene que empezar por una letra minúscula y contener solo letras minúsculas, números y guiones. Cámbiala e inténtalo de nuevo."],"A row of %(field)s has to name the layout it takes. Pick a layout for the row, or remove the row.":["Una fila de %(field)s tiene que nombrar el diseño que toma. Elige un diseño para la fila, o quita la fila."],"A row of %(field)s takes one layout and no more. Reload the page and build the row again.":["Una fila de %(field)s toma un solo diseño. Recarga la página y crea la fila otra vez."],"A rule matching any %(source)s cannot be turned into an exclusion. Leave it as is, or name the ones to leave out instead.":["Una regla que vale para cualquier %(source)s no se puede convertir en exclusión. Déjala como está, o nombra los que quieras dejar fuera."],"A theme naming its own page size uses that instead.":["Un tema que indica su propio tamaño de página usa ese en su lugar."],"A type key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change the key and try again.":["La clave de un tipo tiene que empezar por una letra minúscula y contener solo letras minúsculas, números y guiones. Cambia la clave e inténtalo de nuevo."],"Actions":["Acciones"],"Activate":["Activar"],"Activate %(theme)s":["Activar %(theme)s"],"Active":["Activo"],"Add":["Añadir"],"Add %(layout)s":["Añadir %(layout)s"],"Add %(name)s":["Añadir %(name)s"],"Add %(type)s":["Añadir %(type)s"],"Add Category":["Añadir categoría"],"Add Content Type":["Añadir tipo de contenido"],"Add Field Group":["Añadir grupo de campos"],"Add Media File":["Añadir medios"],"Add Page":["Añadir página"],"Add Post":["Añadir entrada"],"Add User":["Añadir usuario"],"Add block":["Añadir bloque"],"Add choice":["Añadir opción"],"Add condition":["Añadir condición"],"Add field":["Añadir campo"],"Add field to %(field)s":["Añadir campo a %(field)s"],"Add layout":["Añadir diseño"],"Add layout to %(field)s":["Añadir diseño a %(field)s"],"Add one with %(button)s.":["Añade un elemento con %(button)s."],"Add row":["Añadir fila"],"Add rule set":["Añadir conjunto de reglas"],"Add title":["Añadir título"],"Add to %(field)s":["Añadir a %(field)s"],"Address":["Dirección"],"Address updated.":["Dirección actualizada."],"Adopt":["Adoptar"],"Allow custom":["Permitir valores propios"],"Allow empty":["Permitir vacío"],"Alt text":["Texto alternativo"],"An item cannot point at itself. Pick a different item for %(field)s.":["Un elemento no puede apuntarse a sí mismo. Elige otro elemento para %(field)s."],"An item cannot sit inside itself, or inside one of the items nested under it. Pick a parent outside this branch.":["Un elemento no puede estar dentro de sí mismo, ni dentro de otro anidado bajo él. Elige un nivel superior fuera de esta rama."],"Another account wrote this, and only its author, an editor or an administrator may change it. You can still read it.":["Otra cuenta escribió esto, y solo su autor, un editor o un administrador pueden cambiarlo. Todavía puedes leerlo."],"Another account wrote this, so you are reading it rather than editing it.":["Otra cuenta escribió esto, así que lo estás leyendo, no editando."],"Another content type already answers under this route word. Pick a different word.":["Ya hay otro tipo de contenido que responde con esta palabra de ruta de URL. Elige una palabra distinta."],"Another content type already lives at the root of your site, and only one can. Give this type a route word, or hand the root over with Make default.":["Ya hay otro tipo de contenido en la raíz de tu sitio, y solo puede haber uno. Dale a este tipo de contenido una palabra de ruta de URL, o cede la raíz con Establecer como predeterminado."],"Another content type already uses this key. Give this type a different name.":["Ya hay otro tipo de contenido que usa esta clave. Dale a este tipo un nombre distinto."],"Another item of this content type already uses that address. Pick a different slug and save again.":["Ya hay otro elemento de este tipo de contenido que usa esa dirección. Elige un slug distinto y guarda de nuevo."],"Another update is currently in progress.":["En este momento hay otra actualización en marcha."],"Appears on":["Aparece en"],"Apply":["Aplicar"],"April":["abril"],"Are you sure you want to move \"%s\" to the trash?":["¿Seguro que quieres mover «%s» a la papelera?"],"Audio":["Audio"],"August":["agosto"],"Author":["Autor"],"Back to %(type)s":["Volver a %(type)s"],"Back to the latest posts":["Volver a las últimas entradas"],"Block":["Bloque"],"Broken":["Roto"],"Bundles of fields, shown where their rules place them.":["Conjuntos de campos, mostrados donde sus reglas los colocan."],"Button group":["Grupo de botones"],"Cancel":["Cancelar"],"Caption":["Leyenda"],"Change %(name)s":["Cambiar %(name)s"],"Change address":["Cambiar la dirección"],"Change address…":["Cambiar la dirección…"],"Change role":["Cambiar el rol"],"Change the role of %(name)s":["Cambiar el rol de %(name)s"],"Checkbox group":["Grupo de casillas"],"Choices":["Opciones"],"Choose %(field)s":["Elegir %(field)s"],"Choose how the site serves its content.":["Elige cómo el sitio sirve su contenido."],"Choose the language the admin reads in.":["Elige el idioma en el que se lee el panel de administración."],"Choose the role of %(name)s.":["Elige el rol de %(name)s."],"Choose the version to update to.":["Elige la versión a la que actualizar."],"Clear %(field)s":["Vaciar %(field)s"],"Close":["Cerrar"],"Close Settings":["Cerrar ajustes"],"Closed":["Cerrado"],"Condition":["Condición"],"Content":["Contenido"],"Content Types":["Tipos de contenido"],"Content nests at most %(max)s levels deep, and this item would sit deeper. Pick a parent closer to the top.":["El contenido se anida como mucho %(max)s niveles, y este elemento quedaría más abajo. Elige un nivel superior más cercano al inicio."],"Content type":["Tipo de contenido"],"Content type updated.":["Tipo de contenido actualizado."],"Could not delete that item.":["No se ha podido borrar ese elemento.","No se han podido borrar todos los elementos.","No se han podido borrar todos los elementos."],"Could not load that post.":["No se ha podido cargar esa entrada."],"Could not save that post.":["No se ha podido guardar esa entrada."],"Create":["Crear"],"Create a field group":["Crear un grupo de campos"],"Create user":["Crear usuario"],"Created":["Fecha de creación"],"Date added":["Fecha de subida"],"Deactivate":["Desactivar"],"Deactivate %(theme)s":["Desactivar %(theme)s"],"December":["diciembre"],"Default":["Por defecto"],"Default content type updated.":["Tipo de contenido predeterminado actualizado."],"Definitions file":["Archivo de definiciones"],"Delete":["Borrar"],"Delete \"%(title)s\" for good? This cannot be undone.":["¿Borrar permanentemente «%(title)s»? Esto no se puede deshacer."],"Delete %(field)s":["Borrar %(field)s"],"Delete %(group)s":["Eliminar %(group)s"],"Delete %(title)s for good? This cannot be undone.":["¿Borrar %(title)s para siempre? Esto no se puede deshacer."],"Delete %s content type for good? This cannot be undone.":["¿Borrar permanentemente %s tipo de contenido? Esto no se puede deshacer.","¿Borrar permanentemente %s de tipos de contenido? Esto no se puede deshacer.","¿Borrar permanentemente %s tipos de contenido? Esto no se puede deshacer."],"Delete %s item for good? This cannot be undone.":["¿Borrar permanentemente %s elemento? Esto no se puede deshacer.","¿Borrar permanentemente %s de elementos? Esto no se puede deshacer.","¿Borrar permanentemente %s elementos? Esto no se puede deshacer."],"Delete Permanently":["Borrar permanentemente"],"Delete the field":["Borrar el campo"],"Delete the group":["Eliminar el grupo"],"Delete this %(count)s item for good? This cannot be undone.":["¿Borrar este %(count)s elemento para siempre? Esto no se puede deshacer.","¿Borrar estos %(count)s elementos para siempre? Esto no se puede deshacer.","¿Borrar estos %(count)s elementos para siempre? Esto no se puede deshacer."],"Delete…":["Borrar…"],"Describe":["Describir"],"Describe…":["Describir…"],"Description":["Descripción"],"Description updated.":["Descripción actualizada."],"Desktop":["Escritorio"],"Destination":["Destino"],"Dimensions":["Dimensiones"],"Disable":["Desactivar"],"Disable %(name)s":["Desactivar a %(name)s"],"Disabled":["Desactivado"],"Dismiss":["Descartar"],"Document":["Documento"],"Done. The site now holds what the file describes.":["Hecho. El sitio ya tiene lo que describe el archivo."],"Draft saved.":["Borrador guardado."],"Drop files to upload":["Arrastra los archivos para subirlos"],"Email":["Correo electrónico"],"Empty Trash":["Vaciar papelera"],"Empty trash":["Vaciar papelera"],"Enable":["Activar"],"Enable %(name)s":["Activar a %(name)s"],"Every address of this type moves. Links kept elsewhere to the old addresses stop working.":["Cambian todas las direcciones de este tipo. Los enlaces guardados en otro sitio hacia las direcciones antiguas dejan de funcionar."],"Every content type":["Todos los tipos de contenido"],"Every field group needs a title. Give it a name and save again.":["Todo grupo de campos necesita un título. Ponle un nombre y guarda otra vez."],"Every item needs an author who exists. Pick one from the list and save again.":["Cada elemento necesita un autor que exista. Elige uno de la lista y guarda de nuevo."],"Every kind of content this site holds.":["Todos los tipos de contenido que hay en este sitio."],"Every value stored under this field goes with it, in every item the group reaches and in the revisions behind them.":["Todos los valores guardados en este campo se van con él, en cada elemento que el grupo alcanza y en las revisiones que hay detrás."],"Excerpt":["Extracto"],"Export definitions":["Exportar definiciones"],"February":["febrero"],"Fewest files":["Mínimo de archivos"],"Fewest rows":["Mínimo de filas"],"Field":["Campo"],"Field Groups":["Grupos de campos"],"Field group":["Grupo de campos"],"Fields":["Campos"],"Fields of %(group)s":["Campos de %(group)s"],"File name":["Nombre del archivo"],"File size":["Tamaño del archivo"],"File type":["Tipo de archivo"],"Files":["Archivos"],"Filter by status":["Filtrar por estado"],"Follow the browser":["Sigue al navegador"],"From %(plugin)s":["De %(plugin)s"],"Full Size":["Tamaño completo"],"Gophenberg cannot start this update: %(reason)s":["Gophenberg no puede empezar esta actualización: %(reason)s"],"Gophenberg checked for updates moments ago. Try again later.":["Gophenberg ha buscado actualizaciones hace un momento. Inténtalo de nuevo más tarde."],"Gophenberg could not read what your browser sent. Reload the page and try again, and tell whoever looks after your site if it keeps happening.":["Gophenberg no ha podido leer lo que ha enviado tu navegador. Recarga la página e inténtalo de nuevo, y avisa a quien gestiona tu sitio si sigue pasando."],"Gophenberg could not read which item you picked as parent. Reload the page and pick it again.":["Gophenberg no ha podido leer qué elemento has elegido como elemento superior. Recarga la página y vuelve a elegirlo."],"Gophenberg does not accept %(extension)s files. Upload a picture, a video, an audio file, a PDF or a zip instead.":["Gophenberg no acepta archivos %(extension)s. Sube una imagen, un vídeo, un audio, un PDF o un zip."],"Gophenberg does not answer in %(value)s. Pick one of the languages on the list.":["Gophenberg no responde en %(value)s. Elige uno de los idiomas de la lista."],"Gophenberg has no autosave of yours for this item. Open the item to see the version that is stored.":["Gophenberg no tiene ningún guardado automático tuyo de este elemento. Ábrelo para ver la versión guardada."],"Gophenberg holds no kind of definition called %(subject)s, so nothing was taken over. Reload the page so the admin and the server agree again.":["Gophenberg no tiene ninguna clase de definición llamada %(subject)s, así que no se ha adoptado nada. Recarga la página para que el admin y el servidor vuelvan a coincidir."],"Gophenberg keeps /%(path)s for itself or for another content type, so your content cannot answer there. Give the item a different slug.":["Gophenberg reserva la dirección /%(path)s para sí mismo o para otro tipo de contenido, así que tu contenido no puede usarla. Dale al elemento otro slug."],"Gophenberg keeps this word for itself, so no content type can answer under it. Pick a different route word.":["Gophenberg reserva esta palabra para sí mismo, así que ningún tipo de contenido puede usarla. Elige otra palabra de ruta de URL."],"Group":["Grupo"],"Highest":["Máximo"],"Holds":["Contiene"],"Images":["Imágenes"],"Import definitions":["Importar definiciones"],"In list":["En la lista"],"Inactive":["Inactivo"],"Installed":["Instalado"],"Instructions":["Instrucciones"],"It moves here from another group and keeps its stored values.":["Se mueve aquí desde otro grupo y conserva sus valores guardados."],"It moves to another group and keeps its stored values.":["Se mueve a otro grupo y conserva sus valores guardados."],"It moves to another group, so its stored values do not follow.":["Se mueve a otro grupo, así que sus valores guardados no le siguen."],"It reads a relation inside a container, and only an import can point it elsewhere.":["Lee una relación dentro de un contenedor, y solo una importación puede apuntarlo a otro sitio."],"It starts on %(type)s, and the rules may be edited afterwards.":["Empieza en %(type)s, y las reglas se pueden editar después."],"Items could not be loaded.":["No se han podido cargar los elementos."],"Its items sit inside one another, so it keeps nesting.":["Sus elementos están unos dentro de otros, así que sigue anidando."],"Its kind changed, so its stored values go with it.":["Su tipo ha cambiado, así que sus valores guardados se van con él."],"January":["enero"],"July":["julio"],"June":["junio"],"Keep %(field)s out of the list":["Dejar %(field)s fuera de la lista"],"Keep it":["Conservarlo"],"Keep it here":["Dejarlo aquí"],"Key":["Clave"],"Label":["Etiqueta"],"Language":["Idioma"],"Large":["Grande"],"Let items nest":["Permitir anidar"],"Linked from":["Enlazado desde"],"List view":["Vista de lista"],"Loading field groups.":["Cargando los grupos de campos."],"Loading the post.":["Cargando la entrada."],"Loading the screen.":["Cargando la pantalla."],"Loading themes.":["Cargando los temas."],"Loading users.":["Cargando los usuarios."],"Loading…":["Cargando…"],"Longest":["Largo máximo"],"Lowest":["Mínimo"],"Make %(field)s optional":["Hacer %(field)s opcional"],"Make default":["Establecer como predeterminado"],"Make default…":["Establecer como predeterminado…"],"Make optional":["Hacer opcional"],"Manage the categories on this site.":["Gestiona las categorías de este sitio."],"Manage the images and files this site holds.":["Gestiona las imágenes y los archivos que hay en este sitio."],"Manage the items of this content type.":["Gestiona los elementos de este tipo de contenido."],"Manage the pages on this site.":["Gestiona las páginas de este sitio."],"Manage the posts on this site.":["Gestiona las entradas de este sitio."],"Many targets":["Varios destinos"],"Many values":["Varios valores"],"March":["marzo"],"May":["mayo"],"Media %(id)s":["Medio %(id)s"],"Media Library":["Biblioteca de medios"],"Medium":["Mediano"],"Mobile":["Móvil"],"Modified: %s":["Modificado: %s"],"Most files":["Máximo de archivos"],"Most rows":["Máximo de filas"],"Move":["Mover"],"Move \"%(title)s\" to the trash?":["¿Mover «%(title)s» a la papelera?"],"Move %(field)s down":["Bajar %(field)s"],"Move %(field)s elsewhere":["Mover %(field)s a otro sitio"],"Move %(field)s up":["Subir %(field)s"],"Move %(group)s down":["Bajar %(group)s"],"Move %(group)s up":["Subir %(group)s"],"Move %(name)s down":["Bajar %(name)s"],"Move %(name)s up":["Subir %(name)s"],"Move %s item to the trash?":["¿Mover %s elemento a la papelera?","¿Mover %s de elementos a la papelera?","¿Mover %s elementos a la papelera?"],"Move every address":["Mover todas las direcciones"],"Move row down":["Bajar la fila"],"Move row up":["Subir la fila"],"Move the field":["Mover el campo"],"Move to trash":["Mover a la papelera"],"Name":["Nombre"],"Name updated.":["Nombre actualizado."],"Nests":["Anida"],"Newer posts":["Entradas más recientes"],"No":["No"],"No content types found.":["No se han encontrado tipos de contenido."],"No description":["Sin descripción"],"No file arrived with this upload. Choose a file and try again.":["No ha llegado ningún archivo con esta subida. Elige un archivo e inténtalo de nuevo."],"No items found.":["No se han encontrado elementos."],"No items yet.":["Todavía no hay elementos."],"No media chosen":["Ningún medio elegido"],"No media has been uploaded yet.":["Todavía no se ha subido ningún medio."],"No media items found.":["No se han encontrado archivos de medios."],"No media items yet.":["Todavía no hay archivos de medios."],"No page lives at this address.":["No existe ninguna página en esta dirección."],"No parent":["Sin elemento superior"],"No theme arrived with this upload. Choose a zip file and try again.":["No ha llegado ningún tema con esta subida. Elige un archivo zip e inténtalo de nuevo."],"No themes are installed.":["No hay ningún tema instalado."],"No title":["Sin título"],"None":["Ninguno"],"Not %(type)s":["Excepto %(type)s"],"Not found":["No encontrado"],"Not serving":["No está sirviendo"],"Nothing declares that role.":["Nada declara ese rol."],"Nothing points here yet.":["Todavía no apunta nada aquí."],"Nothing published here yet.":["Todavía no hay nada publicado aquí."],"Nothing published yet.":["Todavía no hay nada publicado."],"November":["noviembre"],"Nowhere":["En ningún sitio"],"Number":["Número"],"October":["octubre"],"Older posts":["Entradas anteriores"],"One frame of this animation holds more than %(max)s pixels, which is more than Gophenberg opens. Upload a smaller animation.":["Un fotograma de esta animación tiene más de %(max)s píxeles, más de lo que Gophenberg abre. Sube una animación más pequeña."],"One of the filters on this list is not one Gophenberg understands. Clear the filters and try again.":["Uno de los filtros de esta lista no es un filtro que Gophenberg entienda. Quita los filtros e inténtalo de nuevo."],"One of the items picked for %(field)s is not the kind of content that field points at. Reload the page and pick from the list the field offers.":["Uno de los elementos elegidos para %(field)s no es del tipo de contenido al que apunta ese campo. Recarga la página y elige de la lista que ofrece el campo."],"One of the items picked for %(field)s was not named in a way Gophenberg understands. Reload the page and pick the items again.":["Uno de los elementos elegidos para %(field)s no venía nombrado de una forma que Gophenberg entienda. Recarga la página y vuelve a elegir los elementos."],"One of the items picked no longer exists. Reload the page and pick again.":["Uno de los elementos elegidos ya no existe. Recarga la página y vuelve a elegir."],"One target":["Un destino"],"Only a relation field points at another content type or holds many items. Pick the Relation kind, or leave the target and the many option empty.":["Solo un campo de relación apunta a otro tipo de contenido o guarda varios elementos. Elige el tipo Relación, o deja vacío el destino y la opción de varios."],"Only one file may be placed here.":["Aquí solo se puede colocar un archivo."],"Only the content type at the root of your site may go without a route word. Give this type one and try again.":["Solo el tipo de contenido que está en la raíz de tu sitio puede ir sin palabra de ruta de URL. Dale una a este tipo e inténtalo de nuevo."],"Open %(field)s in a new tab":["Abrir %(field)s en una pestaña nueva"],"Order stored.":["Orden guardado."],"Other":["Otro"],"Out of list":["Fuera de la lista"],"Parent":["Elemento superior"],"Password":["Contraseña"],"Pending":["Pendiente"],"Pending Review":["Pendiente de revisión"],"Permanently delete":["Borrar permanentemente"],"Permanently delete…":["Borrar permanentemente…"],"Picture quality":["Calidad de imagen"],"Picture quality runs from 1 to %(max)s, so %(value)s is not a quality Gophenberg stores at. Pick a number inside that range.":["La calidad de imagen va de 1 a %(max)s, así que %(value)s no es una calidad con la que Gophenberg guarde. Elige un número dentro de ese rango."],"Placeholder":["Texto de ejemplo"],"Plural name":["Nombre en plural"],"Points at":["Apunta a"],"Post published.":["Entrada publicada."],"Posts per page":["Entradas por página"],"Preview":["Vista previa"],"Private":["Privada"],"Publish":["Publicar"],"Published: %s":["Publicado: %s"],"Radio group":["Grupo de botones de radio"],"Reads from":["Lee de"],"Redo":["Rehacer"],"Relation":["Relación"],"Remove":["Quitar"],"Remove %(name)s":["Quitar %(name)s"],"Remove %(title)s":["Quitar %(title)s"],"Remove row":["Quitar fila"],"Rename":["Renombrar"],"Rename %(field)s":["Renombrar %(field)s"],"Rename…":["Renombrar…"],"Require":["Hacer obligatorio"],"Require %(field)s":["Hacer %(field)s obligatorio"],"Required":["Obligatorio"],"Restore":["Restaurar"],"Role changed.":["Rol cambiado."],"Roll back to %(theme)s":["Revertir a %(theme)s"],"Roll back to the built-in renderer":["Revertir al renderizador integrado"],"Route word":["Palabra de ruta"],"Rule %(number)s of set %(set)s":["Regla %(number)s del conjunto %(set)s"],"Rule set %(number)s":["Conjunto de reglas %(number)s"],"Rules":["Reglas"],"Rules showing %(field)s":["Reglas que muestran %(field)s"],"Save":["Guardar"],"Save draft":["Guardar el borrador"],"Save rules":["Guardar las reglas"],"Save settings":["Guardar los ajustes"],"Saved":["Guardado"],"Saving":["Guardando"],"Scheduled":["Programada"],"Scheduled: %s":["Programado: %s"],"Search %(type)s…":["Buscar %(type)s…"],"Search audio":["Buscar audios"],"Search images":["Buscar imágenes"],"Search media":["Buscar medios"],"Search videos":["Buscar vídeos"],"Select":["Seleccionar"],"September":["septiembre"],"Serving":["Atendiendo"],"Settings":["Ajustes"],"Settings of %(field)s":["Ajustes de %(field)s"],"Shadowed":["Eclipsado"],"Show %(field)s in the list":["Mostrar %(field)s en la lista"],"Showing %(held)s of %(total)s.":["Mostrando %(held)s de %(total)s."],"Singular name":["Nombre en singular"],"Slug":["Slug"],"Somebody must be able to manage users.":["Alguien tiene que poder gestionar los usuarios."],"Someone else changed this media item while you were editing it. Reload the page and make your change again.":["Otra persona ha cambiado este medio mientras lo editabas. Recarga la página y vuelve a hacer tu cambio."],"Someone else saved changes to this item while you were editing, so yours were not saved. Copy your text somewhere safe, then reload the item and put your changes back in.":["Otra persona ha guardado cambios en este elemento mientras editabas, así que los tuyos no se han guardado. Copia tu texto a un lugar seguro, recarga el elemento y vuelve a colocarlos de vuelta."],"Something went wrong inside Gophenberg. Try again in a moment, and tell whoever looks after your site if it keeps happening.":["Algo ha fallado dentro de Gophenberg. Inténtalo de nuevo en un momento, y avisa a quien gestione tu sitio si sigue pasando."],"Something went wrong.":["Algo ha ido mal."],"Something went wrong. Try again.":["Algo ha ido mal. Inténtalo de nuevo."],"Sorry, you are not allowed to access this page.":["Lo siento, no tienes permisos para acceder a esta página."],"Source":["Origen"],"Steps of":["Pasos de"],"Stop nesting":["Dejar de anidar"],"Tablet":["Tableta"],"Text":["Texto"],"Text area":["Área de texto"],"That email is already in use.":["Ese correo electrónico ya está en uso."],"That field group no longer exists. Reload the page to see the groups your site holds now.":["Ese grupo de campos ya no existe. Recarga la página para ver los grupos que tiene ahora tu sitio."],"That field is not on this content type any more. Reload the page to see the fields it has now.":["Ese campo ya no está en este tipo de contenido. Recarga la página para ver los campos que tiene ahora."],"That revision does not exist, and older ones drop off once a type holds more than it keeps. Open the item to see which revisions are left.":["Esa revisión no existe, y las más antiguas se pierden cuando un tipo guarda más de las que conserva. Abre el elemento para ver qué revisiones quedan."],"That role holds more than your own.":["Ese rol abarca más que el tuyo."],"That web address does not name a media item Gophenberg can look up. Open the media library and pick the item from there.":["Esa dirección web no nombra ningún medio que Gophenberg pueda encontrar. Abre la biblioteca de medios y elige el elemento desde ahí."],"That web address does not name a revision Gophenberg can look up. Open the item and pick a revision from its history.":["Esa dirección web no alude a ninguna revisión que Gophenberg pueda identificar. Abre el elemento y elige una revisión de su historial."],"That web address does not name an item Gophenberg can look up. Go back to the list and open the item from there.":["Esa dirección web no alude a ningún elemento que Gophenberg pueda identificar. Vuelve a la lista y abre el elemento desde ahí."],"The %(count)s item in the trash is deleted for good. This cannot be undone.":["%(count)s elemento de la papelera se borrará permanentemente. Esto no se puede deshacer.","%(count)s de elementos de la papelera se borrarán permanentemente. Esto no se puede deshacer.","Los %(count)s elementos de la papelera se borrarán permanentemente. Esto no se puede deshacer."],"The %(file)s calls this theme %(declared)s, but it is being installed as %(installed)s. Rename the zip file to match, or change the name inside %(file)s.":["El archivo %(file)s llama a este tema %(declared)s, pero se está instalando como %(installed)s. Renombra el zip para que coincida, o cambia el nombre dentro de %(file)s."],"The %(file)s inside %(name)s is not valid JSON, so it could not be read. Fix that file and package the theme again.":["El archivo %(file)s dentro de %(name)s no es JSON válido, así que no se ha podido leer. Corrige ese archivo y vuelve a empaquetar el tema."],"The %(file)s inside %(name)s names its theme kit as %(declared)s, which is not a version number. Build the theme again, and the build will fill that in.":["El %(file)s dentro de %(name)s identifica su kit de temas como %(declared)s, que no srive como número de versión. Compila el tema de nuevo y la compilación lo rellenará."],"The %(origin)s plugin declared this, so it can only be changed in that plugin. Turning it off is still yours to do.":["El plugin %(origin)s lo declaró, así que solo se puede cambiar en ese plugin. Desactivarlo sigue estando en tu mano."],"The %(plugin)s plugin also declares %(name)s, and the site keeps the key.":["El plugin %(plugin)s también declara %(name)s, y el sitio se queda con la clave."],"The %(plugin)s plugin declared these fields, so they change only in its code.":["El plugin %(plugin)s declaró estos campos, así que solo cambian en su código."],"The %(plugin)s plugin declares %(name)s flat, and the site keeps it nesting while its items sit inside another.":["El plugin %(plugin)s declara %(name)s sin anidar, y el sitio lo mantiene anidando mientras sus elementos están dentro de otro."],"The browser kept an unsaved version of this post.":["El navegador ha guardado una versión sin guardar de esta entrada."],"The built-in renderer is now serving the public site.":["El renderizador integrado ya presta servicio al sitio público."],"The built-in renderer is serving the public site.":["El renderizador integrado presta servicio al sitio público."],"The content type this field points at is not named in a shape Gophenberg accepts. Give it a key that starts with a lowercase letter and then holds only lowercase letters, numbers and hyphens.":["El tipo de contenido al que apunta este campo no está nombrado con un formato que Gophenberg acepte. Dale una clave que empiece por una letra minúscula y contenga solo letras minúsculas, números y guiones."],"The content types could not be loaded, so where these groups appear and which fields they shadow are not shown.":["No se han podido cargar los tipos de contenido, así que no se muestra dónde aparecen estos grupos ni qué campos eclipsan."],"The content types could not be loaded.":["No se han podido cargar los tipos de contenido."],"The content types could not be refreshed, so where these groups appear and what they shadow may be out of date.":["No se han podido actualizar los tipos de contenido, así que dónde aparecen estos grupos y qué eclipsan puede estar desactualizado."],"The draft could not be created.":["No se ha podido crear el borrador."],"The entry %(entry)s in this zip would be written outside the theme folder, so nothing was installed. Package the theme again.":["La entrada %(entry)s de este zip se escribiría fuera de la carpeta del tema, así que no se ha instalado nada. Vuelve a empaquetar el tema."],"The field %(field)s cannot go, because %(by)s reads it. Change %(by)s first.":["El campo %(field)s no se puede quitar, porque %(by)s lo lee. Cambia antes %(by)s."],"The field %(field)s cannot hold the value it was given. Check what kind of field it is and enter a value that matches.":["El campo %(field)s no puede guardar el valor que se le ha dado. Comprueba de qué tipo de campo se trata e introduce un valor que encaje."],"The field %(field)s has to hold a value before this item can be published. Fill it in, or leave the item as a draft.":["El campo %(field)s tiene que tener un valor antes de poder publicar este elemento. Rellénalo, o deja el elemento como borrador."],"The field %(field)s in %(group)s still points at this content type. Delete that field first, then delete this type.":["El campo %(field)s de %(group)s todavía apunta a este tipo de contenido. Borra antes ese campo y luego borra este tipo."],"The field %(field)s is hidden by its own rules right now, so it cannot hold a value. Show it again before filling it in.":["El campo %(field)s está oculto por sus propias reglas ahora mismo, así que no puede guardar ningún valor. Vuelve a mostrarlo antes de rellenarlo."],"The field %(field)s points at one item only. Remove the extra choices and keep one.":["El campo %(field)s apunta a un solo elemento. Quita las opciones de más y deja una."],"The field %(field)s points at other items, so it does not take a plain value. Pick the items it points at instead.":["El campo %(field)s apunta a otros elementos, así que no admite un valor suelto. Elige en su lugar los elementos a los que apunta."],"The field %(field)s takes a list of items, and what arrived was not a list. Reload the page and pick the items again.":["El campo %(field)s admite una lista de elementos, y lo que ha llegado no era una lista. Recarga la página y vuelve a elegir los elementos."],"The field %(source)s cannot hold the value %(value)s, so no rule can compare it. Write a value of the kind %(source)s stores.":["El campo %(source)s no puede guardar el valor %(value)s, así que ninguna regla puede compararlo. Escribe un valor de la clase que %(source)s guarda."],"The field groups could not be loaded.":["No se han podido cargar los grupos de campos."],"The field groups could not be reached.":["No se ha podido contactar con los grupos de campos."],"The field keeps its values on the content both groups reach.":["El campo conserva sus valores en el contenido al que llegan ambos grupos."],"The fields this group holds go with it, and so do the values every item stored in them.":["Los campos que contiene este grupo se van con él, y también los valores que cada elemento guardó en ellos."],"The groups were reordered.":["Los grupos se han reordenado."],"The item could not be duplicated.":["No se ha podido duplicar el elemento."],"The item could not be moved to the trash.":["No se ha podido mover el elemento a la papelera."],"The item could not be permanently deleted.":["No se ha podido borrar permanentemente el elemento."],"The item could not be restored.":["No se ha podido restaurar el elemento."],"The item you picked as parent holds another kind of content. Pick a parent of the same content type as this item.":["El elemento que has elegido como superior tiene otro tipo de contenido. Elige un elemento superior del mismo tipo de contenido que este elemento."],"The item you picked as parent is in the trash. Restore it, or pick another parent.":["El elemento que has elegido como superior está en la papelera. Restáuralo, o elige otro elemento superior."],"The item you picked as parent no longer exists. Reload the page and pick another parent.":["El elemento que has elegido como superior ya no existe. Recarga la página y elige otro."],"The media library could not be loaded.":["No se ha podido cargar la biblioteca de medios."],"The name changes. Nothing stored under this field moves.":["El nombre cambia. Nada de lo guardado en este campo se mueve."],"The name could not be updated.":["No se ha podido actualizar el nombre."],"The new order has to name every field group exactly once. Reload the page and move the groups again.":["El nuevo orden tiene que nombrar cada grupo de campos exactamente una vez. Recarga la página y mueve los grupos otra vez."],"The new order has to name every field of this content type exactly once. Reload the page and move the fields again.":["El nuevo orden tiene que nombrar cada campo de este tipo de contenido exactamente una vez. Recarga la página y mueve los campos de nuevo."],"The number of revisions to keep has to be a whole number, and it cannot be negative. Enter a different number.":["El número de revisiones que se conservan tiene que ser un número entero, y no puede ser negativo. Introduce otro número."],"The registry could not be reached.":["No se ha podido acceder al registro."],"The role could not be changed.":["No se ha podido cambiar el rol."],"The rule on %(source)s names nothing to match. Pick a value, or remove the rule.":["La regla sobre %(source)s no nombra nada con lo que coincidir. Elige un valor, o quita la regla."],"The rules could not be loaded.":["No se han podido cargar las reglas."],"The rules on %(field)s lead back to %(field)s itself, so nothing can decide whether to show it. Point one of them at another field.":["Las reglas de %(field)s vuelven al propio %(field)s, así que nada puede decidir si mostrarlo. Apunta una de ellas a otro campo."],"The same item was picked twice for %(field)s. Pick each item once.":["El mismo elemento se ha elegido dos veces para %(field)s. Elige cada elemento una sola vez."],"The server could not be reached, so nothing was changed.":["No se ha podido acceder al servidor, así que no se ha cambiado nada."],"The server could not be reached, so nothing was saved.":["No se ha podido acceder al servidor, así que no se ha guardado nada."],"The server could not be reached, so nothing was uploaded.":["No se ha podido acceder al servidor, así que no se ha subido nada."],"The setting %(setting)s cannot hold the value it was given. Check what this setting takes and try again.":["El ajuste %(setting)s no puede contener el valor que se le dio. Comprueba qué acepta este ajuste y prueba de nuevo."],"The setting %(setting)s falls outside the others. Keep min below max and the default inside the bounds.":["El ajuste %(setting)s queda fuera de los demás. Mantén min por debajo de max y el valor por defecto dentro de los límites."],"The settings could not be read, so nothing can be changed here yet.":["No se han podido leer los ajustes, así que todavía no se puede cambiar nada aquí."],"The site default":["El idioma por defecto del sitio"],"The site may already hold part of this file. Import it again to see what is left.":["Puede que el sitio ya tenga parte de este archivo. Impórtalo de nuevo para ver lo que queda."],"The source %(source)s does not offer %(operator)s. Pick one of the comparisons it does offer.":["La fuente %(source)s no ofrece %(operator)s. Elige una de las comparaciones que sí ofrece."],"The theme %(name)s carries no %(file)s, so Gophenberg cannot tell what it is. Put that file at the top level of the zip and package the theme again.":["El tema %(name)s no lleva ningún %(file)s, así que Gophenberg no puede saber qué es. Pon ese archivo en el primer nivel del zip y vuelve a empaquetar el tema."],"The theme %(name)s could not be read, so it cannot serve your site. Upload it again, or choose another theme.":["El tema %(name)s no se ha podido leer, así que no puede servir tu sitio. Vuelve a subirlo, o elige otro tema."],"The theme %(name)s did not start, so your site is still served by the theme it had. Ask whoever looks after your site to check the server log for what the theme reported.":["El tema %(name)s no ha arrancado, así que tu sitio lo sigue sirviendo el tema que tenía. Pide a quien gestiona tu sitio que mire en el registro del servidor para saber qué problema ha informado el tema."],"The theme %(name)s does not carry %(path)s as a file, so there is nothing to run. Package the built theme rather than its source.":["El tema %(name)s no lleva %(path)s como archivo, así que no hay nada que ejecutar. Empaqueta el tema ya construido, no su código fuente."],"The theme %(name)s does not carry %(path)s as a folder, so its styles and scripts are missing. Package the built theme rather than its source.":["El tema %(name)s no lleva %(path)s como carpeta, así que le faltan los estilos y los scripts. Empaqueta el tema ya construido, no su código fuente."],"The theme %(name)s holds a link to another file at %(path)s, and a theme may only carry real files. Replace it and package the theme again.":["El tema %(name)s lleva un enlace a otro archivo en %(path)s, y un tema solo puede llevar archivos de verdad. Sustitúyelo y vuelve a empaquetar el tema."],"The theme %(name)s is serving your site right now, so it cannot be replaced. Deactivate it first, or install this copy under another name.":["El tema %(name)s sirve tu sitio ahora mismo, así que no se puede sustituir. Desactívalo primero, o instala esta copia con otro nombre."],"The theme %(name)s is the one chosen, but it is not installed, so your site is served by the built-in renderer. Upload the theme again, or choose another one.":["El tema %(name)s es el elegido, pero no está instalado, así que el renderizador integrado está prestando servicio a tu sitio. Vuelve a subir el tema, o elige otro."],"The theme %(name)s was built on theme kit %(declared)s, and this site serves %(served)s. Build the theme again against a kit this site serves, then install it.":["El tema %(name)s se compiló con el kit de temas %(declared)s y este sitio sirve %(served)s. Compila el tema de nuevo con un kit que este sitio sirva y después instálalo."],"The trash could not be emptied.":["No se ha podido vaciar la papelera."],"The user could not be created.":["No se ha podido crear el usuario."],"The values stored under it do not follow.":["Los valores guardados debajo no le siguen."],"Theme":["Tema"],"Themes":["Temas"],"Themes could not be loaded.":["No se han podido cargar los temas."],"There is no content type called %(value)s. Pick a type your site holds, or register it first.":["No existe ningún tipo de contenido llamado %(value)s. Elige un tipo que tenga tu sitio, o regístralo primero."],"There is no content type called %(value)s. Pick one of the types your site holds and try again.":["No existe ningún tipo de contenido llamado %(value)s. Elige uno de los tipos que tiene tu sitio e inténtalo de nuevo."],"There is no kind of field called %(value)s. Pick one of these instead: %(allowed)s.":["No existe ningún tipo de campo llamado %(value)s. Elige uno de estos: %(allowed)s."],"There is no page kind called %(value)s. Pick one of these instead: %(allowed)s.":["No existe ningún tipo de página llamado %(value)s. Elige uno de estos: %(allowed)s."],"There is no status called %(value)s. Pick one of these instead: %(allowed)s.":["No existe ningún estado llamado %(value)s. Elige uno de estos: %(allowed)s."],"There is nothing at this address. Check the link and try again.":["No hay nada en esta dirección. Comprueba el enlace e inténtalo de nuevo."],"There is nothing to roll back to, because your site has not been through a theme change yet. Install a theme first, then a rollback has somewhere to go.":["No hay nada a lo que retornar, porque tu sitio todavía no ha cambiado de tema. Instala un tema primero y así el retorno tendrá adónde ir."],"These files could not be read.":["No se han podido leer estos archivos."],"These were left alone, because nobody confirmed losing them.":["Estos se han dejado como estaban, porque nadie confirmó perderlos."],"This content type does not exist any more. Reload the page to see the types your site holds now.":["Este tipo de contenido ya no existe. Recarga la página para ver los tipos que tiene ahora tu sitio."],"This content type does not nest, so one of its items cannot sit inside another. Mark the type as Nests first.":["Este tipo de contenido no anida, así que uno de sus elementos no puede estar dentro de otro. Marca el tipo como Anida primero."],"This content type has no field called %(field)s. Check the name, or declare the field on the type first.":["Este tipo de contenido no tiene ningún campo llamado %(field)s. Comprueba el nombre, o declara antes el campo en el tipo."],"This content type is turned off, so its content is not served or edited. Activate it first.":["Este tipo de contenido está desactivado, así que su contenido ni se sirve ni se edita. Actívalo primero."],"This content type still holds content, so it cannot be deleted. Move or delete its items first.":["Este tipo de contenido todavía tiene contenido, así que no se puede borrar. Mueve o borra antes sus elementos."],"This definitions file is larger than the %(max)s bytes Gophenberg reads in one request. Split it, or raise the limit your site runs with.":["Este archivo de definiciones es mayor que los %(max)s bytes que Gophenberg lee en una petición. Divídelo, o sube el límite con el que funciona tu sitio."],"This field holds no layouts yet.":["Este campo todavía no tiene diseños."],"This field would stand inside too many containers. Put it nearer the top and try again.":["Este campo quedaría dentro de demasiados contenedores. Ponlo más cerca del nivel superior e inténtalo de nuevo."],"This file could not be opened as a zip. Package the theme again and upload the new file.":["Este archivo no se ha podido abrir como zip. Vuelve a empaquetar el tema y sube el archivo nuevo."],"This file is larger than the upload limit. Upload a smaller file.":["Este archivo es más grande que el límite de subida. Sube un archivo más pequeño."],"This file is named %(extension)s but it holds %(detected)s content. Upload it under a name that matches what is inside it.":["Este archivo se llama %(extension)s pero lleva contenido %(detected)s. Súbelo con un nombre que coincida con lo que lleva dentro."],"This file matches the site, so there is nothing to change.":["Este archivo coincide con el sitio, así que no hay nada que cambiar."],"This file says it was written in format %(declared)s, which is not a version Gophenberg can read. Export the definitions again from the site they came from.":["Este archivo dice estar escrito en el formato %(declared)s, que no es una versión que Gophenberg pueda leer. Vuelve a exportar las definiciones desde el sitio del que vinieron."],"This file was written in format %(declared)s, and this release reads %(served)s. Upgrade the site the file came from, or this one.":["Este archivo está escrito en el formato %(declared)s, y esta versión lee %(served)s. Actualiza el sitio del que viene el archivo, o este."],"This group holds no fields yet.":["Este grupo todavía no tiene campos."],"This install of Gophenberg has no install root, so it updates by hand.":["Esta instalación de Gophenberg no tiene raíz de instalación, así que se actualiza a mano."],"This is a custom build of Gophenberg, so it updates by hand.":["Esta es una compilación propia de Gophenberg, así que se actualiza a mano."],"This item cannot go straight to that status from the one it holds now. Save it as a draft first, then try again.":["Este elemento no puede pasar directamente a ese estado desde el que tiene ahora. Guárdalo antes como borrador e inténtalo de nuevo."],"This item changed while you were describing it. Reload and try again.":["Este elemento ha cambiado mientras lo describías. Recarga e inténtalo de nuevo."],"This item does not exist, and it may have been deleted. Go back to the list to see what is there.":["Este elemento no existe, y puede que se haya borrado. Vuelve a la lista para ver qué hay."],"This item is already in the trash. Restore it first to work on it again.":["Este elemento ya está en la papelera. Restáuralo primero para volver a trabajar en él."],"This item is in the trash. Restore it first to work on it again.":["Este elemento está en la papelera. Restáuralo primero para volver a trabajar en él."],"This item is not in the trash, so there is nothing to restore. Open it from the list to work on it as it is.":["Este elemento no está en la papelera, así que no hay nada que restaurar. Ábrelo desde la lista para trabajar en él tal como está."],"This item still holds items nested inside it. Move or delete those first.":["Este elemento todavía tiene elementos anidados dentro. Mueve o borra antes esos."],"This kind of field holds no fields inside it. Put the field in a section or a repeater instead.":["Este tipo de campo no contiene campos dentro. Pon el campo en una sección o en un repetidor."],"This media item is not in the library, and someone may have deleted it. Reload the page to see what is there now.":["Este medio no está en la biblioteca, y puede que alguien lo haya borrado. Recarga la página para ver qué hay ahora."],"This picture could not be read, so nothing was stored. The file may be damaged, so open it on your computer and upload it again.":["Esta imagen no se ha podido leer, así que no se ha guardado nada. Puede que el archivo esté dañado, así que ábrelo en tu ordenador y vuelve a subirlo."],"This picture is %(width)s by %(height)s pixels, over the limit of %(max)s pixels in all. Make it smaller and upload it again.":["Esta imagen tiene %(width)s por %(height)s píxeles, por encima del límite de %(max)s píxeles en total. Hazla más pequeña y vuelve a subirla."],"This post changed elsewhere. Reload before saving again.":["Esta entrada ha cambiado en otro sitio. Recarga antes de volver a guardar."],"This request came from a page on another site, so it was refused. Open the admin on this site and try again.":["Esta petición vino de una página de otro sitio, así que se rechazó. Abre la administración en este sitio e inténtalo de nuevo."],"This request carried %(attribute)s, which fields do not take. Reload the page so the admin and the server agree again.":["Esta petición llevaba %(attribute)s, que los campos no aceptan. Recarga la página para que la administración y el servidor vuelvan a coincidir."],"This request left out %(field)s, which Gophenberg needs. Reload the page and try again.":["En esta petición faltaba %(field)s, que Gophenberg necesita. Recarga la página e inténtalo de nuevo."],"This rule reads a source called %(source)s, and nothing on your site declares one. A plugin that added it may have been removed.":["Esta regla lee una fuente llamada %(source)s, y nada en tu sitio declara ninguna. Puede que se haya quitado un plugin que la añadía."],"This site cannot write to its themes folder, so no theme can be installed. Ask whoever looks after your site to make the folder that %(setting)s points at writable. A read-only mount is the usual cause.":["Este sitio no puede escribir en su carpeta de temas, así que no se puede instalar ninguno. Pide a quien gestione tu sitio que haga escribible la carpeta a la que apunta %(setting)s. La causa habitual es un montaje de solo lectura."],"This site has nowhere to keep themes, so no theme can be installed. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Este sitio no tiene dónde guardar los temas, así que no se puede instalar ninguno. Pide a quien gestione tu sitio que apunte %(setting)s a una carpeta en la que el servidor pueda escribir."],"This site has nowhere to store uploads, so nothing can be uploaded. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Este sitio no tiene dónde guardar las subidas, así que no se puede subir nada. Pide a quien gestione tu sitio que apunte %(setting)s a una carpeta en la que el servidor pueda escribir."],"This theme is over the limit of %(max)s bytes, packed or unpacked. Leave out what the theme does not need and package it again.":["Este tema pasa del límite de %(max)s bytes, comprimido o sin comprimir. Saca lo que el tema no necesite y vuelve a empaquetarlo."],"This type will answer under /%(word)s.":["Este tipo responderá como /%(word)s."],"This zip holds %(entries)s files, more than the %(max)s a theme may carry. Package only the built theme.":["Este zip lleva %(entries)s archivos, más de los %(max)s que puede llevar un tema. Empaqueta solo el tema ya construido."],"Through":["A través de"],"Thumbnail":["Miniatura"],"Title":["Título"],"Two field groups in this file share the key %(key)s. Give one of them a different key and import again.":["Dos grupos de campos de este archivo comparten la clave %(key)s. Dale una clave distinta a uno de ellos e importa de nuevo."],"Two rule sources are registered under the name %(param)s. A plugin is declaring a source your site already holds.":["Hay dos fuentes de reglas registradas con el nombre %(param)s. Un plugin declara una fuente que tu sitio ya tiene."],"Type":["Tipo"],"Type a whole number in both boxes before saving.":["Escribe un número entero en las dos casillas antes de guardar."],"Undo":["Deshacer"],"Unsaved changes":["Cambios sin guardar"],"Update":["Actualizar"],"Update failed.":["No se ha podido actualizar."],"Upload theme":["Subir tema"],"Uploaded %s file.":["%s archivo subido.","%s de archivos subidos.","%s archivos subidos."],"Users":["Usuarios"],"Users could not be loaded.":["No se han podido cargar los usuarios."],"Value":["Valor"],"Version":["Versión"],"Videos":["Vídeos"],"Visit Site":["Visitar el sitio"],"Web address":["Dirección web"],"What each Linked from field reads":["Qué lee cada campo Enlazado desde"],"What it points at changed, so its stored values go with it.":["Aquello a lo que apunta ha cambiado, así que sus valores guardados se van con él."],"Where %(group)s appears":["Dónde aparece %(group)s"],"Without a rule this field always shows.":["Sin ninguna regla este campo siempre se muestra."],"Without a rule this group appears nowhere.":["Sin una regla este grupo no aparece en ningún sitio."],"Yes":["Sí"],"Yes or no":["Sí o no"],"You cannot change your own role.":["No puedes cambiar tu propio rol."],"Your language":["Tu idioma"],"Your role does not allow %(capability)s.":["Tu rol no permite %(capability)s."],"Your server pins the site to the theme %(name)s, so themes cannot be changed here. Ask whoever looks after your site to remove the pin.":["Tu servidor bloque el sitio al tema %(name)s, así que los temas no se pueden cambiar desde aquí. Pide a quien gestiona tu sitio que quite ese bloqueo."],"Your site always needs one default content type, so this one cannot be turned off or deleted. Make another type the default first.":["Tu sitio siempre necesita un tipo de contenido por defecto, así que este no se puede desactivar ni borrar. Haz antes que otro tipo sea el que está por defecto."],"a content type":["un tipo de contenido"],"account\u0004Role":["Rol"],"account\u0004Status":["Estado"],"action label\u0004Duplicate":["Duplicar"],"action label\u0004Duplicate…":["Duplicar…"],"address":["dirección"],"admin section\u0004Content Types":["Tipos de contenido"],"admin section\u0004Field Groups":["Grupos de campos"],"admin section\u0004Language":["Idioma"],"admin section\u0004Media":["Medios"],"admin section\u0004Settings":["Ajustes"],"admin section\u0004Themes":["Temas"],"column\u0004Date":["Fecha"],"content type\u0004\"%s\" permanently deleted.":["Se ha borrado permanentemente el tipo de contenido «%s»."],"content type\u0004\"%s\" successfully created.":["Se ha creado correctamente el tipo de contenido «%s»."],"content type\u0004Status":["Estado"],"field\u0004Name":["Nombre"],"field group\u0004Status":["Estado"],"field type\u0004Choice":["Elección"],"field type\u0004Color":["Color"],"field type\u0004Date":["Fecha"],"field type\u0004Flexible content":["Contenido flexible"],"field type\u0004Gallery":["Galería"],"field type\u0004Kind":["Tipo"],"field type\u0004Layout":["Diseño"],"field type\u0004Link":["Enlace"],"field type\u0004Media":["Medio"],"field type\u0004Range":["Rango"],"field type\u0004Repeater":["Repetidor"],"field type\u0004Section":["Sección"],"field type\u0004Select":["Desplegable"],"holds":["contiene"],"holds anything":["contiene algo"],"is":["es"],"is above":["es mayor que"],"is below":["es menor que"],"is empty":["está vacío"],"is not":["no es"],"media family\u0004Kind":["Clase"],"panel button label\u0004Settings":["Ajustes"],"person\u0004Name":["Nombre"],"post\u0004%s (Copy)":["%s (copia)"],"post\u0004Status":["Estado"],"post status\u0004Draft":["Borrador"],"post status\u0004Published":["Publicada"],"post status\u0004Trash":["Papelera"],"post type general name\u0004Pages":["Páginas"],"post type general name\u0004Posts":["Entradas"],"post type singular name\u0004Page":["Página"],"post type singular name\u0004Post":["Entrada"],"relation target\u0004None":["Ninguno"],"role\u0004Administrator":["Administrador"],"role\u0004Author":["Autor"],"role\u0004Editor":["Editor"],"role\u0004Member":["Miembro"],"role\u0004No role":["Sin rol"],"status filter\u0004All":["Todo"],"taxonomy general name\u0004Categories":["Categorías"],"taxonomy singular name\u0004Category":["Categoría"],"theme\u0004Status":["Estado"],"trash\u0004Restore":["Restaurar"],"v%(version)s":["v%(version)s"],"verb\u0004Trash…":["Papelera…"],"verb\u0004View":["Ver"],"{day} {month} {year}":["{day} de {month} de {year}"]} +{"":{"lang":"es","plural-forms":"nplurals=3; plural=((n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2));"}," and ":[" y "]," or ":[" o "],"\"%s\" activated.":["Se ha activado «%s»."],"\"%s\" deactivated.":["Se ha desactivado «%s»."],"\"%s\" has been restored.":["«%s» se ha restaurado."],"\"%s\" moved to the trash.":["«%s» trasladado a la papelera."],"\"%s\" permanently deleted.":["«%s» borrado permanentemente."],"\"%s\" successfully created.":["«%s» creado correctamente."],"\"%s\" uploaded.":["«%s» subido."],"%(field)s address":["Dirección de %(field)s"],"%(field)s cannot move inside itself. Pick a container that is not one of its own.":["%(field)s no puede moverse dentro de sí mismo. Elige un contenedor que no sea uno de los suyos."],"%(field)s declared.":["%(field)s declarado."],"%(field)s deleted.":["%(field)s borrado."],"%(field)s does not match the format its field asks for. Check it and save again.":["%(field)s no tiene el formato que su campo requiere. Revísalo y guarda de nuevo."],"%(field)s goes no higher than %(limit)s. Lower the value and save again.":["%(field)s no pasa de %(limit)s. Baja el valor y guarda de nuevo."],"%(field)s goes no lower than %(limit)s. Raise the value and save again.":["%(field)s no baja de %(limit)s. Sube el valor y guarda de nuevo."],"%(field)s holds no value of its own, so it cannot be required. Make it optional.":["%(field)s no guarda ningún valor propio, así que no puede ser obligatorio. Hazlo opcional."],"%(field)s is optional again.":["%(field)s vuelve a ser opcional."],"%(field)s is required now.":["%(field)s ahora es obligatorio."],"%(field)s moved.":["%(field)s movido."],"%(field)s names the same one twice. Remove the repeat and save again.":["%(field)s nombra el mismo dos veces. Quita la repetición y guarda de nuevo."],"%(field)s needs at least %(limit)s files. Add one and save again.":["%(field)s necesita al menos %(limit)s archivos. Añade uno y guarda de nuevo."],"%(field)s needs at least %(limit)s rows. Add one and save again.":["%(field)s necesita al menos %(limit)s filas. Añade una y guarda de nuevo."],"%(field)s only takes one of its listed choices. Pick one and save again.":["%(field)s solo admite una de las opciones de su lista. Elige una y guarda de nuevo."],"%(field)s reads a relation field that is not there. Pick a group and a relation field it holds.":["%(field)s lee un campo de relación que no existe. Elige un grupo y un campo de relación que contenga."],"%(field)s reads a relation pointing at %(type)s, so it would list nothing here. Pick a relation pointing at the items this group is shown on.":["%(field)s lee una relación que apunta a %(type)s, así que aquí no mostraría nada. Elige una relación que apunte a los elementos donde se muestra este grupo."],"%(field)s renamed.":["%(field)s renombrado."],"%(field)s settled.":["%(field)s ajustado."],"%(field)s shows by its rules now.":["%(field)s ahora se muestra según sus reglas."],"%(field)s stands in the list.":["%(field)s aparece en la lista."],"%(field)s stays out of the list.":["%(field)s se queda fuera de la lista."],"%(field)s takes at most %(limit)s files. Remove one and save again.":["%(field)s acepta como mucho %(limit)s archivos. Quita uno y guarda de nuevo."],"%(field)s takes at most %(limit)s letters. Shorten what you wrote and save again.":["%(field)s acepta como mucho %(limit)s letras. Acorta lo que has escrito y guarda de nuevo."],"%(field)s takes at most %(limit)s rows. Remove one and save again.":["%(field)s admite como mucho %(limit)s filas. Quita una y guarda de nuevo."],"%(field)s title":["Título de %(field)s"],"%(file)s is larger than one upload may be.":["%(file)s es más grande de lo que una subida permite."],"%(file)s is not a file this block accepts.":["%(file)s no es un archivo que este bloque acepte."],"%(file)s: %(reason)s":["%(file)s: %(reason)s"],"%(group)s created.":["%(group)s creado."],"%(group)s now appears where you said.":["%(group)s ya aparece donde has dicho."],"%(group)s removed.":["%(group)s eliminado."],"%(group)s updated.":["%(group)s actualizado."],"%(key)s is served by %(winner)s, so %(loser)s does not show it on %(where)s.":["%(key)s lo sirve %(winner)s, así que %(loser)s no lo muestra en %(where)s."],"%(name)s %(version)s is serving the public site.":["%(name)s %(version)s está publicado en la web."],"%(name)s belongs to the site now.":["%(name)s ya pertenece al sitio."],"%(name)s came from the %(plugin)s plugin, which no longer declares it.":["%(name)s vino del plugin %(plugin)s, que ya no lo declara."],"%(name)s cannot be used as a theme name. Name the zip file after the theme, with no dot at the start and no slashes in it.":["%(name)s no se puede usar como nombre de tema. Nombra el archivo zip como el tema, sin punto al principio y sin barras."],"%(name)s moves to /%(word)s. Every address of both types changes.":["El tipo %(name)s pasa a /%(word)s. Cambian todas las direcciones de ambos tipos."],"%(name)s would move to /%(word)s, which Gophenberg keeps for itself. Change its plural name and try again.":["El tipo %(name)s pasaría a /%(word)s, que Gophenberg reserva para sí mismo. Cambia su nombre en plural e inténtalo de nuevo."],"%(source)s is %(value)s":["%(source)s es %(value)s"],"%(theme)s failed to start, so the built-in renderer is serving.":["%(theme)s no pudo arrancar, así que el renderizador ha tomado el relevo."],"%(theme)s is now serving the public site.":["%(theme)s ya sirve el sitio público."],"%(theme)s is serving the public site.":["%(theme)s sirve el sitio público."],"%(theme)s is still starting, so the built-in renderer is serving.":["%(theme)s todavía está arrancando, así que el renderizador ha tomado el relevo."],"%(theme)s was installed.":["%(theme)s se ha instalado."],"%(theme)s will not load, so the built-in renderer is serving.":["%(theme)s no se carga, así que el renderizador ha tomado el relevo."],"%(type)s answers on a new address, and stored links move with it.":["%(type)s responde en una dirección nueva, y los enlaces guardados se mueven con ella."],"%(type)s becomes the type the site opens on, and the old one takes an address.":["%(type)s pasa a ser el tipo con el que abre el sitio, y el anterior toma una dirección."],"%(type)s keeps nesting while %(items)s of its items sit inside another. Move those to the top level first, the ones in the trash included.":["%(type)s sigue anidando mientras %(items)s de sus elementos están dentro de otro. Muévelos primero al nivel superior, incluidos los de la papelera."],"%(type)s keeps nesting, because some of its items sit inside another.":["%(type)s sigue anidando, porque algunos de sus elementos están dentro de otro."],"%(type)s will answer at the root.":["El tipo %(type)s responderá en la raíz."],"%s content type activated.":["Se ha activado %s tipo de contenido.","Se han activado %s de tipos de contenido.","Se han activado %s tipos de contenido."],"%s content type could not be activated.":["No se ha podido activar %s tipo de contenido.","No se han podido activar %s de tipos de contenido.","No se han podido activar %s tipos de contenido."],"%s content type could not be deactivated.":["No se ha podido desactivar %s tipo de contenido.","No se han podido desactivar %s de tipos de contenido.","No se han podido desactivar %s tipos de contenido."],"%s content type could not be permanently deleted.":["No se ha podido borrar permanentemente %s tipo de contenido.","No se han podido borrar permanentemente %s de tipos de contenido.","No se han podido borrar permanentemente %s tipos de contenido."],"%s content type deactivated.":["Se ha desactivado %s tipo de contenido.","Se han desactivado %s de tipos de contenido.","Se han desactivado %s tipos de contenido."],"%s content type permanently deleted.":["Se ha borrado permanentemente %s tipo de contenido.","Se han borrado permanentemente %s de tipos de contenido.","Se han borrado permanentemente %s tipos de contenido."],"%s item could not be moved to the trash.":["No se ha podido mover %s elemento a la papelera.","No se han podido mover %s de elementos a la papelera.","No se han podido mover %s elementos a la papelera."],"%s item could not be permanently deleted.":["No se ha podido borrar permanentemente %s elemento.","No se han podido borrar permanentemente %s de elementos.","No se han podido borrar permanentemente %s elementos."],"%s item could not be restored.":["No se ha podido restaurar %s elemento.","No se han podido restaurar %s de elementos.","No se han podido restaurar %s elementos."],"%s item moved to the trash.":["%s elemento movido a la papelera.","%s de elementos movidos a la papelera.","%s elementos movidos a la papelera."],"%s item permanently deleted.":["%s elemento borrado permanentemente.","%s de elementos borrados permanentemente.","%s elementos borrados permanentemente."],"%s item stays in the trash.":["%s elemento sigue en la papelera.","%s de elementos siguen en la papelera.","%s elementos siguen en la papelera."],"%s page has been restored.":["Se ha restaurado %s página.","Se han restaurado %s de páginas.","Se han restaurado %s páginas."],"%s post has been restored.":["Se ha restaurado %s entrada.","Se han restaurado %s de entradas.","Se han restaurado %s entradas."],"(no title)":["(sin título)"],"A %(kind)s field does not take a setting called %(setting)s. Remove it and try again.":["Un campo %(kind)s no acepta un ajuste llamado %(setting)s. Quítalo y prueba de nuevo."],"A Linked from field stands on its own, not inside a section or a repeater. Declare %(field)s beside the container.":["Un campo Enlazado desde va por su cuenta, no dentro de una sección ni de un repetidor. Declara %(field)s junto al contenedor."],"A content type needs a singular and a plural name, like Guide and Guides. Fill both in and try again.":["Un tipo de contenido necesita un nombre en singular y otro en plural, como Guía y Guías. Rellena ambos e inténtalo de nuevo."],"A descriptive summary of the content type.":["Un resumen descriptivo del tipo de contenido."],"A field group key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the group in the file so its key comes out in that shape.":["La clave de un grupo de campos tiene que empezar por una letra minúscula y después llevar solo letras minúsculas, números y guiones. Cambia el nombre del grupo en el archivo para que su clave salga con esa forma."],"A field key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the field so its key comes out in that shape.":["La clave de un campo tiene que empezar por una letra minúscula y contener solo letras minúsculas, números y guiones. Renombra el campo para que su clave tenga esa forma."],"A field needs a name. Type one and try again.":["El campo necesita un nombre. Escribe uno e inténtalo de nuevo."],"A field with that name already exists on the items this group reaches. Pick another name.":["Ya existe un campo con ese nombre en los elementos que este grupo alcanza. Elige otro nombre."],"A flexible content field holds layouts, and %(field)s is not one. Add a layout first, then declare the field inside that layout.":["Un campo de contenido flexible contiene diseños, y %(field)s no lo es. Añade primero un diseño y después declara el campo dentro de ese diseño."],"A higher quality stores larger files, and it applies to pictures uploaded from now on.":["Una calidad más alta almacena archivos más grandes, y se aplica a las imágenes que subas a partir de ahora."],"A layout stands inside a flexible content field only. Put %(field)s inside one of those instead.":["Un diseño solo va dentro de un campo de contenido flexible. Pon %(field)s dentro de uno de ellos."],"A layout stands inside a flexible content field, never on its own. Declare a flexible content field first, then add %(field)s inside it.":["Un diseño va dentro de un campo de contenido flexible, nunca por su cuenta. Declara primero un campo de contenido flexible y después añade %(field)s dentro."],"A listing carries between 1 and %(max)s items, so %(value)s is not a size Gophenberg takes. Pick a number inside that range.":["Un listado muestra entre 1 y %(max)s elementos, así que %(value)s no es un tamaño que Gophenberg acepte. Elige un número dentro de ese rango."],"A relation field has to say which content type it points at. Pick that type and try again.":["Un campo de relación tiene que indicar a qué tipo de contenido apunta. Elige ese tipo e inténtalo de nuevo."],"A reload shows the admin in the language you chose.":["Al recargar verás el panel de administración en el idioma que has elegido."],"A route word has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change it and try again.":["La palabra de ruta de la URL tiene que empezar por una letra minúscula y contener solo letras minúsculas, números y guiones. Cámbiala e inténtalo de nuevo."],"A row of %(field)s has to name the layout it takes. Pick a layout for the row, or remove the row.":["Una fila de %(field)s tiene que nombrar el diseño que toma. Elige un diseño para la fila, o quita la fila."],"A row of %(field)s takes one layout and no more. Reload the page and build the row again.":["Una fila de %(field)s toma un solo diseño. Recarga la página y crea la fila otra vez."],"A rule matching any %(source)s cannot be turned into an exclusion. Leave it as is, or name the ones to leave out instead.":["Una regla que vale para cualquier %(source)s no se puede convertir en exclusión. Déjala como está, o nombra los que quieras dejar fuera."],"A theme naming its own page size uses that instead.":["Un tema que indica su propio tamaño de página usa ese en su lugar."],"A type key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change the key and try again.":["La clave de un tipo tiene que empezar por una letra minúscula y contener solo letras minúsculas, números y guiones. Cambia la clave e inténtalo de nuevo."],"Actions":["Acciones"],"Activate":["Activar"],"Activate %(theme)s":["Activar %(theme)s"],"Active":["Activo"],"Add":["Añadir"],"Add %(layout)s":["Añadir %(layout)s"],"Add %(name)s":["Añadir %(name)s"],"Add %(type)s":["Añadir %(type)s"],"Add Category":["Añadir categoría"],"Add Content Type":["Añadir tipo de contenido"],"Add Field Group":["Añadir grupo de campos"],"Add Media File":["Añadir medios"],"Add Page":["Añadir página"],"Add Post":["Añadir entrada"],"Add User":["Añadir usuario"],"Add block":["Añadir bloque"],"Add choice":["Añadir opción"],"Add condition":["Añadir condición"],"Add field":["Añadir campo"],"Add field to %(field)s":["Añadir campo a %(field)s"],"Add layout":["Añadir diseño"],"Add layout to %(field)s":["Añadir diseño a %(field)s"],"Add one with %(button)s.":["Añade un elemento con %(button)s."],"Add row":["Añadir fila"],"Add rule set":["Añadir conjunto de reglas"],"Add title":["Añadir título"],"Add to %(field)s":["Añadir a %(field)s"],"Address":["Dirección"],"Address updated.":["Dirección actualizada."],"Adopt":["Adoptar"],"Allow custom":["Permitir valores propios"],"Allow empty":["Permitir vacío"],"Alt text":["Texto alternativo"],"An item cannot point at itself. Pick a different item for %(field)s.":["Un elemento no puede apuntarse a sí mismo. Elige otro elemento para %(field)s."],"An item cannot sit inside itself, or inside one of the items nested under it. Pick a parent outside this branch.":["Un elemento no puede estar dentro de sí mismo, ni dentro de otro anidado bajo él. Elige un nivel superior fuera de esta rama."],"Another account wrote this, and only its author, an editor or an administrator may change it. You can still read it.":["Otra cuenta escribió esto, y solo su autor, un editor o un administrador pueden cambiarlo. Todavía puedes leerlo."],"Another account wrote this, so you are reading it rather than editing it.":["Otra cuenta escribió esto, así que lo estás leyendo, no editando."],"Another content type already answers under this route word. Pick a different word.":["Ya hay otro tipo de contenido que responde con esta palabra de ruta de URL. Elige una palabra distinta."],"Another content type already lives at the root of your site, and only one can. Give this type a route word, or hand the root over with Make default.":["Ya hay otro tipo de contenido en la raíz de tu sitio, y solo puede haber uno. Dale a este tipo de contenido una palabra de ruta de URL, o cede la raíz con Establecer como predeterminado."],"Another content type already uses this key. Give this type a different name.":["Ya hay otro tipo de contenido que usa esta clave. Dale a este tipo un nombre distinto."],"Another item of this content type already uses that address. Pick a different slug and save again.":["Ya hay otro elemento de este tipo de contenido que usa esa dirección. Elige un slug distinto y guarda de nuevo."],"Another update is currently in progress.":["En este momento hay otra actualización en marcha."],"Appears on":["Aparece en"],"Apply":["Aplicar"],"April":["abril"],"Are you sure you want to move \"%s\" to the trash?":["¿Seguro que quieres mover «%s» a la papelera?"],"Audio":["Audio"],"August":["agosto"],"Author":["Autor"],"Back to %(type)s":["Volver a %(type)s"],"Back to the latest posts":["Volver a las últimas entradas"],"Block":["Bloque"],"Broken":["Roto"],"Bundles of fields, shown where their rules place them.":["Conjuntos de campos, mostrados donde sus reglas los colocan."],"Button group":["Grupo de botones"],"Cancel":["Cancelar"],"Caption":["Leyenda"],"Change %(name)s":["Cambiar %(name)s"],"Change address":["Cambiar la dirección"],"Change address…":["Cambiar la dirección…"],"Change role":["Cambiar el rol"],"Change the role of %(name)s":["Cambiar el rol de %(name)s"],"Checkbox group":["Grupo de casillas"],"Choices":["Opciones"],"Choose %(field)s":["Elegir %(field)s"],"Choose how the site serves its content.":["Elige cómo el sitio sirve su contenido."],"Choose the language the admin reads in.":["Elige el idioma en el que se lee el panel de administración."],"Choose the role of %(name)s.":["Elige el rol de %(name)s."],"Choose the version to update to.":["Elige la versión a la que actualizar."],"Clear %(field)s":["Vaciar %(field)s"],"Close":["Cerrar"],"Close Settings":["Cerrar ajustes"],"Closed":["Cerrado"],"Condition":["Condición"],"Content":["Contenido"],"Content Types":["Tipos de contenido"],"Content nests at most %(max)s levels deep, and this item would sit deeper. Pick a parent closer to the top.":["El contenido se anida como mucho %(max)s niveles, y este elemento quedaría más abajo. Elige un nivel superior más cercano al inicio."],"Content type":["Tipo de contenido"],"Content type updated.":["Tipo de contenido actualizado."],"Could not load that post.":["No se ha podido cargar esa entrada."],"Could not save image. %s":["No se ha podido guardar la imagen. %s"],"Could not save media. %s":["No se ha podido guardar el archivo multimedia. %s"],"Could not save that post.":["No se ha podido guardar esa entrada."],"Create":["Crear"],"Create a field group":["Crear un grupo de campos"],"Create user":["Crear usuario"],"Created":["Fecha de creación"],"Date added":["Fecha de inclusión"],"Date modified":["Fecha de modificación"],"Deactivate":["Desactivar"],"Deactivate %(theme)s":["Desactivar %(theme)s"],"December":["diciembre"],"Default":["Por defecto"],"Default content type updated.":["Tipo de contenido predeterminado actualizado."],"Definitions file":["Archivo de definiciones"],"Delete":["Borrar"],"Delete \"%(title)s\" for good? This cannot be undone.":["¿Borrar permanentemente «%(title)s»? Esto no se puede deshacer."],"Delete %(field)s":["Borrar %(field)s"],"Delete %(group)s":["Eliminar %(group)s"],"Delete %s content type for good? This cannot be undone.":["¿Borrar permanentemente %s tipo de contenido? Esto no se puede deshacer.","¿Borrar permanentemente %s de tipos de contenido? Esto no se puede deshacer.","¿Borrar permanentemente %s tipos de contenido? Esto no se puede deshacer."],"Delete %s item for good? This cannot be undone.":["¿Borrar permanentemente %s elemento? Esto no se puede deshacer.","¿Borrar permanentemente %s de elementos? Esto no se puede deshacer.","¿Borrar permanentemente %s elementos? Esto no se puede deshacer."],"Delete permanently":["Borrar permanentemente"],"Delete the field":["Borrar el campo"],"Delete the group":["Eliminar el grupo"],"Delete…":["Borrar…"],"Describe":["Describir"],"Describe the purpose of the image.":["Describe el propósito de la imagen."],"Describe…":["Describir…"],"Description":["Descripción"],"Description updated.":["Descripción actualizada."],"Desktop":["Escritorio"],"Destination":["Destino"],"Dimensions":["Dimensiones"],"Disable":["Desactivar"],"Disable %(name)s":["Desactivar a %(name)s"],"Disabled":["Desactivado"],"Dismiss":["Descartar"],"Document":["Documento"],"Documents":["Documentos"],"Done. The site now holds what the file describes.":["Hecho. El sitio ya tiene lo que describe el archivo."],"Draft saved.":["Borrador guardado."],"Drop files to upload":["Arrastra los archivos para subirlos"],"Edit media":["Editar los medios"],"Edit…":["Editar…"],"Email":["Correo electrónico"],"Empty Trash":["Vaciar papelera"],"Empty trash":["Vaciar papelera"],"Enable":["Activar"],"Enable %(name)s":["Activar a %(name)s"],"Every address of this type moves. Links kept elsewhere to the old addresses stop working.":["Cambian todas las direcciones de este tipo. Los enlaces guardados en otro sitio hacia las direcciones antiguas dejan de funcionar."],"Every content type":["Todos los tipos de contenido"],"Every field group needs a title. Give it a name and save again.":["Todo grupo de campos necesita un título. Ponle un nombre y guarda otra vez."],"Every item needs an author who exists. Pick one from the list and save again.":["Cada elemento necesita un autor que exista. Elige uno de la lista y guarda de nuevo."],"Every kind of content this site holds.":["Todos los tipos de contenido que hay en este sitio."],"Every value stored under this field goes with it, in every item the group reaches and in the revisions behind them.":["Todos los valores guardados en este campo se van con él, en cada elemento que el grupo alcanza y en las revisiones que hay detrás."],"Excerpt":["Extracto"],"Export definitions":["Exportar definiciones"],"February":["febrero"],"Fewest files":["Mínimo de archivos"],"Fewest rows":["Mínimo de filas"],"Field":["Campo"],"Field Groups":["Grupos de campos"],"Field group":["Grupo de campos"],"Fields":["Campos"],"Fields of %(group)s":["Campos de %(group)s"],"File name":["Nombre del archivo"],"File size":["Tamaño del archivo"],"File type":["Tipo de archivo"],"Filter by status":["Filtrar por estado"],"Follow the browser":["Sigue al navegador"],"From %(plugin)s":["De %(plugin)s"],"Full Size":["Tamaño completo"],"Gophenberg cannot start this update: %(reason)s":["Gophenberg no puede empezar esta actualización: %(reason)s"],"Gophenberg checked for updates moments ago. Try again later.":["Gophenberg ha buscado actualizaciones hace un momento. Inténtalo de nuevo más tarde."],"Gophenberg could not read what your browser sent. Reload the page and try again, and tell whoever looks after your site if it keeps happening.":["Gophenberg no ha podido leer lo que ha enviado tu navegador. Recarga la página e inténtalo de nuevo, y avisa a quien gestiona tu sitio si sigue pasando."],"Gophenberg could not read which item you picked as parent. Reload the page and pick it again.":["Gophenberg no ha podido leer qué elemento has elegido como elemento superior. Recarga la página y vuelve a elegirlo."],"Gophenberg does not accept %(extension)s files. Upload a picture, a video, an audio file, a PDF or a zip instead.":["Gophenberg no acepta archivos %(extension)s. Sube una imagen, un vídeo, un audio, un PDF o un zip."],"Gophenberg does not answer in %(value)s. Pick one of the languages on the list.":["Gophenberg no responde en %(value)s. Elige uno de los idiomas de la lista."],"Gophenberg has no autosave of yours for this item. Open the item to see the version that is stored.":["Gophenberg no tiene ningún guardado automático tuyo de este elemento. Ábrelo para ver la versión guardada."],"Gophenberg holds no kind of definition called %(subject)s, so nothing was taken over. Reload the page so the admin and the server agree again.":["Gophenberg no tiene ninguna clase de definición llamada %(subject)s, así que no se ha adoptado nada. Recarga la página para que el admin y el servidor vuelvan a coincidir."],"Gophenberg keeps /%(path)s for itself or for another content type, so your content cannot answer there. Give the item a different slug.":["Gophenberg reserva la dirección /%(path)s para sí mismo o para otro tipo de contenido, así que tu contenido no puede usarla. Dale al elemento otro slug."],"Gophenberg keeps this word for itself, so no content type can answer under it. Pick a different route word.":["Gophenberg reserva esta palabra para sí mismo, así que ningún tipo de contenido puede usarla. Elige otra palabra de ruta de URL."],"Group":["Grupo"],"Highest":["Máximo"],"Holds":["Contiene"],"Images":["Imágenes"],"Import definitions":["Importar definiciones"],"In list":["En la lista"],"Inactive":["Inactivo"],"Installed":["Instalado"],"Instructions":["Instrucciones"],"It moves here from another group and keeps its stored values.":["Se mueve aquí desde otro grupo y conserva sus valores guardados."],"It moves to another group and keeps its stored values.":["Se mueve a otro grupo y conserva sus valores guardados."],"It moves to another group, so its stored values do not follow.":["Se mueve a otro grupo, así que sus valores guardados no le siguen."],"It reads a relation inside a container, and only an import can point it elsewhere.":["Lee una relación dentro de un contenedor, y solo una importación puede apuntarlo a otro sitio."],"It starts on %(type)s, and the rules may be edited afterwards.":["Empieza en %(type)s, y las reglas se pueden editar después."],"Items could not be loaded.":["No se han podido cargar los elementos."],"Its items sit inside one another, so it keeps nesting.":["Sus elementos están unos dentro de otros, así que sigue anidando."],"Its kind changed, so its stored values go with it.":["Su tipo ha cambiado, así que sus valores guardados se van con él."],"January":["enero"],"July":["julio"],"June":["junio"],"Keep %(field)s out of the list":["Dejar %(field)s fuera de la lista"],"Keep it":["Conservarlo"],"Keep it here":["Dejarlo aquí"],"Key":["Clave"],"Label":["Etiqueta"],"Language":["Idioma"],"Large":["Grande"],"Leave empty if decorative.":["Déjalo vacío si es decorativo."],"Let items nest":["Permitir anidar"],"Linked from":["Enlazado desde"],"List view":["Vista de lista"],"Loading field groups.":["Cargando los grupos de campos."],"Loading the post.":["Cargando la entrada."],"Loading the screen.":["Cargando la pantalla."],"Loading themes.":["Cargando los temas."],"Loading users.":["Cargando los usuarios."],"Loading…":["Cargando…"],"Longest":["Largo máximo"],"Lowest":["Mínimo"],"Make %(field)s optional":["Hacer %(field)s opcional"],"Make default":["Establecer como predeterminado"],"Make default…":["Establecer como predeterminado…"],"Make optional":["Hacer opcional"],"Manage the categories on this site.":["Gestiona las categorías de este sitio."],"Manage the images and files this site holds.":["Gestiona las imágenes y los archivos que hay en este sitio."],"Manage the items of this content type.":["Gestiona los elementos de este tipo de contenido."],"Manage the pages on this site.":["Gestiona las páginas de este sitio."],"Manage the posts on this site.":["Gestiona las entradas de este sitio."],"Many targets":["Varios destinos"],"Many values":["Varios valores"],"March":["marzo"],"May":["mayo"],"Media %(id)s":["Medio %(id)s"],"Media Library":["Biblioteca de medios"],"Media file updated.":["Archivo de medios actualizado."],"Medium":["Mediano"],"Mobile":["Móvil"],"Modified: %s":["Modificado: %s"],"Most files":["Máximo de archivos"],"Most rows":["Máximo de filas"],"Move":["Mover"],"Move \"%(title)s\" to the trash?":["¿Mover «%(title)s» a la papelera?"],"Move %(field)s down":["Bajar %(field)s"],"Move %(field)s elsewhere":["Mover %(field)s a otro sitio"],"Move %(field)s up":["Subir %(field)s"],"Move %(group)s down":["Bajar %(group)s"],"Move %(group)s up":["Subir %(group)s"],"Move %(name)s down":["Bajar %(name)s"],"Move %(name)s up":["Subir %(name)s"],"Move %s item to the trash?":["¿Mover %s elemento a la papelera?","¿Mover %s de elementos a la papelera?","¿Mover %s elementos a la papelera?"],"Move every address":["Mover todas las direcciones"],"Move row down":["Bajar la fila"],"Move row up":["Subir la fila"],"Move the field":["Mover el campo"],"Move to trash":["Mover a la papelera"],"Name":["Nombre"],"Name updated.":["Nombre actualizado."],"Nests":["Anida"],"Newer posts":["Entradas más recientes"],"No":["No"],"No content types found.":["No se han encontrado tipos de contenido."],"No description":["Sin descripción"],"No file arrived with this upload. Choose a file and try again.":["No ha llegado ningún archivo con esta subida. Elige un archivo e inténtalo de nuevo."],"No items found.":["No se han encontrado elementos."],"No items yet.":["Todavía no hay elementos."],"No media chosen":["Ningún medio elegido"],"No media has been uploaded yet.":["Todavía no se ha subido ningún medio."],"No media items found.":["No se han encontrado archivos de medios."],"No media items yet.":["Todavía no hay archivos de medios."],"No page lives at this address.":["No existe ninguna página en esta dirección."],"No parent":["Sin elemento superior"],"No theme arrived with this upload. Choose a zip file and try again.":["No ha llegado ningún tema con esta subida. Elige un archivo zip e inténtalo de nuevo."],"No themes are installed.":["No hay ningún tema instalado."],"No title":["Sin título"],"None":["Ninguno"],"Not %(type)s":["Excepto %(type)s"],"Not found":["No encontrado"],"Not serving":["No está sirviendo"],"Nothing declares that role.":["Nada declara ese rol."],"Nothing points here yet.":["Todavía no apunta nada aquí."],"Nothing published here yet.":["Todavía no hay nada publicado aquí."],"Nothing published yet.":["Todavía no hay nada publicado."],"November":["noviembre"],"Nowhere":["En ningún sitio"],"Number":["Número"],"October":["octubre"],"Older posts":["Entradas anteriores"],"One frame of this animation holds more than %(max)s pixels, which is more than Gophenberg opens. Upload a smaller animation.":["Un fotograma de esta animación tiene más de %(max)s píxeles, más de lo que Gophenberg abre. Sube una animación más pequeña."],"One of the filters on this list is not one Gophenberg understands. Clear the filters and try again.":["Uno de los filtros de esta lista no es un filtro que Gophenberg entienda. Quita los filtros e inténtalo de nuevo."],"One of the items picked for %(field)s is not the kind of content that field points at. Reload the page and pick from the list the field offers.":["Uno de los elementos elegidos para %(field)s no es del tipo de contenido al que apunta ese campo. Recarga la página y elige de la lista que ofrece el campo."],"One of the items picked for %(field)s was not named in a way Gophenberg understands. Reload the page and pick the items again.":["Uno de los elementos elegidos para %(field)s no venía nombrado de una forma que Gophenberg entienda. Recarga la página y vuelve a elegir los elementos."],"One of the items picked no longer exists. Reload the page and pick again.":["Uno de los elementos elegidos ya no existe. Recarga la página y vuelve a elegir."],"One target":["Un destino"],"Only a relation field points at another content type or holds many items. Pick the Relation kind, or leave the target and the many option empty.":["Solo un campo de relación apunta a otro tipo de contenido o guarda varios elementos. Elige el tipo Relación, o deja vacío el destino y la opción de varios."],"Only one file may be placed here.":["Aquí solo se puede colocar un archivo."],"Only the content type at the root of your site may go without a route word. Give this type one and try again.":["Solo el tipo de contenido que está en la raíz de tu sitio puede ir sin palabra de ruta de URL. Dale una a este tipo e inténtalo de nuevo."],"Open %(field)s in a new tab":["Abrir %(field)s en una pestaña nueva"],"Order stored.":["Orden guardado."],"Other":["Otro"],"Out of list":["Fuera de la lista"],"Parent":["Elemento superior"],"Password":["Contraseña"],"Pending":["Pendiente"],"Pending Review":["Pendiente de revisión"],"Permanently delete…":["Eliminar permanentemente…"],"Picture quality":["Calidad de imagen"],"Picture quality runs from 1 to %(max)s, so %(value)s is not a quality Gophenberg stores at. Pick a number inside that range.":["La calidad de imagen va de 1 a %(max)s, así que %(value)s no es una calidad con la que Gophenberg guarde. Elige un número dentro de ese rango."],"Placeholder":["Texto de ejemplo"],"Plural name":["Nombre en plural"],"Points at":["Apunta a"],"Post published.":["Entrada publicada."],"Posts per page":["Entradas por página"],"Preview":["Vista previa"],"Private":["Privada"],"Publish":["Publicar"],"Published: %s":["Publicado: %s"],"Radio group":["Grupo de botones de radio"],"Reads from":["Lee de"],"Redo":["Rehacer"],"Relation":["Relación"],"Remove":["Quitar"],"Remove %(name)s":["Quitar %(name)s"],"Remove %(title)s":["Quitar %(title)s"],"Remove row":["Quitar fila"],"Rename":["Renombrar"],"Rename %(field)s":["Renombrar %(field)s"],"Rename…":["Renombrar…"],"Require":["Hacer obligatorio"],"Require %(field)s":["Hacer %(field)s obligatorio"],"Required":["Obligatorio"],"Restore":["Restaurar"],"Role changed.":["Rol cambiado."],"Roll back to %(theme)s":["Revertir a %(theme)s"],"Roll back to the built-in renderer":["Revertir al renderizador integrado"],"Route word":["Palabra de ruta"],"Rule %(number)s of set %(set)s":["Regla %(number)s del conjunto %(set)s"],"Rule set %(number)s":["Conjunto de reglas %(number)s"],"Rules":["Reglas"],"Rules showing %(field)s":["Reglas que muestran %(field)s"],"Save":["Guardar"],"Save draft":["Guardar el borrador"],"Save rules":["Guardar las reglas"],"Save settings":["Guardar los ajustes"],"Saved":["Guardado"],"Saving":["Guardando"],"Scheduled":["Programada"],"Scheduled: %s":["Programado: %s"],"Search %(type)s…":["Buscar %(type)s…"],"Search audio":["Buscar audios"],"Search images":["Buscar imágenes"],"Search media":["Buscar medios"],"Search videos":["Buscar vídeos"],"Select":["Seleccionar"],"September":["septiembre"],"Serving":["Atendiendo"],"Settings":["Ajustes"],"Settings of %(field)s":["Ajustes de %(field)s"],"Shadowed":["Eclipsado"],"Show %(field)s in the list":["Mostrar %(field)s en la lista"],"Showing %(held)s of %(total)s.":["Mostrando %(held)s de %(total)s."],"Singular name":["Nombre en singular"],"Slug":["Slug"],"Somebody must be able to manage users.":["Alguien tiene que poder gestionar los usuarios."],"Someone else changed this media item while you were editing it. Reload the page and make your change again.":["Otra persona ha cambiado este medio mientras lo editabas. Recarga la página y vuelve a hacer tu cambio."],"Someone else saved changes to this item while you were editing, so yours were not saved. Copy your text somewhere safe, then reload the item and put your changes back in.":["Otra persona ha guardado cambios en este elemento mientras editabas, así que los tuyos no se han guardado. Copia tu texto a un lugar seguro, recarga el elemento y vuelve a colocarlos de vuelta."],"Something went wrong inside Gophenberg. Try again in a moment, and tell whoever looks after your site if it keeps happening.":["Algo ha fallado dentro de Gophenberg. Inténtalo de nuevo en un momento, y avisa a quien gestione tu sitio si sigue pasando."],"Something went wrong.":["Algo ha ido mal."],"Something went wrong. Try again.":["Algo ha ido mal. Inténtalo de nuevo."],"Sorry, you are not allowed to access this page.":["Lo siento, no tienes permisos para acceder a esta página."],"Source":["Origen"],"Steps of":["Pasos de"],"Stop nesting":["Dejar de anidar"],"Tablet":["Tableta"],"Text":["Texto"],"Text area":["Área de texto"],"That email is already in use.":["Ese correo electrónico ya está en uso."],"That field group no longer exists. Reload the page to see the groups your site holds now.":["Ese grupo de campos ya no existe. Recarga la página para ver los grupos que tiene ahora tu sitio."],"That field is not on this content type any more. Reload the page to see the fields it has now.":["Ese campo ya no está en este tipo de contenido. Recarga la página para ver los campos que tiene ahora."],"That revision does not exist, and older ones drop off once a type holds more than it keeps. Open the item to see which revisions are left.":["Esa revisión no existe, y las más antiguas se pierden cuando un tipo guarda más de las que conserva. Abre el elemento para ver qué revisiones quedan."],"That role holds more than your own.":["Ese rol abarca más que el tuyo."],"That web address does not name a media item Gophenberg can look up. Open the media library and pick the item from there.":["Esa dirección web no nombra ningún medio que Gophenberg pueda encontrar. Abre la biblioteca de medios y elige el elemento desde ahí."],"That web address does not name a revision Gophenberg can look up. Open the item and pick a revision from its history.":["Esa dirección web no alude a ninguna revisión que Gophenberg pueda identificar. Abre el elemento y elige una revisión de su historial."],"That web address does not name an item Gophenberg can look up. Go back to the list and open the item from there.":["Esa dirección web no alude a ningún elemento que Gophenberg pueda identificar. Vuelve a la lista y abre el elemento desde ahí."],"The %(count)s item in the trash is deleted for good. This cannot be undone.":["%(count)s elemento de la papelera se borrará permanentemente. Esto no se puede deshacer.","%(count)s de elementos de la papelera se borrarán permanentemente. Esto no se puede deshacer.","Los %(count)s elementos de la papelera se borrarán permanentemente. Esto no se puede deshacer."],"The %(file)s calls this theme %(declared)s, but it is being installed as %(installed)s. Rename the zip file to match, or change the name inside %(file)s.":["El archivo %(file)s llama a este tema %(declared)s, pero se está instalando como %(installed)s. Renombra el zip para que coincida, o cambia el nombre dentro de %(file)s."],"The %(file)s inside %(name)s is not valid JSON, so it could not be read. Fix that file and package the theme again.":["El archivo %(file)s dentro de %(name)s no es JSON válido, así que no se ha podido leer. Corrige ese archivo y vuelve a empaquetar el tema."],"The %(file)s inside %(name)s names its theme kit as %(declared)s, which is not a version number. Build the theme again, and the build will fill that in.":["El %(file)s dentro de %(name)s identifica su kit de temas como %(declared)s, que no srive como número de versión. Compila el tema de nuevo y la compilación lo rellenará."],"The %(origin)s plugin declared this, so it can only be changed in that plugin. Turning it off is still yours to do.":["El plugin %(origin)s lo declaró, así que solo se puede cambiar en ese plugin. Desactivarlo sigue estando en tu mano."],"The %(plugin)s plugin also declares %(name)s, and the site keeps the key.":["El plugin %(plugin)s también declara %(name)s, y el sitio se queda con la clave."],"The %(plugin)s plugin declared these fields, so they change only in its code.":["El plugin %(plugin)s declaró estos campos, así que solo cambian en su código."],"The %(plugin)s plugin declares %(name)s flat, and the site keeps it nesting while its items sit inside another.":["El plugin %(plugin)s declara %(name)s sin anidar, y el sitio lo mantiene anidando mientras sus elementos están dentro de otro."],"The browser kept an unsaved version of this post.":["El navegador ha guardado una versión sin guardar de esta entrada."],"The built-in renderer is now serving the public site.":["El renderizador integrado ya presta servicio al sitio público."],"The built-in renderer is serving the public site.":["El renderizador integrado presta servicio al sitio público."],"The content type this field points at is not named in a shape Gophenberg accepts. Give it a key that starts with a lowercase letter and then holds only lowercase letters, numbers and hyphens.":["El tipo de contenido al que apunta este campo no está nombrado con un formato que Gophenberg acepte. Dale una clave que empiece por una letra minúscula y contenga solo letras minúsculas, números y guiones."],"The content types could not be loaded, so where these groups appear and which fields they shadow are not shown.":["No se han podido cargar los tipos de contenido, así que no se muestra dónde aparecen estos grupos ni qué campos eclipsan."],"The content types could not be loaded.":["No se han podido cargar los tipos de contenido."],"The content types could not be refreshed, so where these groups appear and what they shadow may be out of date.":["No se han podido actualizar los tipos de contenido, así que dónde aparecen estos grupos y qué eclipsan puede estar desactualizado."],"The draft could not be created.":["No se ha podido crear el borrador."],"The entry %(entry)s in this zip would be written outside the theme folder, so nothing was installed. Package the theme again.":["La entrada %(entry)s de este zip se escribiría fuera de la carpeta del tema, así que no se ha instalado nada. Vuelve a empaquetar el tema."],"The field %(field)s cannot go, because %(by)s reads it. Change %(by)s first.":["El campo %(field)s no se puede quitar, porque %(by)s lo lee. Cambia antes %(by)s."],"The field %(field)s cannot hold the value it was given. Check what kind of field it is and enter a value that matches.":["El campo %(field)s no puede guardar el valor que se le ha dado. Comprueba de qué tipo de campo se trata e introduce un valor que encaje."],"The field %(field)s has to hold a value before this item can be published. Fill it in, or leave the item as a draft.":["El campo %(field)s tiene que tener un valor antes de poder publicar este elemento. Rellénalo, o deja el elemento como borrador."],"The field %(field)s in %(group)s still points at this content type. Delete that field first, then delete this type.":["El campo %(field)s de %(group)s todavía apunta a este tipo de contenido. Borra antes ese campo y luego borra este tipo."],"The field %(field)s is hidden by its own rules right now, so it cannot hold a value. Show it again before filling it in.":["El campo %(field)s está oculto por sus propias reglas ahora mismo, así que no puede guardar ningún valor. Vuelve a mostrarlo antes de rellenarlo."],"The field %(field)s points at one item only. Remove the extra choices and keep one.":["El campo %(field)s apunta a un solo elemento. Quita las opciones de más y deja una."],"The field %(field)s points at other items, so it does not take a plain value. Pick the items it points at instead.":["El campo %(field)s apunta a otros elementos, así que no admite un valor suelto. Elige en su lugar los elementos a los que apunta."],"The field %(field)s takes a list of items, and what arrived was not a list. Reload the page and pick the items again.":["El campo %(field)s admite una lista de elementos, y lo que ha llegado no era una lista. Recarga la página y vuelve a elegir los elementos."],"The field %(source)s cannot hold the value %(value)s, so no rule can compare it. Write a value of the kind %(source)s stores.":["El campo %(source)s no puede guardar el valor %(value)s, así que ninguna regla puede compararlo. Escribe un valor de la clase que %(source)s guarda."],"The field groups could not be loaded.":["No se han podido cargar los grupos de campos."],"The field groups could not be reached.":["No se ha podido contactar con los grupos de campos."],"The field keeps its values on the content both groups reach.":["El campo conserva sus valores en el contenido al que llegan ambos grupos."],"The fields this group holds go with it, and so do the values every item stored in them.":["Los campos que contiene este grupo se van con él, y también los valores que cada elemento guardó en ellos."],"The groups were reordered.":["Los grupos se han reordenado."],"The item could not be duplicated.":["No se ha podido duplicar el elemento."],"The item could not be moved to the trash.":["No se ha podido mover el elemento a la papelera."],"The item could not be permanently deleted.":["No se ha podido borrar permanentemente el elemento."],"The item could not be restored.":["No se ha podido restaurar el elemento."],"The item you picked as parent holds another kind of content. Pick a parent of the same content type as this item.":["El elemento que has elegido como superior tiene otro tipo de contenido. Elige un elemento superior del mismo tipo de contenido que este elemento."],"The item you picked as parent is in the trash. Restore it, or pick another parent.":["El elemento que has elegido como superior está en la papelera. Restáuralo, o elige otro elemento superior."],"The item you picked as parent no longer exists. Reload the page and pick another parent.":["El elemento que has elegido como superior ya no existe. Recarga la página y elige otro."],"The media library could not be loaded.":["No se ha podido cargar la biblioteca de medios."],"The name changes. Nothing stored under this field moves.":["El nombre cambia. Nada de lo guardado en este campo se mueve."],"The name could not be updated.":["No se ha podido actualizar el nombre."],"The new order has to name every field group exactly once. Reload the page and move the groups again.":["El nuevo orden tiene que nombrar cada grupo de campos exactamente una vez. Recarga la página y mueve los grupos otra vez."],"The new order has to name every field of this content type exactly once. Reload the page and move the fields again.":["El nuevo orden tiene que nombrar cada campo de este tipo de contenido exactamente una vez. Recarga la página y mueve los campos de nuevo."],"The number of revisions to keep has to be a whole number, and it cannot be negative. Enter a different number.":["El número de revisiones que se conservan tiene que ser un número entero, y no puede ser negativo. Introduce otro número."],"The registry could not be reached.":["No se ha podido acceder al registro."],"The role could not be changed.":["No se ha podido cambiar el rol."],"The rule on %(source)s names nothing to match. Pick a value, or remove the rule.":["La regla sobre %(source)s no nombra nada con lo que coincidir. Elige un valor, o quita la regla."],"The rules could not be loaded.":["No se han podido cargar las reglas."],"The rules on %(field)s lead back to %(field)s itself, so nothing can decide whether to show it. Point one of them at another field.":["Las reglas de %(field)s vuelven al propio %(field)s, así que nada puede decidir si mostrarlo. Apunta una de ellas a otro campo."],"The same item was picked twice for %(field)s. Pick each item once.":["El mismo elemento se ha elegido dos veces para %(field)s. Elige cada elemento una sola vez."],"The server could not be reached, so nothing was changed.":["No se ha podido acceder al servidor, así que no se ha cambiado nada."],"The server could not be reached, so nothing was saved.":["No se ha podido acceder al servidor, así que no se ha guardado nada."],"The server could not be reached, so nothing was uploaded.":["No se ha podido acceder al servidor, así que no se ha subido nada."],"The setting %(setting)s cannot hold the value it was given. Check what this setting takes and try again.":["El ajuste %(setting)s no puede contener el valor que se le dio. Comprueba qué acepta este ajuste y prueba de nuevo."],"The setting %(setting)s falls outside the others. Keep min below max and the default inside the bounds.":["El ajuste %(setting)s queda fuera de los demás. Mantén min por debajo de max y el valor por defecto dentro de los límites."],"The settings could not be read, so nothing can be changed here yet.":["No se han podido leer los ajustes, así que todavía no se puede cambiar nada aquí."],"The site default":["El idioma por defecto del sitio"],"The site may already hold part of this file. Import it again to see what is left.":["Puede que el sitio ya tenga parte de este archivo. Impórtalo de nuevo para ver lo que queda."],"The source %(source)s does not offer %(operator)s. Pick one of the comparisons it does offer.":["La fuente %(source)s no ofrece %(operator)s. Elige una de las comparaciones que sí ofrece."],"The theme %(name)s carries no %(file)s, so Gophenberg cannot tell what it is. Put that file at the top level of the zip and package the theme again.":["El tema %(name)s no lleva ningún %(file)s, así que Gophenberg no puede saber qué es. Pon ese archivo en el primer nivel del zip y vuelve a empaquetar el tema."],"The theme %(name)s could not be read, so it cannot serve your site. Upload it again, or choose another theme.":["El tema %(name)s no se ha podido leer, así que no puede servir tu sitio. Vuelve a subirlo, o elige otro tema."],"The theme %(name)s did not start, so your site is still served by the theme it had. Ask whoever looks after your site to check the server log for what the theme reported.":["El tema %(name)s no ha arrancado, así que tu sitio lo sigue sirviendo el tema que tenía. Pide a quien gestiona tu sitio que mire en el registro del servidor para saber qué problema ha informado el tema."],"The theme %(name)s does not carry %(path)s as a file, so there is nothing to run. Package the built theme rather than its source.":["El tema %(name)s no lleva %(path)s como archivo, así que no hay nada que ejecutar. Empaqueta el tema ya construido, no su código fuente."],"The theme %(name)s does not carry %(path)s as a folder, so its styles and scripts are missing. Package the built theme rather than its source.":["El tema %(name)s no lleva %(path)s como carpeta, así que le faltan los estilos y los scripts. Empaqueta el tema ya construido, no su código fuente."],"The theme %(name)s holds a link to another file at %(path)s, and a theme may only carry real files. Replace it and package the theme again.":["El tema %(name)s lleva un enlace a otro archivo en %(path)s, y un tema solo puede llevar archivos de verdad. Sustitúyelo y vuelve a empaquetar el tema."],"The theme %(name)s is serving your site right now, so it cannot be replaced. Deactivate it first, or install this copy under another name.":["El tema %(name)s sirve tu sitio ahora mismo, así que no se puede sustituir. Desactívalo primero, o instala esta copia con otro nombre."],"The theme %(name)s is the one chosen, but it is not installed, so your site is served by the built-in renderer. Upload the theme again, or choose another one.":["El tema %(name)s es el elegido, pero no está instalado, así que el renderizador integrado está prestando servicio a tu sitio. Vuelve a subir el tema, o elige otro."],"The theme %(name)s was built on theme kit %(declared)s, and this site serves %(served)s. Build the theme again against a kit this site serves, then install it.":["El tema %(name)s se compiló con el kit de temas %(declared)s y este sitio sirve %(served)s. Compila el tema de nuevo con un kit que este sitio sirva y después instálalo."],"The trash could not be emptied.":["No se ha podido vaciar la papelera."],"The user could not be created.":["No se ha podido crear el usuario."],"The values stored under it do not follow.":["Los valores guardados debajo no le siguen."],"Theme":["Tema"],"Themes":["Temas"],"Themes could not be loaded.":["No se han podido cargar los temas."],"There is no content type called %(value)s. Pick a type your site holds, or register it first.":["No existe ningún tipo de contenido llamado %(value)s. Elige un tipo que tenga tu sitio, o regístralo primero."],"There is no content type called %(value)s. Pick one of the types your site holds and try again.":["No existe ningún tipo de contenido llamado %(value)s. Elige uno de los tipos que tiene tu sitio e inténtalo de nuevo."],"There is no kind of field called %(value)s. Pick one of these instead: %(allowed)s.":["No existe ningún tipo de campo llamado %(value)s. Elige uno de estos: %(allowed)s."],"There is no page kind called %(value)s. Pick one of these instead: %(allowed)s.":["No existe ningún tipo de página llamado %(value)s. Elige uno de estos: %(allowed)s."],"There is no status called %(value)s. Pick one of these instead: %(allowed)s.":["No existe ningún estado llamado %(value)s. Elige uno de estos: %(allowed)s."],"There is nothing at this address. Check the link and try again.":["No hay nada en esta dirección. Comprueba el enlace e inténtalo de nuevo."],"There is nothing to roll back to, because your site has not been through a theme change yet. Install a theme first, then a rollback has somewhere to go.":["No hay nada a lo que retornar, porque tu sitio todavía no ha cambiado de tema. Instala un tema primero y así el retorno tendrá adónde ir."],"These files could not be read.":["No se han podido leer estos archivos."],"These were left alone, because nobody confirmed losing them.":["Estos se han dejado como estaban, porque nadie confirmó perderlos."],"This content type does not exist any more. Reload the page to see the types your site holds now.":["Este tipo de contenido ya no existe. Recarga la página para ver los tipos que tiene ahora tu sitio."],"This content type does not nest, so one of its items cannot sit inside another. Mark the type as Nests first.":["Este tipo de contenido no anida, así que uno de sus elementos no puede estar dentro de otro. Marca el tipo como Anida primero."],"This content type has no field called %(field)s. Check the name, or declare the field on the type first.":["Este tipo de contenido no tiene ningún campo llamado %(field)s. Comprueba el nombre, o declara antes el campo en el tipo."],"This content type is turned off, so its content is not served or edited. Activate it first.":["Este tipo de contenido está desactivado, así que su contenido ni se sirve ni se edita. Actívalo primero."],"This content type still holds content, so it cannot be deleted. Move or delete its items first.":["Este tipo de contenido todavía tiene contenido, así que no se puede borrar. Mueve o borra antes sus elementos."],"This definitions file is larger than the %(max)s bytes Gophenberg reads in one request. Split it, or raise the limit your site runs with.":["Este archivo de definiciones es mayor que los %(max)s bytes que Gophenberg lee en una petición. Divídelo, o sube el límite con el que funciona tu sitio."],"This field holds no layouts yet.":["Este campo todavía no tiene diseños."],"This field would stand inside too many containers. Put it nearer the top and try again.":["Este campo quedaría dentro de demasiados contenedores. Ponlo más cerca del nivel superior e inténtalo de nuevo."],"This file could not be opened as a zip. Package the theme again and upload the new file.":["Este archivo no se ha podido abrir como zip. Vuelve a empaquetar el tema y sube el archivo nuevo."],"This file is larger than the upload limit. Upload a smaller file.":["Este archivo es más grande que el límite de subida. Sube un archivo más pequeño."],"This file is named %(extension)s but it holds %(detected)s content. Upload it under a name that matches what is inside it.":["Este archivo se llama %(extension)s pero lleva contenido %(detected)s. Súbelo con un nombre que coincida con lo que lleva dentro."],"This file matches the site, so there is nothing to change.":["Este archivo coincide con el sitio, así que no hay nada que cambiar."],"This file says it was written in format %(declared)s, which is not a version Gophenberg can read. Export the definitions again from the site they came from.":["Este archivo dice estar escrito en el formato %(declared)s, que no es una versión que Gophenberg pueda leer. Vuelve a exportar las definiciones desde el sitio del que vinieron."],"This file was written in format %(declared)s, and this release reads %(served)s. Upgrade the site the file came from, or this one.":["Este archivo está escrito en el formato %(declared)s, y esta versión lee %(served)s. Actualiza el sitio del que viene el archivo, o este."],"This group holds no fields yet.":["Este grupo todavía no tiene campos."],"This install of Gophenberg has no install root, so it updates by hand.":["Esta instalación de Gophenberg no tiene raíz de instalación, así que se actualiza a mano."],"This is a custom build of Gophenberg, so it updates by hand.":["Esta es una compilación propia de Gophenberg, así que se actualiza a mano."],"This item cannot go straight to that status from the one it holds now. Save it as a draft first, then try again.":["Este elemento no puede pasar directamente a ese estado desde el que tiene ahora. Guárdalo antes como borrador e inténtalo de nuevo."],"This item does not exist, and it may have been deleted. Go back to the list to see what is there.":["Este elemento no existe, y puede que se haya borrado. Vuelve a la lista para ver qué hay."],"This item is already in the trash. Restore it first to work on it again.":["Este elemento ya está en la papelera. Restáuralo primero para volver a trabajar en él."],"This item is in the trash. Restore it first to work on it again.":["Este elemento está en la papelera. Restáuralo primero para volver a trabajar en él."],"This item is not in the trash, so there is nothing to restore. Open it from the list to work on it as it is.":["Este elemento no está en la papelera, así que no hay nada que restaurar. Ábrelo desde la lista para trabajar en él tal como está."],"This item still holds items nested inside it. Move or delete those first.":["Este elemento todavía tiene elementos anidados dentro. Mueve o borra antes esos."],"This kind of field holds no fields inside it. Put the field in a section or a repeater instead.":["Este tipo de campo no contiene campos dentro. Pon el campo en una sección o en un repetidor."],"This media item is not in the library, and someone may have deleted it. Reload the page to see what is there now.":["Este medio no está en la biblioteca, y puede que alguien lo haya borrado. Recarga la página para ver qué hay ahora."],"This picture could not be read, so nothing was stored. The file may be damaged, so open it on your computer and upload it again.":["Esta imagen no se ha podido leer, así que no se ha guardado nada. Puede que el archivo esté dañado, así que ábrelo en tu ordenador y vuelve a subirlo."],"This picture is %(width)s by %(height)s pixels, over the limit of %(max)s pixels in all. Make it smaller and upload it again.":["Esta imagen tiene %(width)s por %(height)s píxeles, por encima del límite de %(max)s píxeles en total. Hazla más pequeña y vuelve a subirla."],"This post changed elsewhere. Reload before saving again.":["Esta entrada ha cambiado en otro sitio. Recarga antes de volver a guardar."],"This request came from a page on another site, so it was refused. Open the admin on this site and try again.":["Esta petición vino de una página de otro sitio, así que se rechazó. Abre la administración en este sitio e inténtalo de nuevo."],"This request carried %(attribute)s, which fields do not take. Reload the page so the admin and the server agree again.":["Esta petición llevaba %(attribute)s, que los campos no aceptan. Recarga la página para que la administración y el servidor vuelvan a coincidir."],"This request left out %(field)s, which Gophenberg needs. Reload the page and try again.":["En esta petición faltaba %(field)s, que Gophenberg necesita. Recarga la página e inténtalo de nuevo."],"This rule reads a source called %(source)s, and nothing on your site declares one. A plugin that added it may have been removed.":["Esta regla lee una fuente llamada %(source)s, y nada en tu sitio declara ninguna. Puede que se haya quitado un plugin que la añadía."],"This site cannot write to its themes folder, so no theme can be installed. Ask whoever looks after your site to make the folder that %(setting)s points at writable. A read-only mount is the usual cause.":["Este sitio no puede escribir en su carpeta de temas, así que no se puede instalar ninguno. Pide a quien gestione tu sitio que haga escribible la carpeta a la que apunta %(setting)s. La causa habitual es un montaje de solo lectura."],"This site has nowhere to keep themes, so no theme can be installed. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Este sitio no tiene dónde guardar los temas, así que no se puede instalar ninguno. Pide a quien gestione tu sitio que apunte %(setting)s a una carpeta en la que el servidor pueda escribir."],"This site has nowhere to store uploads, so nothing can be uploaded. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Este sitio no tiene dónde guardar las subidas, así que no se puede subir nada. Pide a quien gestione tu sitio que apunte %(setting)s a una carpeta en la que el servidor pueda escribir."],"This theme is over the limit of %(max)s bytes, packed or unpacked. Leave out what the theme does not need and package it again.":["Este tema pasa del límite de %(max)s bytes, comprimido o sin comprimir. Saca lo que el tema no necesite y vuelve a empaquetarlo."],"This type will answer under /%(word)s.":["Este tipo responderá como /%(word)s."],"This zip holds %(entries)s files, more than the %(max)s a theme may carry. Package only the built theme.":["Este zip lleva %(entries)s archivos, más de los %(max)s que puede llevar un tema. Empaqueta solo el tema ya construido."],"Through":["A través de"],"Thumbnail":["Miniatura"],"Title":["Título"],"Two field groups in this file share the key %(key)s. Give one of them a different key and import again.":["Dos grupos de campos de este archivo comparten la clave %(key)s. Dale una clave distinta a uno de ellos e importa de nuevo."],"Two rule sources are registered under the name %(param)s. A plugin is declaring a source your site already holds.":["Hay dos fuentes de reglas registradas con el nombre %(param)s. Un plugin declara una fuente que tu sitio ya tiene."],"Type":["Tipo"],"Type a whole number in both boxes before saving.":["Escribe un número entero en las dos casillas antes de guardar."],"Undo":["Deshacer"],"Unsaved changes":["Cambios sin guardar"],"Update":["Actualizar"],"Update failed.":["No se ha podido actualizar."],"Upload theme":["Subir tema"],"Uploaded %s file.":["%s archivo subido.","%s de archivos subidos.","%s archivos subidos."],"Users":["Usuarios"],"Users could not be loaded.":["No se han podido cargar los usuarios."],"Value":["Valor"],"Version":["Versión"],"Videos":["Vídeos"],"Visit Site":["Visitar el sitio"],"Web address":["Dirección web"],"What each Linked from field reads":["Qué lee cada campo Enlazado desde"],"What it points at changed, so its stored values go with it.":["Aquello a lo que apunta ha cambiado, así que sus valores guardados se van con él."],"Where %(group)s appears":["Dónde aparece %(group)s"],"Without a rule this field always shows.":["Sin ninguna regla este campo siempre se muestra."],"Without a rule this group appears nowhere.":["Sin una regla este grupo no aparece en ningún sitio."],"Yes":["Sí"],"Yes or no":["Sí o no"],"You cannot change your own role.":["No puedes cambiar tu propio rol."],"Your language":["Tu idioma"],"Your role does not allow %(capability)s.":["Tu rol no permite %(capability)s."],"Your server pins the site to the theme %(name)s, so themes cannot be changed here. Ask whoever looks after your site to remove the pin.":["Tu servidor bloque el sitio al tema %(name)s, así que los temas no se pueden cambiar desde aquí. Pide a quien gestiona tu sitio que quite ese bloqueo."],"Your site always needs one default content type, so this one cannot be turned off or deleted. Make another type the default first.":["Tu sitio siempre necesita un tipo de contenido por defecto, así que este no se puede desactivar ni borrar. Haz antes que otro tipo sea el que está por defecto."],"a content type":["un tipo de contenido"],"account\u0004Role":["Rol"],"account\u0004Status":["Estado"],"action label\u0004Duplicate":["Duplicar"],"action label\u0004Duplicate…":["Duplicar…"],"address":["dirección"],"admin section\u0004Content Types":["Tipos de contenido"],"admin section\u0004Field Groups":["Grupos de campos"],"admin section\u0004Language":["Idioma"],"admin section\u0004Media":["Medios"],"admin section\u0004Settings":["Ajustes"],"admin section\u0004Themes":["Temas"],"column\u0004Date":["Fecha"],"content type\u0004\"%s\" permanently deleted.":["Se ha borrado permanentemente el tipo de contenido «%s»."],"content type\u0004\"%s\" successfully created.":["Se ha creado correctamente el tipo de contenido «%s»."],"content type\u0004Status":["Estado"],"field\u0004Name":["Nombre"],"field group\u0004Status":["Estado"],"field type\u0004Choice":["Elección"],"field type\u0004Color":["Color"],"field type\u0004Date":["Fecha"],"field type\u0004Flexible content":["Contenido flexible"],"field type\u0004Gallery":["Galería"],"field type\u0004Kind":["Tipo"],"field type\u0004Layout":["Diseño"],"field type\u0004Link":["Enlace"],"field type\u0004Media":["Medio"],"field type\u0004Range":["Rango"],"field type\u0004Repeater":["Repetidor"],"field type\u0004Section":["Sección"],"field type\u0004Select":["Desplegable"],"file size\u0004%1$s %2$s":["%1$s %2$s"],"file type group\u0004Archives":["Archivos"],"file type group\u0004Audio":["Audio"],"file type group\u0004Video":["Vídeo"],"holds":["contiene"],"holds anything":["contiene algo"],"https://www.w3.org/WAI/tutorials/images/decision-tree/":["https://www.w3.org/WAI/tutorials/images/decision-tree/"],"image dimensions\u0004%1$s × %2$s":["%1$s × %2$s"],"is":["es"],"is above":["es mayor que"],"is below":["es menor que"],"is empty":["está vacío"],"is not":["no es"],"panel button label\u0004Settings":["Ajustes"],"person\u0004Name":["Nombre"],"post\u0004%s (Copy)":["%s (copia)"],"post\u0004Status":["Estado"],"post status\u0004Draft":["Borrador"],"post status\u0004Published":["Publicada"],"post status\u0004Trash":["Papelera"],"post type general name\u0004Pages":["Páginas"],"post type general name\u0004Posts":["Entradas"],"post type singular name\u0004Page":["Página"],"post type singular name\u0004Post":["Entrada"],"relation target\u0004None":["Ninguno"],"role\u0004Administrator":["Administrador"],"role\u0004Author":["Autor"],"role\u0004Editor":["Editor"],"role\u0004Member":["Miembro"],"role\u0004No role":["Sin rol"],"status filter\u0004All":["Todo"],"taxonomy general name\u0004Categories":["Categorías"],"taxonomy singular name\u0004Category":["Categoría"],"theme\u0004Status":["Estado"],"trash\u0004Restore":["Restaurar"],"unit symbol\u0004B":["B"],"unit symbol\u0004EB":["EB"],"unit symbol\u0004GB":["GB"],"unit symbol\u0004KB":["KB"],"unit symbol\u0004MB":["MB"],"unit symbol\u0004PB":["PB"],"unit symbol\u0004TB":["TB"],"unit symbol\u0004YB":["YB"],"unit symbol\u0004ZB":["ZB"],"v%(version)s":["v%(version)s"],"verb\u0004Trash…":["Papelera…"],"verb\u0004View":["Ver"],"{day} {month} {year}":["{day} de {month} de {year}"]} diff --git a/frontend/src/languages/fr-FR.json b/frontend/src/languages/fr-FR.json index b2c4a848..89c8e8dd 100644 --- a/frontend/src/languages/fr-FR.json +++ b/frontend/src/languages/fr-FR.json @@ -1 +1 @@ -{"":{"lang":"fr","plural-forms":"nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;"}," and ":[" et "]," or ":[" ou "],"\"%s\" activated.":["Type de contenu « %s » activé."],"\"%s\" deactivated.":["Type de contenu « %s » désactivé."],"\"%s\" has been restored.":["« %s » a été rétabli."],"\"%s\" moved to the trash.":["« %s » déplacé dans la corbeille."],"\"%s\" permanently deleted.":["« %s » supprimé définitivement."],"\"%s\" successfully created.":["« %s » créé avec succès."],"\"%s\" uploaded.":["« %s » téléversé."],"%(field)s address":["Adresse de %(field)s"],"%(field)s cannot move inside itself. Pick a container that is not one of its own.":["%(field)s ne peut pas être déplacé à l'intérieur de lui-même. Choisissez un conteneur qui n'est pas l'un des siens."],"%(field)s declared.":["%(field)s déclaré."],"%(field)s deleted.":["%(field)s supprimé."],"%(field)s does not match the format its field asks for. Check it and save again.":["%(field)s ne respecte pas le format demandé par son champ. Vérifiez-le et enregistrez de nouveau."],"%(field)s goes no higher than %(limit)s. Lower the value and save again.":["%(field)s ne dépasse pas %(limit)s. Baissez la valeur et enregistrez de nouveau."],"%(field)s goes no lower than %(limit)s. Raise the value and save again.":["%(field)s ne descend pas sous %(limit)s. Augmentez la valeur et enregistrez de nouveau."],"%(field)s holds no value of its own, so it cannot be required. Make it optional.":["%(field)s ne contient aucune valeur propre, il ne peut donc pas être obligatoire. Rendez-le facultatif."],"%(field)s is optional again.":["%(field)s est de nouveau facultatif."],"%(field)s is required now.":["%(field)s est maintenant obligatoire."],"%(field)s moved.":["%(field)s déplacé."],"%(field)s names the same one twice. Remove the repeat and save again.":["%(field)s nomme le même deux fois. Retirez la répétition et enregistrez de nouveau."],"%(field)s needs at least %(limit)s files. Add one and save again.":["%(field)s demande au moins %(limit)s fichiers. Ajoutez-en un et enregistrez."],"%(field)s needs at least %(limit)s rows. Add one and save again.":["%(field)s demande au moins %(limit)s lignes. Ajoutez-en une et enregistrez."],"%(field)s only takes one of its listed choices. Pick one and save again.":["%(field)s accepte uniquement un des choix de sa liste. Choisissez-en un et enregistrez de nouveau."],"%(field)s reads a relation field that is not there. Pick a group and a relation field it holds.":["%(field)s lit un champ de relation qui n'existe pas. Choisissez un groupe et un champ de relation qu'il contient."],"%(field)s reads a relation pointing at %(type)s, so it would list nothing here. Pick a relation pointing at the items this group is shown on.":["%(field)s lit une relation qui pointe vers %(type)s, elle n'afficherait donc rien ici. Choisissez une relation qui pointe vers les éléments où ce groupe s'affiche."],"%(field)s renamed.":["%(field)s renommé."],"%(field)s settled.":["%(field)s réglé."],"%(field)s shows by its rules now.":["%(field)s s'affiche désormais selon ses règles."],"%(field)s stands in the list.":["%(field)s figure dans la liste."],"%(field)s stays out of the list.":["%(field)s reste hors de la liste."],"%(field)s takes at most %(limit)s files. Remove one and save again.":["%(field)s accepte au plus %(limit)s fichiers. Retirez-en un et enregistrez."],"%(field)s takes at most %(limit)s letters. Shorten what you wrote and save again.":["%(field)s accepte au plus %(limit)s lettres. Raccourcissez ce que vous avez écrit et enregistrez de nouveau."],"%(field)s takes at most %(limit)s rows. Remove one and save again.":["%(field)s accepte au plus %(limit)s lignes. Retirez-en une et enregistrez."],"%(field)s title":["Titre de %(field)s"],"%(file)s is larger than one upload may be.":["%(file)s dépasse la taille maximale d'un téléversement."],"%(file)s is not a file this block accepts.":["%(file)s n'est pas un fichier que ce bloc accepte."],"%(file)s: %(reason)s":["%(file)s : %(reason)s"],"%(group)s created.":["%(group)s créé."],"%(group)s now appears where you said.":["%(group)s apparaît maintenant là où vous avez dit."],"%(group)s removed.":["%(group)s supprimé."],"%(group)s updated.":["%(group)s mis à jour."],"%(key)s is served by %(winner)s, so %(loser)s does not show it on %(where)s.":["%(key)s est servi par %(winner)s, donc %(loser)s ne le montre pas sur %(where)s."],"%(name)s %(version)s is serving the public site.":["%(name)s %(version)s sert le site public."],"%(name)s belongs to the site now.":["%(name)s appartient désormais au site."],"%(name)s came from the %(plugin)s plugin, which no longer declares it.":["%(name)s venait de l'extension %(plugin)s, qui ne le déclare plus."],"%(name)s cannot be used as a theme name. Name the zip file after the theme, with no dot at the start and no slashes in it.":["%(name)s ne peut pas servir de nom de thème. Nommez le fichier zip comme le thème, sans point au début et sans barre oblique."],"%(name)s moves to /%(word)s. Every address of both types changes.":["Le type %(name)s passe à /%(word)s. Toutes les adresses des deux types changent."],"%(name)s would move to /%(word)s, which Gophenberg keeps for itself. Change its plural name and try again.":["Le type %(name)s passerait à /%(word)s, que Gophenberg garde pour lui. Modifiez son nom au pluriel et réessayez."],"%(source)s is %(value)s":["%(source)s est %(value)s"],"%(theme)s failed to start, so the built-in renderer is serving.":["%(theme)s n'a pas pu démarrer, le moteur de rendu intégré sert donc le site."],"%(theme)s is now serving the public site.":["%(theme)s sert désormais le site public."],"%(theme)s is serving the public site.":["%(theme)s sert le site public."],"%(theme)s is still starting, so the built-in renderer is serving.":["%(theme)s démarre encore, le moteur de rendu intégré sert donc le site."],"%(theme)s was installed.":["%(theme)s a été installé."],"%(theme)s will not load, so the built-in renderer is serving.":["%(theme)s ne se charge pas, le moteur de rendu intégré sert donc le site."],"%(type)s answers on a new address, and stored links move with it.":["%(type)s répond à une nouvelle adresse, et les liens enregistrés la suivent."],"%(type)s becomes the type the site opens on, and the old one takes an address.":["%(type)s devient le type sur lequel le site ouvre, et l'ancien prend une adresse."],"%(type)s keeps nesting while %(items)s of its items sit inside another. Move those to the top level first, the ones in the trash included.":["%(type)s continue de s'imbriquer tant que %(items)s de ses éléments se trouvent dans un autre. Déplacez-les d'abord au niveau supérieur, ceux de la corbeille compris."],"%(type)s keeps nesting, because some of its items sit inside another.":["%(type)s continue de s'imbriquer, car certains de ses éléments se trouvent dans un autre."],"%(type)s will answer at the root.":["Le type %(type)s répondra à la racine."],"%s content type activated.":["%s type de contenu activé.","%s types de contenu activés.","%s types de contenu activés."],"%s content type could not be activated.":["%s type de contenu n'a pas pu être activé.","%s types de contenu n'ont pas pu être activés.","%s types de contenu n'ont pas pu être activés."],"%s content type could not be deactivated.":["%s type de contenu n'a pas pu être désactivé.","%s types de contenu n'ont pas pu être désactivés.","%s types de contenu n'ont pas pu être désactivés."],"%s content type could not be permanently deleted.":["%s type de contenu n'a pas pu être supprimé définitivement.","%s types de contenu n'ont pas pu être supprimés définitivement.","%s types de contenu n'ont pas pu être supprimés définitivement."],"%s content type deactivated.":["%s type de contenu désactivé.","%s types de contenu désactivés.","%s types de contenu désactivés."],"%s content type permanently deleted.":["%s type de contenu supprimé définitivement.","%s types de contenu supprimés définitivement.","%s types de contenu supprimés définitivement."],"%s item could not be moved to the trash.":["%s élément n'a pas pu être déplacé dans la corbeille.","%s éléments n'ont pas pu être déplacés dans la corbeille.","%s éléments n'ont pas pu être déplacés dans la corbeille."],"%s item could not be permanently deleted.":["%s élément n'a pas pu être supprimé définitivement.","%s éléments n'ont pas pu être supprimés définitivement.","%s éléments n'ont pas pu être supprimés définitivement."],"%s item could not be restored.":["%s élément n'a pas pu être rétabli.","%s éléments n'ont pas pu être rétablis.","%s éléments n'ont pas pu être rétablis."],"%s item moved to the trash.":["%s élément déplacé dans la corbeille.","%s éléments déplacés dans la corbeille.","%s éléments déplacés dans la corbeille."],"%s item permanently deleted.":["%s élément supprimé définitivement.","%s éléments supprimés définitivement.","%s éléments supprimés définitivement."],"%s item stays in the trash.":["%s élément reste dans la corbeille.","%s éléments restent dans la corbeille.","%s éléments restent dans la corbeille."],"%s page has been restored.":["%s page a été rétablie.","%s pages ont été rétablies.","%s pages ont été rétablies."],"%s post has been restored.":["%s article a été rétabli.","%s articles ont été rétablis.","%s articles ont été rétablis."],"(no title)":["(sans titre)"],"A %(kind)s field does not take a setting called %(setting)s. Remove it and try again.":["Un champ %(kind)s n'accepte pas de réglage nommé %(setting)s. Retirez-le et réessayez."],"A Linked from field stands on its own, not inside a section or a repeater. Declare %(field)s beside the container.":["Un champ Lié depuis se place seul, ni dans une section ni dans un répéteur. Déclarez %(field)s à côté du conteneur."],"A content type needs a singular and a plural name, like Guide and Guides. Fill both in and try again.":["Un type de contenu a besoin d'un nom au singulier et d'un nom au pluriel, comme Guide et Guides. Remplissez les deux et réessayez."],"A descriptive summary of the content type.":["Un résumé descriptif du type de contenu."],"A field group key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the group in the file so its key comes out in that shape.":["La clé d'un groupe de champs doit commencer par une lettre minuscule puis ne contenir que des lettres minuscules, des chiffres et des tirets. Renommez le groupe dans le fichier pour que sa clé prenne cette forme."],"A field key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the field so its key comes out in that shape.":["La clé d'un champ doit commencer par une lettre minuscule et ne contenir ensuite que des lettres minuscules, des chiffres et des tirets. Renommez le champ pour que sa clé prenne cette forme."],"A field needs a name. Type one and try again.":["Un champ a besoin d'un nom. Saisissez-en un et réessayez."],"A field with that name already exists on the items this group reaches. Pick another name.":["Un champ portant ce nom existe déjà sur les éléments que ce groupe atteint. Choisissez un autre nom."],"A flexible content field holds layouts, and %(field)s is not one. Add a layout first, then declare the field inside that layout.":["Un champ de contenu flexible contient des dispositions, et %(field)s n'en est pas une. Ajoutez d'abord une disposition, puis déclarez le champ dans cette disposition."],"A higher quality stores larger files, and it applies to pictures uploaded from now on.":["Une qualité plus élevée stocke des fichiers plus grands, et elle s'applique aux images téléversées à partir de maintenant."],"A layout stands inside a flexible content field only. Put %(field)s inside one of those instead.":["Une disposition ne se place que dans un champ de contenu flexible. Placez %(field)s dans l'un d'eux."],"A layout stands inside a flexible content field, never on its own. Declare a flexible content field first, then add %(field)s inside it.":["Une disposition se place dans un champ de contenu flexible, jamais seule. Déclarez d'abord un champ de contenu flexible, puis ajoutez %(field)s dedans."],"A listing carries between 1 and %(max)s items, so %(value)s is not a size Gophenberg takes. Pick a number inside that range.":["Une liste affiche entre 1 et %(max)s éléments, donc %(value)s n'est pas une taille que Gophenberg accepte. Choisissez un nombre dans cet intervalle."],"A relation field has to say which content type it points at. Pick that type and try again.":["Un champ de relation doit indiquer le type de contenu qu'il vise. Choisissez ce type et réessayez."],"A reload shows the admin in the language you chose.":["Un rechargement affiche l'administration dans la langue que vous avez choisie."],"A route word has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change it and try again.":["Un mot de route doit commencer par une lettre minuscule et ne contenir ensuite que des lettres minuscules, des chiffres et des tirets. Modifiez-le et réessayez."],"A row of %(field)s has to name the layout it takes. Pick a layout for the row, or remove the row.":["Une ligne de %(field)s doit nommer la disposition qu'elle prend. Choisissez une disposition pour la ligne, ou retirez la ligne."],"A row of %(field)s takes one layout and no more. Reload the page and build the row again.":["Une ligne de %(field)s prend une seule disposition. Rechargez la page et reconstruisez la ligne."],"A rule matching any %(source)s cannot be turned into an exclusion. Leave it as is, or name the ones to leave out instead.":["Une règle valable pour n'importe quel %(source)s ne peut pas devenir une exclusion. Laissez-la telle quelle, ou nommez ceux à écarter."],"A theme naming its own page size uses that instead.":["Un thème qui indique sa propre taille de page utilise celle-ci à la place."],"A type key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change the key and try again.":["La clé d'un type doit commencer par une lettre minuscule et ne contenir ensuite que des lettres minuscules, des chiffres et des tirets. Modifiez la clé et réessayez."],"Actions":["Actions"],"Activate":["Activer"],"Activate %(theme)s":["Activer %(theme)s"],"Active":["Actif"],"Add":["Ajouter"],"Add %(layout)s":["Ajouter %(layout)s"],"Add %(name)s":["Ajouter %(name)s"],"Add %(type)s":["Ajouter %(type)s"],"Add Category":["Ajouter une catégorie"],"Add Content Type":["Ajouter un type de contenu"],"Add Field Group":["Ajouter un groupe de champs"],"Add Media File":["Ajouter un fichier média"],"Add Page":["Ajouter une page"],"Add Post":["Ajouter un article"],"Add User":["Ajouter un utilisateur"],"Add block":["Ajouter un bloc"],"Add choice":["Ajouter un choix"],"Add condition":["Ajouter une condition"],"Add field":["Ajouter un champ"],"Add field to %(field)s":["Ajouter un champ à %(field)s"],"Add layout":["Ajouter une disposition"],"Add layout to %(field)s":["Ajouter une disposition à %(field)s"],"Add one with %(button)s.":["Ajoutez un élément avec %(button)s."],"Add row":["Ajouter une ligne"],"Add rule set":["Ajouter un ensemble de règles"],"Add title":["Ajouter un titre"],"Add to %(field)s":["Ajouter à %(field)s"],"Address":["Adresse"],"Address updated.":["Adresse mise à jour."],"Adopt":["Adopter"],"Allow custom":["Autoriser les valeurs libres"],"Allow empty":["Autoriser le vide"],"Alt text":["Texte alternatif"],"An item cannot point at itself. Pick a different item for %(field)s.":["Un élément ne peut pas se viser lui-même. Choisissez un autre élément pour %(field)s."],"An item cannot sit inside itself, or inside one of the items nested under it. Pick a parent outside this branch.":["Un élément ne peut pas se trouver dans lui-même, ni dans un élément imbriqué sous lui. Choisissez un parent hors de cette branche."],"Another account wrote this, and only its author, an editor or an administrator may change it. You can still read it.":["Un autre compte a écrit ceci, et seuls son auteur, un éditeur ou un administrateur peuvent le modifier. Vous pouvez toujours le lire."],"Another account wrote this, so you are reading it rather than editing it.":["Un autre compte a écrit ceci, alors vous le lisez au lieu de le modifier."],"Another content type already answers under this route word. Pick a different word.":["Un autre type de contenu répond déjà sous ce mot de route. Choisissez un autre mot."],"Another content type already lives at the root of your site, and only one can. Give this type a route word, or hand the root over with Make default.":["Un autre type de contenu occupe déjà la racine de votre site, et un seul le peut. Donnez un mot de route à ce type, ou cédez la racine avec Rendre par défaut."],"Another content type already uses this key. Give this type a different name.":["Un autre type de contenu utilise déjà cette clé. Donnez un autre nom à ce type."],"Another item of this content type already uses that address. Pick a different slug and save again.":["Un autre élément de ce type de contenu utilise déjà cette adresse. Choisissez un autre slug et enregistrez de nouveau."],"Another update is currently in progress.":["Une autre mise à jour est actuellement en cours."],"Appears on":["Apparaît sur"],"Apply":["Appliquer"],"April":["avril"],"Are you sure you want to move \"%s\" to the trash?":["Voulez-vous vraiment déplacer « %s » dans la corbeille ?"],"Audio":["Audio"],"August":["août"],"Author":["Auteur"],"Back to %(type)s":["Retour aux %(type)s"],"Back to the latest posts":["Retour aux derniers articles"],"Block":["Bloc"],"Broken":["Cassé"],"Bundles of fields, shown where their rules place them.":["Ensembles de champs, affichés là où leurs règles les placent."],"Button group":["Groupe de boutons"],"Cancel":["Annuler"],"Caption":["Légende"],"Change %(name)s":["Modifier %(name)s"],"Change address":["Changer l'adresse"],"Change address…":["Changer l'adresse…"],"Change role":["Changer le rôle"],"Change the role of %(name)s":["Changer le rôle de %(name)s"],"Checkbox group":["Groupe de cases à cocher"],"Choices":["Choix"],"Choose %(field)s":["Choisir %(field)s"],"Choose how the site serves its content.":["Choisissez comment le site sert son contenu."],"Choose the language the admin reads in.":["Choisissez la langue dans laquelle se lit l'administration."],"Choose the role of %(name)s.":["Choisissez le rôle de %(name)s."],"Choose the version to update to.":["Choisissez la version vers laquelle mettre à jour."],"Clear %(field)s":["Vider %(field)s"],"Close":["Fermer"],"Close Settings":["Fermer les réglages"],"Closed":["Fermé"],"Condition":["Condition"],"Content":["Contenu"],"Content Types":["Types de contenu"],"Content nests at most %(max)s levels deep, and this item would sit deeper. Pick a parent closer to the top.":["Le contenu s'imbrique sur au plus %(max)s niveaux, et cet élément irait plus profond. Choisissez un parent plus proche du sommet."],"Content type":["Type de contenu"],"Content type updated.":["Type de contenu mis à jour."],"Could not delete that item.":["Impossible de supprimer cet élément.","Impossible de supprimer tous les éléments.","Impossible de supprimer tous les éléments."],"Could not load that post.":["Impossible de charger cet article."],"Could not save that post.":["Impossible d'enregistrer cet article."],"Create":["Créer"],"Create a field group":["Créer un groupe de champs"],"Create user":["Créer l'utilisateur"],"Created":["Date de création"],"Date added":["Date d'ajout"],"Deactivate":["Désactiver"],"Deactivate %(theme)s":["Désactiver %(theme)s"],"December":["décembre"],"Default":["Par défaut"],"Default content type updated.":["Type de contenu par défaut mis à jour."],"Definitions file":["Fichier de définitions"],"Delete":["Supprimer"],"Delete \"%(title)s\" for good? This cannot be undone.":["Supprimer « %(title)s » définitivement ? Cette action est irréversible."],"Delete %(field)s":["Supprimer %(field)s"],"Delete %(group)s":["Supprimer %(group)s"],"Delete %(title)s for good? This cannot be undone.":["Supprimer %(title)s définitivement ? Cette action est irréversible."],"Delete %s content type for good? This cannot be undone.":["Supprimer %s type de contenu définitivement ? Cette action est irréversible.","Supprimer %s types de contenu définitivement ? Cette action est irréversible.","Supprimer %s types de contenu définitivement ? Cette action est irréversible."],"Delete %s item for good? This cannot be undone.":["Supprimer %s élément définitivement ? Cette action est irréversible.","Supprimer %s éléments définitivement ? Cette action est irréversible.","Supprimer %s éléments définitivement ? Cette action est irréversible."],"Delete Permanently":["Supprimer définitivement"],"Delete the field":["Supprimer le champ"],"Delete the group":["Supprimer le groupe"],"Delete this %(count)s item for good? This cannot be undone.":["Supprimer définitivement ce %(count)s élément ? Cette action est irréversible.","Supprimer définitivement ces %(count)s éléments ? Cette action est irréversible.","Supprimer définitivement ces %(count)s éléments ? Cette action est irréversible."],"Delete…":["Supprimer…"],"Describe":["Décrire"],"Describe…":["Décrire…"],"Description":["Description"],"Description updated.":["Description mise à jour."],"Desktop":["Ordinateur"],"Destination":["Destination"],"Dimensions":["Dimensions"],"Disable":["Désactiver"],"Disable %(name)s":["Désactiver %(name)s"],"Disabled":["Désactivé"],"Dismiss":["Ignorer"],"Document":["Document"],"Done. The site now holds what the file describes.":["Terminé. Le site contient maintenant ce que décrit le fichier."],"Draft saved.":["Brouillon enregistré."],"Drop files to upload":["Déposez vos fichiers pour les téléverser"],"Email":["E-mail"],"Empty Trash":["Vider la corbeille"],"Empty trash":["Vider la corbeille"],"Enable":["Activer"],"Enable %(name)s":["Activer %(name)s"],"Every address of this type moves. Links kept elsewhere to the old addresses stop working.":["Toutes les adresses de ce type changent. Les liens conservés ailleurs vers les anciennes adresses cessent de fonctionner."],"Every content type":["Tous les types de contenu"],"Every field group needs a title. Give it a name and save again.":["Chaque groupe de champs a besoin d'un titre. Donnez-lui un nom et enregistrez à nouveau."],"Every item needs an author who exists. Pick one from the list and save again.":["Chaque élément a besoin d'un auteur existant. Choisissez-en un dans la liste et enregistrez de nouveau."],"Every kind of content this site holds.":["Toutes les sortes de contenu que ce site contient."],"Every value stored under this field goes with it, in every item the group reaches and in the revisions behind them.":["Toutes les valeurs enregistrées dans ce champ partent avec lui, dans chaque élément que le groupe atteint et dans les révisions qui les suivent."],"Excerpt":["Extrait"],"Export definitions":["Exporter les définitions"],"February":["février"],"Fewest files":["Minimum de fichiers"],"Fewest rows":["Minimum de lignes"],"Field":["Champ"],"Field Groups":["Groupes de champs"],"Field group":["Groupe de champs"],"Fields":["Champs"],"Fields of %(group)s":["Champs de %(group)s"],"File name":["Nom du fichier"],"File size":["Taille du fichier"],"File type":["Type de fichier"],"Files":["Fichiers"],"Filter by status":["Filtrer par état"],"Follow the browser":["Suivre le navigateur"],"From %(plugin)s":["De %(plugin)s"],"Full Size":["Taille originale"],"Gophenberg cannot start this update: %(reason)s":["Gophenberg ne peut pas lancer cette mise à jour : %(reason)s"],"Gophenberg checked for updates moments ago. Try again later.":["Gophenberg a cherché des mises à jour il y a un instant. Réessayez plus tard."],"Gophenberg could not read what your browser sent. Reload the page and try again, and tell whoever looks after your site if it keeps happening.":["Gophenberg n'a pas pu lire ce que votre navigateur a envoyé. Rechargez la page et réessayez, et prévenez la personne qui s'occupe de votre site si cela continue."],"Gophenberg could not read which item you picked as parent. Reload the page and pick it again.":["Gophenberg n'a pas pu lire quel élément vous avez choisi comme parent. Rechargez la page et choisissez-le de nouveau."],"Gophenberg does not accept %(extension)s files. Upload a picture, a video, an audio file, a PDF or a zip instead.":["Gophenberg n'accepte pas les fichiers %(extension)s. Téléversez plutôt une image, une vidéo, un fichier audio, un PDF ou un zip."],"Gophenberg does not answer in %(value)s. Pick one of the languages on the list.":["Gophenberg ne répond pas en %(value)s. Choisissez une des langues de la liste."],"Gophenberg has no autosave of yours for this item. Open the item to see the version that is stored.":["Gophenberg n'a aucun enregistrement automatique de vous pour cet élément. Ouvrez l'élément pour voir la version stockée."],"Gophenberg holds no kind of definition called %(subject)s, so nothing was taken over. Reload the page so the admin and the server agree again.":["Gophenberg ne connaît aucune sorte de définition nommée %(subject)s, rien n'a donc été repris. Rechargez la page pour que l'admin et le serveur se rejoignent à nouveau."],"Gophenberg keeps /%(path)s for itself or for another content type, so your content cannot answer there. Give the item a different slug.":["Gophenberg garde l'adresse /%(path)s pour lui ou pour un autre type de contenu, votre contenu ne peut donc pas y répondre. Donnez un autre slug à l'élément."],"Gophenberg keeps this word for itself, so no content type can answer under it. Pick a different route word.":["Gophenberg garde ce mot pour lui, aucun type de contenu ne peut donc répondre sous ce mot. Choisissez un autre mot de route."],"Group":["Groupe"],"Highest":["Maximum"],"Holds":["Contient"],"Images":["Images"],"Import definitions":["Importer les définitions"],"In list":["Dans la liste"],"Inactive":["Inactif"],"Installed":["Installé"],"Instructions":["Instructions"],"It moves here from another group and keeps its stored values.":["Il arrive ici depuis un autre groupe et garde ses valeurs enregistrées."],"It moves to another group and keeps its stored values.":["Il passe dans un autre groupe et garde ses valeurs enregistrées."],"It moves to another group, so its stored values do not follow.":["Il passe dans un autre groupe, donc ses valeurs enregistrées ne le suivent pas."],"It reads a relation inside a container, and only an import can point it elsewhere.":["Il lit une relation dans un conteneur, et seule une importation peut le faire pointer ailleurs."],"It starts on %(type)s, and the rules may be edited afterwards.":["Il commence sur %(type)s, et les règles peuvent être modifiées ensuite."],"Items could not be loaded.":["Les éléments n'ont pas pu être chargés."],"Its items sit inside one another, so it keeps nesting.":["Ses éléments se trouvent les uns dans les autres, donc il continue de s'imbriquer."],"Its kind changed, so its stored values go with it.":["Son type a changé, donc ses valeurs enregistrées partent avec lui."],"January":["janvier"],"July":["juillet"],"June":["juin"],"Keep %(field)s out of the list":["Garder %(field)s hors de la liste"],"Keep it":["Le conserver"],"Keep it here":["Le laisser ici"],"Key":["Clé"],"Label":["Libellé"],"Language":["Langue"],"Large":["Grande"],"Let items nest":["Autoriser l'imbrication"],"Linked from":["Lié depuis"],"List view":["Vue en liste"],"Loading field groups.":["Chargement des groupes de champs."],"Loading the post.":["Chargement de l'article."],"Loading the screen.":["Chargement de l'écran."],"Loading themes.":["Chargement des thèmes."],"Loading users.":["Chargement des utilisateurs."],"Loading…":["Chargement…"],"Longest":["Longueur maximale"],"Lowest":["Minimum"],"Make %(field)s optional":["Rendre %(field)s facultatif"],"Make default":["Rendre par défaut"],"Make default…":["Rendre par défaut…"],"Make optional":["Rendre facultatif"],"Manage the categories on this site.":["Gérez les catégories de ce site."],"Manage the images and files this site holds.":["Gérez les images et les fichiers que ce site contient."],"Manage the items of this content type.":["Gérez les éléments de ce type de contenu."],"Manage the pages on this site.":["Gérez les pages de ce site."],"Manage the posts on this site.":["Gérez les articles de ce site."],"Many targets":["Plusieurs cibles"],"Many values":["Plusieurs valeurs"],"March":["mars"],"May":["mai"],"Media %(id)s":["Média %(id)s"],"Media Library":["Bibliothèque de médias"],"Medium":["Moyenne"],"Mobile":["Mobile"],"Modified: %s":["Modifié : %s"],"Most files":["Maximum de fichiers"],"Most rows":["Maximum de lignes"],"Move":["Déplacer"],"Move \"%(title)s\" to the trash?":["Déplacer « %(title)s » dans la corbeille ?"],"Move %(field)s down":["Descendre %(field)s"],"Move %(field)s elsewhere":["Déplacer %(field)s ailleurs"],"Move %(field)s up":["Monter %(field)s"],"Move %(group)s down":["Descendre %(group)s"],"Move %(group)s up":["Monter %(group)s"],"Move %(name)s down":["Descendre %(name)s"],"Move %(name)s up":["Monter %(name)s"],"Move %s item to the trash?":["Déplacer %s élément dans la corbeille ?","Déplacer %s éléments dans la corbeille ?","Déplacer %s éléments dans la corbeille ?"],"Move every address":["Déplacer toutes les adresses"],"Move row down":["Descendre la ligne"],"Move row up":["Monter la ligne"],"Move the field":["Déplacer le champ"],"Move to trash":["Déplacer dans la corbeille"],"Name":["Nom"],"Name updated.":["Nom mis à jour."],"Nests":["Imbriqué"],"Newer posts":["Articles plus récents"],"No":["Non"],"No content types found.":["Aucun type de contenu trouvé."],"No description":["Aucune description"],"No file arrived with this upload. Choose a file and try again.":["Aucun fichier n'est arrivé avec ce téléversement. Choisissez un fichier et réessayez."],"No items found.":["Aucun élément trouvé."],"No items yet.":["Aucun élément pour l'instant."],"No media chosen":["Aucun média choisi"],"No media has been uploaded yet.":["Aucun média n'a encore été téléversé."],"No media items found.":["Aucun fichier média trouvé."],"No media items yet.":["Aucun fichier média pour l’instant."],"No page lives at this address.":["Aucune page n'existe à cette adresse."],"No parent":["Pas de parent"],"No theme arrived with this upload. Choose a zip file and try again.":["Aucun thème n'est arrivé avec ce téléversement. Choisissez un fichier zip et réessayez."],"No themes are installed.":["Aucun thème n'est installé."],"No title":["Sans titre"],"None":["Aucun"],"Not %(type)s":["Sauf %(type)s"],"Not found":["Introuvable"],"Not serving":["Hors service"],"Nothing declares that role.":["Rien ne déclare ce rôle."],"Nothing points here yet.":["Rien ne pointe encore ici."],"Nothing published here yet.":["Rien n'est encore publié ici."],"Nothing published yet.":["Rien n'est encore publié."],"November":["novembre"],"Nowhere":["Nulle part"],"Number":["Nombre"],"October":["octobre"],"Older posts":["Articles plus anciens"],"One frame of this animation holds more than %(max)s pixels, which is more than Gophenberg opens. Upload a smaller animation.":["Une image de cette animation dépasse %(max)s pixels, plus que ce que Gophenberg ouvre. Téléversez une animation plus petite."],"One of the filters on this list is not one Gophenberg understands. Clear the filters and try again.":["Un des filtres de cette liste n'est pas un filtre que Gophenberg comprend. Videz les filtres et réessayez."],"One of the items picked for %(field)s is not the kind of content that field points at. Reload the page and pick from the list the field offers.":["Un des éléments choisis pour %(field)s n'est pas le genre de contenu que vise ce champ. Rechargez la page et choisissez dans la liste que le champ propose."],"One of the items picked for %(field)s was not named in a way Gophenberg understands. Reload the page and pick the items again.":["Un des éléments choisis pour %(field)s n'était pas nommé d'une façon que Gophenberg comprend. Rechargez la page et choisissez de nouveau les éléments."],"One of the items picked no longer exists. Reload the page and pick again.":["Un des éléments choisis n'existe plus. Rechargez la page et choisissez de nouveau."],"One target":["Une cible"],"Only a relation field points at another content type or holds many items. Pick the Relation kind, or leave the target and the many option empty.":["Seul un champ de relation vise un autre type de contenu ou contient plusieurs éléments. Choisissez le genre Relation, ou laissez vides la cible et l'option plusieurs."],"Only one file may be placed here.":["Un seul fichier peut être placé ici."],"Only the content type at the root of your site may go without a route word. Give this type one and try again.":["Seul le type de contenu à la racine de votre site peut se passer de mot de route. Donnez-en un à ce type et réessayez."],"Open %(field)s in a new tab":["Ouvrir %(field)s dans un nouvel onglet"],"Order stored.":["Ordre enregistré."],"Other":["Autre"],"Out of list":["Hors de la liste"],"Parent":["Parent"],"Password":["Mot de passe"],"Pending":["En attente"],"Pending Review":["En attente de relecture"],"Permanently delete":["Supprimer définitivement"],"Permanently delete…":["Supprimer définitivement…"],"Picture quality":["Qualité d'image"],"Picture quality runs from 1 to %(max)s, so %(value)s is not a quality Gophenberg stores at. Pick a number inside that range.":["La qualité d'image va de 1 à %(max)s, donc %(value)s n'est pas une qualité avec laquelle Gophenberg enregistre. Choisissez un nombre dans cet intervalle."],"Placeholder":["Texte indicatif"],"Plural name":["Nom au pluriel"],"Points at":["Vise"],"Post published.":["Article publié."],"Posts per page":["Articles par page"],"Preview":["Prévisualiser"],"Private":["Privé"],"Publish":["Publier"],"Published: %s":["Publié : %s"],"Radio group":["Groupe de boutons radio"],"Reads from":["Lit depuis"],"Redo":["Rétablir"],"Relation":["Relation"],"Remove":["Retirer"],"Remove %(name)s":["Retirer %(name)s"],"Remove %(title)s":["Retirer %(title)s"],"Remove row":["Retirer la ligne"],"Rename":["Renommer"],"Rename %(field)s":["Renommer %(field)s"],"Rename…":["Renommer…"],"Require":["Rendre obligatoire"],"Require %(field)s":["Rendre %(field)s obligatoire"],"Required":["Obligatoire"],"Restore":["Rétablir"],"Role changed.":["Rôle modifié."],"Roll back to %(theme)s":["Revenir à %(theme)s"],"Roll back to the built-in renderer":["Revenir au moteur de rendu intégré"],"Route word":["Mot de route"],"Rule %(number)s of set %(set)s":["Règle %(number)s de l'ensemble %(set)s"],"Rule set %(number)s":["Ensemble de règles %(number)s"],"Rules":["Règles"],"Rules showing %(field)s":["Règles affichant %(field)s"],"Save":["Enregistrer"],"Save draft":["Enregistrer le brouillon"],"Save rules":["Enregistrer les règles"],"Save settings":["Enregistrer les réglages"],"Saved":["Enregistré"],"Saving":["Enregistrement"],"Scheduled":["Planifié"],"Scheduled: %s":["Planifié : %s"],"Search %(type)s…":["Rechercher des %(type)s…"],"Search audio":["Rechercher un audio"],"Search images":["Rechercher des images"],"Search media":["Rechercher des médias"],"Search videos":["Rechercher des vidéos"],"Select":["Sélectionner"],"September":["septembre"],"Serving":["En service"],"Settings":["Réglages"],"Settings of %(field)s":["Réglages de %(field)s"],"Shadowed":["Masqué"],"Show %(field)s in the list":["Afficher %(field)s dans la liste"],"Showing %(held)s of %(total)s.":["Affichage de %(held)s sur %(total)s."],"Singular name":["Nom au singulier"],"Slug":["Slug"],"Somebody must be able to manage users.":["Quelqu'un doit pouvoir gérer les utilisateurs."],"Someone else changed this media item while you were editing it. Reload the page and make your change again.":["Quelqu'un d'autre a modifié ce média pendant que vous le modifiiez. Rechargez la page et refaites votre modification."],"Someone else saved changes to this item while you were editing, so yours were not saved. Copy your text somewhere safe, then reload the item and put your changes back in.":["Quelqu'un d'autre a enregistré des modifications sur cet élément pendant que vous éditiez, les vôtres n'ont donc pas été enregistrées. Copiez votre texte en lieu sûr, puis rechargez l'élément et remettez vos modifications."],"Something went wrong inside Gophenberg. Try again in a moment, and tell whoever looks after your site if it keeps happening.":["Quelque chose a mal tourné dans Gophenberg. Réessayez dans un instant, et prévenez la personne qui s'occupe de votre site si cela continue."],"Something went wrong.":["Quelque chose a mal tourné."],"Something went wrong. Try again.":["Quelque chose a mal tourné. Réessayez."],"Sorry, you are not allowed to access this page.":["Désolé, vous n’avez pas l’autorisation d’accéder à cette page."],"Source":["Source"],"Steps of":["Pas de"],"Stop nesting":["Arrêter l'imbrication"],"Tablet":["Tablette"],"Text":["Texte"],"Text area":["Zone de texte"],"That email is already in use.":["Cet e-mail est déjà utilisé."],"That field group no longer exists. Reload the page to see the groups your site holds now.":["Ce groupe de champs n'existe plus. Rechargez la page pour voir les groupes que votre site contient maintenant."],"That field is not on this content type any more. Reload the page to see the fields it has now.":["Ce champ n'est plus sur ce type de contenu. Rechargez la page pour voir les champs qu'il possède maintenant."],"That revision does not exist, and older ones drop off once a type holds more than it keeps. Open the item to see which revisions are left.":["Cette révision n'existe pas, et les plus anciennes disparaissent quand un type en détient plus qu'il n'en conserve. Ouvrez l'élément pour voir les révisions restantes."],"That role holds more than your own.":["Ce rôle va au-delà du vôtre."],"That web address does not name a media item Gophenberg can look up. Open the media library and pick the item from there.":["Cette adresse web ne désigne aucun média que Gophenberg puisse trouver. Ouvrez la bibliothèque de médias et choisissez l'élément depuis là."],"That web address does not name a revision Gophenberg can look up. Open the item and pick a revision from its history.":["Cette adresse web ne désigne aucune révision que Gophenberg puisse trouver. Ouvrez l'élément et choisissez une révision dans son historique."],"That web address does not name an item Gophenberg can look up. Go back to the list and open the item from there.":["Cette adresse web ne désigne aucun élément que Gophenberg puisse trouver. Revenez à la liste et ouvrez l'élément depuis là."],"The %(count)s item in the trash is deleted for good. This cannot be undone.":["%(count)s élément de la corbeille est supprimé définitivement. Cette action est irréversible.","Les %(count)s éléments de la corbeille sont supprimés définitivement. Cette action est irréversible.","Les %(count)s éléments de la corbeille sont supprimés définitivement. Cette action est irréversible."],"The %(file)s calls this theme %(declared)s, but it is being installed as %(installed)s. Rename the zip file to match, or change the name inside %(file)s.":["Le fichier %(file)s appelle ce thème %(declared)s, mais il s'installe sous %(installed)s. Renommez le fichier zip pour qu'ils correspondent, ou changez le nom dans %(file)s."],"The %(file)s inside %(name)s is not valid JSON, so it could not be read. Fix that file and package the theme again.":["Le fichier %(file)s dans %(name)s n'est pas du JSON valide, il n'a donc pas pu être lu. Corrigez ce fichier et réempaquetez le thème."],"The %(file)s inside %(name)s names its theme kit as %(declared)s, which is not a version number. Build the theme again, and the build will fill that in.":["Le %(file)s dans %(name)s indique son kit de thèmes comme %(declared)s, ce qui n'est pas un numéro de version. Compilez le thème à nouveau et la compilation le renseignera."],"The %(origin)s plugin declared this, so it can only be changed in that plugin. Turning it off is still yours to do.":["L'extension %(origin)s l'a déclaré, donc il ne peut être modifié que dans cette extension. Le désactiver reste entre vos mains."],"The %(plugin)s plugin also declares %(name)s, and the site keeps the key.":["L'extension %(plugin)s déclare aussi %(name)s, et le site garde la clé."],"The %(plugin)s plugin declared these fields, so they change only in its code.":["L'extension %(plugin)s a déclaré ces champs, donc ils ne changent que dans son code."],"The %(plugin)s plugin declares %(name)s flat, and the site keeps it nesting while its items sit inside another.":["L'extension %(plugin)s déclare %(name)s sans imbrication, et le site continue de l'imbriquer tant que ses éléments se trouvent dans un autre."],"The browser kept an unsaved version of this post.":["Le navigateur a gardé une version non enregistrée de cet article."],"The built-in renderer is now serving the public site.":["Le moteur de rendu intégré sert désormais le site public."],"The built-in renderer is serving the public site.":["Le moteur de rendu intégré sert le site public."],"The content type this field points at is not named in a shape Gophenberg accepts. Give it a key that starts with a lowercase letter and then holds only lowercase letters, numbers and hyphens.":["Le type de contenu que vise ce champ n'est pas nommé sous une forme que Gophenberg accepte. Donnez-lui une clé qui commence par une lettre minuscule et ne contient ensuite que des lettres minuscules, des chiffres et des tirets."],"The content types could not be loaded, so where these groups appear and which fields they shadow are not shown.":["Les types de contenu n'ont pas pu être chargés, donc où ces groupes apparaissent et quels champs ils masquent ne sont pas affichés."],"The content types could not be loaded.":["Les types de contenu n'ont pas pu être chargés."],"The content types could not be refreshed, so where these groups appear and what they shadow may be out of date.":["Les types de contenu n'ont pas pu être actualisés, donc où ces groupes apparaissent et ce qu'ils masquent peut être obsolète."],"The draft could not be created.":["Le brouillon n'a pas pu être créé."],"The entry %(entry)s in this zip would be written outside the theme folder, so nothing was installed. Package the theme again.":["L'entrée %(entry)s de ce zip s'écrirait hors du dossier du thème, rien n'a donc été installé. Réempaquetez le thème."],"The field %(field)s cannot go, because %(by)s reads it. Change %(by)s first.":["Le champ %(field)s ne peut pas être retiré, car %(by)s le lit. Modifiez d'abord %(by)s."],"The field %(field)s cannot hold the value it was given. Check what kind of field it is and enter a value that matches.":["Le champ %(field)s ne peut pas contenir la valeur qui lui a été donnée. Vérifiez de quel genre de champ il s'agit et saisissez une valeur qui convient."],"The field %(field)s has to hold a value before this item can be published. Fill it in, or leave the item as a draft.":["Le champ %(field)s doit contenir une valeur avant que cet élément puisse être publié. Remplissez-le, ou laissez l'élément en brouillon."],"The field %(field)s in %(group)s still points at this content type. Delete that field first, then delete this type.":["Le champ %(field)s dans %(group)s vise encore ce type de contenu. Supprimez d'abord ce champ, puis supprimez ce type."],"The field %(field)s is hidden by its own rules right now, so it cannot hold a value. Show it again before filling it in.":["Le champ %(field)s est masqué par ses propres règles pour le moment, il ne peut donc contenir aucune valeur. Affichez-le de nouveau avant de le remplir."],"The field %(field)s points at one item only. Remove the extra choices and keep one.":["Le champ %(field)s ne vise qu'un seul élément. Retirez les choix en trop et gardez-en un."],"The field %(field)s points at other items, so it does not take a plain value. Pick the items it points at instead.":["Le champ %(field)s vise d'autres éléments, il n'accepte donc pas une valeur simple. Choisissez plutôt les éléments qu'il vise."],"The field %(field)s takes a list of items, and what arrived was not a list. Reload the page and pick the items again.":["Le champ %(field)s attend une liste d'éléments, et ce qui est arrivé n'était pas une liste. Rechargez la page et choisissez de nouveau les éléments."],"The field %(source)s cannot hold the value %(value)s, so no rule can compare it. Write a value of the kind %(source)s stores.":["Le champ %(source)s ne peut pas contenir la valeur %(value)s, aucune règle ne peut donc la comparer. Écrivez une valeur du type que %(source)s enregistre."],"The field groups could not be loaded.":["Les groupes de champs n'ont pas pu être chargés."],"The field groups could not be reached.":["Les groupes de champs n'ont pas pu être joints."],"The field keeps its values on the content both groups reach.":["Le champ garde ses valeurs sur le contenu que les deux groupes atteignent."],"The fields this group holds go with it, and so do the values every item stored in them.":["Les champs que ce groupe contient partent avec lui, ainsi que les valeurs que chaque élément y a enregistrées."],"The groups were reordered.":["Les groupes ont été réordonnés."],"The item could not be duplicated.":["L'élément n'a pas pu être dupliqué."],"The item could not be moved to the trash.":["L'élément n'a pas pu être déplacé dans la corbeille."],"The item could not be permanently deleted.":["L'élément n'a pas pu être supprimé définitivement."],"The item could not be restored.":["L'élément n'a pas pu être rétabli."],"The item you picked as parent holds another kind of content. Pick a parent of the same content type as this item.":["L'élément que vous avez choisi comme parent contient un autre genre de contenu. Choisissez un parent du même type de contenu que cet élément."],"The item you picked as parent is in the trash. Restore it, or pick another parent.":["L'élément que vous avez choisi comme parent est dans la corbeille. Rétablissez-le, ou choisissez un autre parent."],"The item you picked as parent no longer exists. Reload the page and pick another parent.":["L'élément que vous avez choisi comme parent n'existe plus. Rechargez la page et choisissez un autre parent."],"The media library could not be loaded.":["La bibliothèque de médias n'a pas pu être chargée."],"The name changes. Nothing stored under this field moves.":["Le nom change. Rien de ce qui est stocké sous ce champ ne bouge."],"The name could not be updated.":["Le nom n'a pas pu être mis à jour."],"The new order has to name every field group exactly once. Reload the page and move the groups again.":["Le nouvel ordre doit nommer chaque groupe de champs exactement une fois. Rechargez la page et déplacez les groupes à nouveau."],"The new order has to name every field of this content type exactly once. Reload the page and move the fields again.":["Le nouvel ordre doit nommer chaque champ de ce type de contenu exactement une fois. Rechargez la page et déplacez les champs à nouveau."],"The number of revisions to keep has to be a whole number, and it cannot be negative. Enter a different number.":["Le nombre de révisions à conserver doit être un nombre entier, et il ne peut pas être négatif. Saisissez un autre nombre."],"The registry could not be reached.":["Le registre n'a pas pu être joint."],"The role could not be changed.":["Le rôle n'a pas pu être modifié."],"The rule on %(source)s names nothing to match. Pick a value, or remove the rule.":["La règle sur %(source)s ne nomme rien à faire correspondre. Choisissez une valeur, ou retirez la règle."],"The rules could not be loaded.":["Les règles n'ont pas pu être chargées."],"The rules on %(field)s lead back to %(field)s itself, so nothing can decide whether to show it. Point one of them at another field.":["Les règles de %(field)s reviennent à %(field)s lui-même, rien ne peut donc décider de l'afficher. Faites pointer l'une d'elles vers un autre champ."],"The same item was picked twice for %(field)s. Pick each item once.":["Le même élément a été choisi deux fois pour %(field)s. Choisissez chaque élément une seule fois."],"The server could not be reached, so nothing was changed.":["Le serveur n'a pas pu être joint, rien n'a donc été changé."],"The server could not be reached, so nothing was saved.":["Le serveur n'a pas pu être joint, rien n'a donc été enregistré."],"The server could not be reached, so nothing was uploaded.":["Le serveur n'a pas pu être joint, rien n'a donc été téléversé."],"The setting %(setting)s cannot hold the value it was given. Check what this setting takes and try again.":["Le réglage %(setting)s ne peut pas contenir la valeur donnée. Vérifiez ce que ce réglage accepte et réessayez."],"The setting %(setting)s falls outside the others. Keep min below max and the default inside the bounds.":["Le réglage %(setting)s sort des autres. Gardez min sous max et la valeur par défaut dans les limites."],"The settings could not be read, so nothing can be changed here yet.":["Les réglages n'ont pas pu être lus, donc rien ne peut encore être modifié ici."],"The site default":["La langue par défaut du site"],"The site may already hold part of this file. Import it again to see what is left.":["Le site contient peut-être déjà une partie de ce fichier. Importez-le à nouveau pour voir ce qu'il reste."],"The source %(source)s does not offer %(operator)s. Pick one of the comparisons it does offer.":["La source %(source)s n'offre pas %(operator)s. Choisissez une des comparaisons qu'elle offre."],"The theme %(name)s carries no %(file)s, so Gophenberg cannot tell what it is. Put that file at the top level of the zip and package the theme again.":["Le thème %(name)s ne contient aucun %(file)s, Gophenberg ne peut donc pas savoir ce que c'est. Placez ce fichier au premier niveau du zip et réempaquetez le thème."],"The theme %(name)s could not be read, so it cannot serve your site. Upload it again, or choose another theme.":["Le thème %(name)s n'a pas pu être lu, il ne peut donc pas servir votre site. Téléversez-le de nouveau, ou choisissez un autre thème."],"The theme %(name)s did not start, so your site is still served by the theme it had. Ask whoever looks after your site to check the server log for what the theme reported.":["Le thème %(name)s n'a pas démarré, votre site est donc toujours servi par le thème qu'il avait. Demandez à la personne qui s'occupe de votre site de regarder ce que le thème a signalé dans le journal du serveur."],"The theme %(name)s does not carry %(path)s as a file, so there is nothing to run. Package the built theme rather than its source.":["Le thème %(name)s ne contient pas %(path)s comme fichier, il n'y a donc rien à exécuter. Empaquetez le thème construit plutôt que sa source."],"The theme %(name)s does not carry %(path)s as a folder, so its styles and scripts are missing. Package the built theme rather than its source.":["Le thème %(name)s ne contient pas %(path)s comme dossier, ses styles et ses scripts manquent donc. Empaquetez le thème construit plutôt que sa source."],"The theme %(name)s holds a link to another file at %(path)s, and a theme may only carry real files. Replace it and package the theme again.":["Le thème %(name)s contient un lien vers un autre fichier à %(path)s, et un thème ne peut contenir que de vrais fichiers. Remplacez-le et réempaquetez le thème."],"The theme %(name)s is serving your site right now, so it cannot be replaced. Deactivate it first, or install this copy under another name.":["Le thème %(name)s sert votre site en ce moment, il ne peut donc pas être remplacé. Désactivez-le d'abord, ou installez cette copie sous un autre nom."],"The theme %(name)s is the one chosen, but it is not installed, so your site is served by the built-in renderer. Upload the theme again, or choose another one.":["Le thème %(name)s est celui choisi, mais il n'est pas installé, votre site est donc servi par le moteur de rendu intégré. Téléversez de nouveau le thème, ou choisissez-en un autre."],"The theme %(name)s was built on theme kit %(declared)s, and this site serves %(served)s. Build the theme again against a kit this site serves, then install it.":["Le thème %(name)s a été compilé avec le kit de thèmes %(declared)s, et ce site sert %(served)s. Compilez le thème à nouveau avec un kit que ce site sert, puis installez-le."],"The trash could not be emptied.":["La corbeille n'a pas pu être vidée."],"The user could not be created.":["L'utilisateur n'a pas pu être créé."],"The values stored under it do not follow.":["Les valeurs enregistrées dessous ne le suivent pas."],"Theme":["Thème"],"Themes":["Thèmes"],"Themes could not be loaded.":["Les thèmes n'ont pas pu être chargés."],"There is no content type called %(value)s. Pick a type your site holds, or register it first.":["Il n'existe aucun type de contenu appelé %(value)s. Choisissez un type que votre site possède, ou déclarez-le d'abord."],"There is no content type called %(value)s. Pick one of the types your site holds and try again.":["Il n'existe aucun type de contenu appelé %(value)s. Choisissez un des types que votre site possède et réessayez."],"There is no kind of field called %(value)s. Pick one of these instead: %(allowed)s.":["Il n'existe aucun genre de champ appelé %(value)s. Choisissez plutôt parmi ceux-ci : %(allowed)s."],"There is no page kind called %(value)s. Pick one of these instead: %(allowed)s.":["Il n'existe aucun genre de page appelé %(value)s. Choisissez plutôt parmi ceux-ci : %(allowed)s."],"There is no status called %(value)s. Pick one of these instead: %(allowed)s.":["Il n'existe aucun état appelé %(value)s. Choisissez plutôt parmi ceux-ci : %(allowed)s."],"There is nothing at this address. Check the link and try again.":["Il n'y a rien à cette adresse. Vérifiez le lien et réessayez."],"There is nothing to roll back to, because your site has not been through a theme change yet. Install a theme first, then a rollback has somewhere to go.":["Il n'y a rien vers quoi revenir, car votre site n'a pas encore changé de thème. Installez d'abord un thème, et le retour aura alors une destination."],"These files could not be read.":["Ces fichiers n'ont pas pu être lus."],"These were left alone, because nobody confirmed losing them.":["Ceux-ci ont été laissés tels quels, car personne n'a confirmé leur perte."],"This content type does not exist any more. Reload the page to see the types your site holds now.":["Ce type de contenu n'existe plus. Rechargez la page pour voir les types que votre site possède maintenant."],"This content type does not nest, so one of its items cannot sit inside another. Mark the type as Nests first.":["Ce type de contenu ne s'imbrique pas, un de ses éléments ne peut donc pas se trouver dans un autre. Marquez d'abord le type comme Imbriqué."],"This content type has no field called %(field)s. Check the name, or declare the field on the type first.":["Ce type de contenu n'a aucun champ appelé %(field)s. Vérifiez le nom, ou déclarez d'abord le champ sur le type."],"This content type is turned off, so its content is not served or edited. Activate it first.":["Ce type de contenu est désactivé, son contenu n'est donc ni servi ni modifié. Activez-le d'abord."],"This content type still holds content, so it cannot be deleted. Move or delete its items first.":["Ce type de contenu contient encore du contenu, il ne peut donc pas être supprimé. Déplacez ou supprimez d'abord ses éléments."],"This definitions file is larger than the %(max)s bytes Gophenberg reads in one request. Split it, or raise the limit your site runs with.":["Ce fichier de définitions dépasse les %(max)s octets que Gophenberg lit en une requête. Divisez-le, ou relevez la limite de votre site."],"This field holds no layouts yet.":["Ce champ ne contient pas encore de dispositions."],"This field would stand inside too many containers. Put it nearer the top and try again.":["Ce champ se trouverait dans trop de conteneurs. Placez-le plus près du niveau supérieur et réessayez."],"This file could not be opened as a zip. Package the theme again and upload the new file.":["Ce fichier n'a pas pu être ouvert comme zip. Réempaquetez le thème et téléversez le nouveau fichier."],"This file is larger than the upload limit. Upload a smaller file.":["Ce fichier dépasse la limite de téléversement. Téléversez un fichier plus petit."],"This file is named %(extension)s but it holds %(detected)s content. Upload it under a name that matches what is inside it.":["Ce fichier est nommé %(extension)s mais il contient du %(detected)s. Téléversez-le sous un nom qui correspond à ce qu'il contient."],"This file matches the site, so there is nothing to change.":["Ce fichier correspond au site, il n'y a donc rien à changer."],"This file says it was written in format %(declared)s, which is not a version Gophenberg can read. Export the definitions again from the site they came from.":["Ce fichier indique le format %(declared)s, qui n'est pas une version que Gophenberg sait lire. Exportez à nouveau les définitions depuis le site d'origine."],"This file was written in format %(declared)s, and this release reads %(served)s. Upgrade the site the file came from, or this one.":["Ce fichier est écrit au format %(declared)s, et cette version lit %(served)s. Mettez à jour le site d'origine du fichier, ou celui-ci."],"This group holds no fields yet.":["Ce groupe ne contient pas encore de champs."],"This install of Gophenberg has no install root, so it updates by hand.":["Cette installation de Gophenberg n'a pas de racine d'installation, elle se met donc à jour à la main."],"This is a custom build of Gophenberg, so it updates by hand.":["Il s'agit d'une version compilée sur mesure de Gophenberg, elle se met donc à jour à la main."],"This item cannot go straight to that status from the one it holds now. Save it as a draft first, then try again.":["Cet élément ne peut pas passer directement à cet état depuis celui qu'il a maintenant. Enregistrez-le d'abord comme brouillon, puis réessayez."],"This item changed while you were describing it. Reload and try again.":["Cet élément a changé pendant que vous le décriviez. Rechargez et réessayez."],"This item does not exist, and it may have been deleted. Go back to the list to see what is there.":["Cet élément n'existe pas, et il a peut-être été supprimé. Revenez à la liste pour voir ce qui s'y trouve."],"This item is already in the trash. Restore it first to work on it again.":["Cet élément est déjà dans la corbeille. Rétablissez-le d'abord pour y retravailler."],"This item is in the trash. Restore it first to work on it again.":["Cet élément est dans la corbeille. Rétablissez-le d'abord pour y retravailler."],"This item is not in the trash, so there is nothing to restore. Open it from the list to work on it as it is.":["Cet élément n'est pas dans la corbeille, il n'y a donc rien à rétablir. Ouvrez-le depuis la liste pour travailler dessus tel quel."],"This item still holds items nested inside it. Move or delete those first.":["Cet élément contient encore des éléments imbriqués. Déplacez-les ou supprimez-les d'abord."],"This kind of field holds no fields inside it. Put the field in a section or a repeater instead.":["Ce type de champ ne contient aucun champ. Placez le champ dans une section ou dans un répéteur."],"This media item is not in the library, and someone may have deleted it. Reload the page to see what is there now.":["Ce média n'est pas dans la bibliothèque, et quelqu'un l'a peut-être supprimé. Rechargez la page pour voir ce qui s'y trouve maintenant."],"This picture could not be read, so nothing was stored. The file may be damaged, so open it on your computer and upload it again.":["Cette image n'a pas pu être lue, rien n'a donc été stocké. Le fichier est peut-être endommagé, ouvrez-le sur votre ordinateur et téléversez-le de nouveau."],"This picture is %(width)s by %(height)s pixels, over the limit of %(max)s pixels in all. Make it smaller and upload it again.":["Cette image fait %(width)s par %(height)s pixels, au-delà de la limite de %(max)s pixels au total. Réduisez-la et téléversez-la de nouveau."],"This post changed elsewhere. Reload before saving again.":["Cet article a changé ailleurs. Rechargez avant d'enregistrer de nouveau."],"This request came from a page on another site, so it was refused. Open the admin on this site and try again.":["Cette requête vient d'une page d'un autre site, elle a donc été refusée. Ouvrez l'administration sur ce site et réessayez."],"This request carried %(attribute)s, which fields do not take. Reload the page so the admin and the server agree again.":["Cette requête portait %(attribute)s, que les champs n'acceptent pas. Rechargez la page pour que l'administration et le serveur concordent à nouveau."],"This request left out %(field)s, which Gophenberg needs. Reload the page and try again.":["Cette requête a omis %(field)s, dont Gophenberg a besoin. Rechargez la page et réessayez."],"This rule reads a source called %(source)s, and nothing on your site declares one. A plugin that added it may have been removed.":["Cette règle lit une source nommée %(source)s, et rien sur votre site n'en déclare. Une extension qui l'ajoutait a pu être retirée."],"This site cannot write to its themes folder, so no theme can be installed. Ask whoever looks after your site to make the folder that %(setting)s points at writable. A read-only mount is the usual cause.":["Ce site ne peut pas écrire dans son dossier de thèmes, aucun thème ne peut donc être installé. Demandez à la personne qui s'occupe de votre site de rendre accessible en écriture le dossier vers lequel pointe %(setting)s. Un montage en lecture seule en est la cause habituelle."],"This site has nowhere to keep themes, so no theme can be installed. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Ce site n'a nulle part où garder les thèmes, aucun thème ne peut donc être installé. Demandez à la personne qui s'occupe de votre site de faire pointer %(setting)s vers un dossier où le serveur peut écrire."],"This site has nowhere to store uploads, so nothing can be uploaded. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Ce site n'a nulle part où stocker les téléversements, rien ne peut donc être téléversé. Demandez à la personne qui s'occupe de votre site de faire pointer %(setting)s vers un dossier où le serveur peut écrire."],"This theme is over the limit of %(max)s bytes, packed or unpacked. Leave out what the theme does not need and package it again.":["Ce thème dépasse la limite de %(max)s octets, compressé ou non. Retirez ce dont le thème n'a pas besoin et réempaquetez-le."],"This type will answer under /%(word)s.":["Ce type répondra sous /%(word)s."],"This zip holds %(entries)s files, more than the %(max)s a theme may carry. Package only the built theme.":["Ce zip contient %(entries)s fichiers, plus que les %(max)s qu'un thème peut contenir. Empaquetez seulement le thème construit."],"Through":["Via"],"Thumbnail":["Miniature"],"Title":["Titre"],"Two field groups in this file share the key %(key)s. Give one of them a different key and import again.":["Deux groupes de champs de ce fichier partagent la clé %(key)s. Donnez une clé différente à l'un d'eux et importez à nouveau."],"Two rule sources are registered under the name %(param)s. A plugin is declaring a source your site already holds.":["Deux sources de règles sont enregistrées sous le nom %(param)s. Une extension déclare une source que votre site possède déjà."],"Type":["Type"],"Type a whole number in both boxes before saving.":["Saisissez un nombre entier dans les deux cases avant d'enregistrer."],"Undo":["Annuler"],"Unsaved changes":["Modifications non enregistrées"],"Update":["Mettre à jour"],"Update failed.":["La mise à jour a échoué."],"Upload theme":["Téléverser un thème"],"Uploaded %s file.":["%s fichier téléversé.","%s fichiers téléversés.","%s fichiers téléversés."],"Users":["Utilisateurs"],"Users could not be loaded.":["Les utilisateurs n'ont pas pu être chargés."],"Value":["Valeur"],"Version":["Version"],"Videos":["Vidéos"],"Visit Site":["Aller sur le site"],"Web address":["Adresse web"],"What each Linked from field reads":["Ce que lit chaque champ Lié depuis"],"What it points at changed, so its stored values go with it.":["Ce qu'il vise a changé, donc ses valeurs enregistrées partent avec lui."],"Where %(group)s appears":["Où apparaît %(group)s"],"Without a rule this field always shows.":["Sans aucune règle ce champ s'affiche toujours."],"Without a rule this group appears nowhere.":["Sans une règle ce groupe n'apparaît nulle part."],"Yes":["Oui"],"Yes or no":["Oui ou non"],"You cannot change your own role.":["Vous ne pouvez pas changer votre propre rôle."],"Your language":["Votre langue"],"Your role does not allow %(capability)s.":["Votre rôle ne permet pas %(capability)s."],"Your server pins the site to the theme %(name)s, so themes cannot be changed here. Ask whoever looks after your site to remove the pin.":["Votre serveur fixe le site au thème %(name)s, les thèmes ne peuvent donc pas être changés ici. Demandez à la personne qui s'occupe de votre site de retirer cette fixation."],"Your site always needs one default content type, so this one cannot be turned off or deleted. Make another type the default first.":["Votre site a toujours besoin d'un type de contenu par défaut, celui-ci ne peut donc être ni désactivé ni supprimé. Rendez d'abord un autre type par défaut."],"a content type":["un type de contenu"],"account\u0004Role":["Rôle"],"account\u0004Status":["État"],"action label\u0004Duplicate":["Dupliquer"],"action label\u0004Duplicate…":["Dupliquer…"],"address":["adresse"],"admin section\u0004Content Types":["Types de contenu"],"admin section\u0004Field Groups":["Groupes de champs"],"admin section\u0004Language":["Langue"],"admin section\u0004Media":["Médias"],"admin section\u0004Settings":["Réglages"],"admin section\u0004Themes":["Thèmes"],"column\u0004Date":["Date"],"content type\u0004\"%s\" permanently deleted.":["Type de contenu « %s » supprimé définitivement."],"content type\u0004\"%s\" successfully created.":["Type de contenu « %s » créé avec succès."],"content type\u0004Status":["État"],"field\u0004Name":["Nom"],"field group\u0004Status":["Statut"],"field type\u0004Choice":["Choix"],"field type\u0004Color":["Couleur"],"field type\u0004Date":["Date"],"field type\u0004Flexible content":["Contenu flexible"],"field type\u0004Gallery":["Galerie"],"field type\u0004Kind":["Genre"],"field type\u0004Layout":["Disposition"],"field type\u0004Link":["Lien"],"field type\u0004Media":["Média"],"field type\u0004Range":["Plage"],"field type\u0004Repeater":["Répéteur"],"field type\u0004Section":["Section"],"field type\u0004Select":["Liste déroulante"],"holds":["contient"],"holds anything":["contient quelque chose"],"is":["est"],"is above":["est supérieur à"],"is below":["est inférieur à"],"is empty":["est vide"],"is not":["n'est pas"],"media family\u0004Kind":["Type"],"panel button label\u0004Settings":["Réglages"],"person\u0004Name":["Nom"],"post\u0004%s (Copy)":["%s (copie)"],"post\u0004Status":["État"],"post status\u0004Draft":["Brouillon"],"post status\u0004Published":["Publié"],"post status\u0004Trash":["Corbeille"],"post type general name\u0004Pages":["Pages"],"post type general name\u0004Posts":["Articles"],"post type singular name\u0004Page":["Page"],"post type singular name\u0004Post":["Article"],"relation target\u0004None":["Aucun"],"role\u0004Administrator":["Administrateur"],"role\u0004Author":["Auteur"],"role\u0004Editor":["Éditeur"],"role\u0004Member":["Membre"],"role\u0004No role":["Sans rôle"],"status filter\u0004All":["Tout"],"taxonomy general name\u0004Categories":["Catégories"],"taxonomy singular name\u0004Category":["Catégorie"],"theme\u0004Status":["État"],"trash\u0004Restore":["Rétablir"],"v%(version)s":["v%(version)s"],"verb\u0004Trash…":["Mettre à la corbeille…"],"verb\u0004View":["Voir"],"{day} {month} {year}":["{day} {month} {year}"]} +{"":{"lang":"fr","plural-forms":"nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;"}," and ":[" et "]," or ":[" ou "],"\"%s\" activated.":["Type de contenu « %s » activé."],"\"%s\" deactivated.":["Type de contenu « %s » désactivé."],"\"%s\" has been restored.":["« %s » a été rétabli."],"\"%s\" moved to the trash.":["« %s » déplacé dans la corbeille."],"\"%s\" permanently deleted.":["Suppression définitive de « %s »."],"\"%s\" successfully created.":["« %s » créé avec succès."],"\"%s\" uploaded.":["« %s » téléversé."],"%(field)s address":["Adresse de %(field)s"],"%(field)s cannot move inside itself. Pick a container that is not one of its own.":["%(field)s ne peut pas être déplacé à l'intérieur de lui-même. Choisissez un conteneur qui n'est pas l'un des siens."],"%(field)s declared.":["%(field)s déclaré."],"%(field)s deleted.":["%(field)s supprimé."],"%(field)s does not match the format its field asks for. Check it and save again.":["%(field)s ne respecte pas le format demandé par son champ. Vérifiez-le et enregistrez de nouveau."],"%(field)s goes no higher than %(limit)s. Lower the value and save again.":["%(field)s ne dépasse pas %(limit)s. Baissez la valeur et enregistrez de nouveau."],"%(field)s goes no lower than %(limit)s. Raise the value and save again.":["%(field)s ne descend pas sous %(limit)s. Augmentez la valeur et enregistrez de nouveau."],"%(field)s holds no value of its own, so it cannot be required. Make it optional.":["%(field)s ne contient aucune valeur propre, il ne peut donc pas être obligatoire. Rendez-le facultatif."],"%(field)s is optional again.":["%(field)s est de nouveau facultatif."],"%(field)s is required now.":["%(field)s est maintenant obligatoire."],"%(field)s moved.":["%(field)s déplacé."],"%(field)s names the same one twice. Remove the repeat and save again.":["%(field)s nomme le même deux fois. Retirez la répétition et enregistrez de nouveau."],"%(field)s needs at least %(limit)s files. Add one and save again.":["%(field)s demande au moins %(limit)s fichiers. Ajoutez-en un et enregistrez."],"%(field)s needs at least %(limit)s rows. Add one and save again.":["%(field)s demande au moins %(limit)s lignes. Ajoutez-en une et enregistrez."],"%(field)s only takes one of its listed choices. Pick one and save again.":["%(field)s accepte uniquement un des choix de sa liste. Choisissez-en un et enregistrez de nouveau."],"%(field)s reads a relation field that is not there. Pick a group and a relation field it holds.":["%(field)s lit un champ de relation qui n'existe pas. Choisissez un groupe et un champ de relation qu'il contient."],"%(field)s reads a relation pointing at %(type)s, so it would list nothing here. Pick a relation pointing at the items this group is shown on.":["%(field)s lit une relation qui pointe vers %(type)s, elle n'afficherait donc rien ici. Choisissez une relation qui pointe vers les éléments où ce groupe s'affiche."],"%(field)s renamed.":["%(field)s renommé."],"%(field)s settled.":["%(field)s réglé."],"%(field)s shows by its rules now.":["%(field)s s'affiche désormais selon ses règles."],"%(field)s stands in the list.":["%(field)s figure dans la liste."],"%(field)s stays out of the list.":["%(field)s reste hors de la liste."],"%(field)s takes at most %(limit)s files. Remove one and save again.":["%(field)s accepte au plus %(limit)s fichiers. Retirez-en un et enregistrez."],"%(field)s takes at most %(limit)s letters. Shorten what you wrote and save again.":["%(field)s accepte au plus %(limit)s lettres. Raccourcissez ce que vous avez écrit et enregistrez de nouveau."],"%(field)s takes at most %(limit)s rows. Remove one and save again.":["%(field)s accepte au plus %(limit)s lignes. Retirez-en une et enregistrez."],"%(field)s title":["Titre de %(field)s"],"%(file)s is larger than one upload may be.":["%(file)s dépasse la taille maximale d'un téléversement."],"%(file)s is not a file this block accepts.":["%(file)s n'est pas un fichier que ce bloc accepte."],"%(file)s: %(reason)s":["%(file)s : %(reason)s"],"%(group)s created.":["%(group)s créé."],"%(group)s now appears where you said.":["%(group)s apparaît maintenant là où vous avez dit."],"%(group)s removed.":["%(group)s supprimé."],"%(group)s updated.":["%(group)s mis à jour."],"%(key)s is served by %(winner)s, so %(loser)s does not show it on %(where)s.":["%(key)s est servi par %(winner)s, donc %(loser)s ne le montre pas sur %(where)s."],"%(name)s %(version)s is serving the public site.":["%(name)s %(version)s sert le site public."],"%(name)s belongs to the site now.":["%(name)s appartient désormais au site."],"%(name)s came from the %(plugin)s plugin, which no longer declares it.":["%(name)s venait de l'extension %(plugin)s, qui ne le déclare plus."],"%(name)s cannot be used as a theme name. Name the zip file after the theme, with no dot at the start and no slashes in it.":["%(name)s ne peut pas servir de nom de thème. Nommez le fichier zip comme le thème, sans point au début et sans barre oblique."],"%(name)s moves to /%(word)s. Every address of both types changes.":["Le type %(name)s passe à /%(word)s. Toutes les adresses des deux types changent."],"%(name)s would move to /%(word)s, which Gophenberg keeps for itself. Change its plural name and try again.":["Le type %(name)s passerait à /%(word)s, que Gophenberg garde pour lui. Modifiez son nom au pluriel et réessayez."],"%(source)s is %(value)s":["%(source)s est %(value)s"],"%(theme)s failed to start, so the built-in renderer is serving.":["%(theme)s n'a pas pu démarrer, le moteur de rendu intégré sert donc le site."],"%(theme)s is now serving the public site.":["%(theme)s sert désormais le site public."],"%(theme)s is serving the public site.":["%(theme)s sert le site public."],"%(theme)s is still starting, so the built-in renderer is serving.":["%(theme)s démarre encore, le moteur de rendu intégré sert donc le site."],"%(theme)s was installed.":["%(theme)s a été installé."],"%(theme)s will not load, so the built-in renderer is serving.":["%(theme)s ne se charge pas, le moteur de rendu intégré sert donc le site."],"%(type)s answers on a new address, and stored links move with it.":["%(type)s répond à une nouvelle adresse, et les liens enregistrés la suivent."],"%(type)s becomes the type the site opens on, and the old one takes an address.":["%(type)s devient le type sur lequel le site ouvre, et l'ancien prend une adresse."],"%(type)s keeps nesting while %(items)s of its items sit inside another. Move those to the top level first, the ones in the trash included.":["%(type)s continue de s'imbriquer tant que %(items)s de ses éléments se trouvent dans un autre. Déplacez-les d'abord au niveau supérieur, ceux de la corbeille compris."],"%(type)s keeps nesting, because some of its items sit inside another.":["%(type)s continue de s'imbriquer, car certains de ses éléments se trouvent dans un autre."],"%(type)s will answer at the root.":["Le type %(type)s répondra à la racine."],"%s content type activated.":["%s type de contenu activé.","%s types de contenu activés.","%s types de contenu activés."],"%s content type could not be activated.":["%s type de contenu n'a pas pu être activé.","%s types de contenu n'ont pas pu être activés.","%s types de contenu n'ont pas pu être activés."],"%s content type could not be deactivated.":["%s type de contenu n'a pas pu être désactivé.","%s types de contenu n'ont pas pu être désactivés.","%s types de contenu n'ont pas pu être désactivés."],"%s content type could not be permanently deleted.":["%s type de contenu n'a pas pu être supprimé définitivement.","%s types de contenu n'ont pas pu être supprimés définitivement.","%s types de contenu n'ont pas pu être supprimés définitivement."],"%s content type deactivated.":["%s type de contenu désactivé.","%s types de contenu désactivés.","%s types de contenu désactivés."],"%s content type permanently deleted.":["%s type de contenu supprimé définitivement.","%s types de contenu supprimés définitivement.","%s types de contenu supprimés définitivement."],"%s item could not be moved to the trash.":["%s élément n'a pas pu être déplacé dans la corbeille.","%s éléments n'ont pas pu être déplacés dans la corbeille.","%s éléments n'ont pas pu être déplacés dans la corbeille."],"%s item could not be permanently deleted.":["%s élément n'a pas pu être supprimé définitivement.","%s éléments n'ont pas pu être supprimés définitivement.","%s éléments n'ont pas pu être supprimés définitivement."],"%s item could not be restored.":["%s élément n'a pas pu être rétabli.","%s éléments n'ont pas pu être rétablis.","%s éléments n'ont pas pu être rétablis."],"%s item moved to the trash.":["%s élément déplacé dans la corbeille.","%s éléments déplacés dans la corbeille.","%s éléments déplacés dans la corbeille."],"%s item permanently deleted.":["%s élément supprimé définitivement.","%s éléments supprimés définitivement.","%s éléments supprimés définitivement."],"%s item stays in the trash.":["%s élément reste dans la corbeille.","%s éléments restent dans la corbeille.","%s éléments restent dans la corbeille."],"%s page has been restored.":["%s page a été rétablie.","%s pages ont été rétablies.","%s pages ont été rétablies."],"%s post has been restored.":["%s article a été rétabli.","%s articles ont été rétablis.","%s articles ont été rétablis."],"(no title)":["(pas de titre)"],"A %(kind)s field does not take a setting called %(setting)s. Remove it and try again.":["Un champ %(kind)s n'accepte pas de réglage nommé %(setting)s. Retirez-le et réessayez."],"A Linked from field stands on its own, not inside a section or a repeater. Declare %(field)s beside the container.":["Un champ Lié depuis se place seul, ni dans une section ni dans un répéteur. Déclarez %(field)s à côté du conteneur."],"A content type needs a singular and a plural name, like Guide and Guides. Fill both in and try again.":["Un type de contenu a besoin d'un nom au singulier et d'un nom au pluriel, comme Guide et Guides. Remplissez les deux et réessayez."],"A descriptive summary of the content type.":["Un résumé descriptif du type de contenu."],"A field group key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the group in the file so its key comes out in that shape.":["La clé d'un groupe de champs doit commencer par une lettre minuscule puis ne contenir que des lettres minuscules, des chiffres et des tirets. Renommez le groupe dans le fichier pour que sa clé prenne cette forme."],"A field key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the field so its key comes out in that shape.":["La clé d'un champ doit commencer par une lettre minuscule et ne contenir ensuite que des lettres minuscules, des chiffres et des tirets. Renommez le champ pour que sa clé prenne cette forme."],"A field needs a name. Type one and try again.":["Un champ a besoin d'un nom. Saisissez-en un et réessayez."],"A field with that name already exists on the items this group reaches. Pick another name.":["Un champ portant ce nom existe déjà sur les éléments que ce groupe atteint. Choisissez un autre nom."],"A flexible content field holds layouts, and %(field)s is not one. Add a layout first, then declare the field inside that layout.":["Un champ de contenu flexible contient des dispositions, et %(field)s n'en est pas une. Ajoutez d'abord une disposition, puis déclarez le champ dans cette disposition."],"A higher quality stores larger files, and it applies to pictures uploaded from now on.":["Une qualité plus élevée stocke des fichiers plus grands, et elle s'applique aux images téléversées à partir de maintenant."],"A layout stands inside a flexible content field only. Put %(field)s inside one of those instead.":["Une disposition ne se place que dans un champ de contenu flexible. Placez %(field)s dans l'un d'eux."],"A layout stands inside a flexible content field, never on its own. Declare a flexible content field first, then add %(field)s inside it.":["Une disposition se place dans un champ de contenu flexible, jamais seule. Déclarez d'abord un champ de contenu flexible, puis ajoutez %(field)s dedans."],"A listing carries between 1 and %(max)s items, so %(value)s is not a size Gophenberg takes. Pick a number inside that range.":["Une liste affiche entre 1 et %(max)s éléments, donc %(value)s n'est pas une taille que Gophenberg accepte. Choisissez un nombre dans cet intervalle."],"A relation field has to say which content type it points at. Pick that type and try again.":["Un champ de relation doit indiquer le type de contenu qu'il vise. Choisissez ce type et réessayez."],"A reload shows the admin in the language you chose.":["Un rechargement affiche l'administration dans la langue que vous avez choisie."],"A route word has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change it and try again.":["Un mot de route doit commencer par une lettre minuscule et ne contenir ensuite que des lettres minuscules, des chiffres et des tirets. Modifiez-le et réessayez."],"A row of %(field)s has to name the layout it takes. Pick a layout for the row, or remove the row.":["Une ligne de %(field)s doit nommer la disposition qu'elle prend. Choisissez une disposition pour la ligne, ou retirez la ligne."],"A row of %(field)s takes one layout and no more. Reload the page and build the row again.":["Une ligne de %(field)s prend une seule disposition. Rechargez la page et reconstruisez la ligne."],"A rule matching any %(source)s cannot be turned into an exclusion. Leave it as is, or name the ones to leave out instead.":["Une règle valable pour n'importe quel %(source)s ne peut pas devenir une exclusion. Laissez-la telle quelle, ou nommez ceux à écarter."],"A theme naming its own page size uses that instead.":["Un thème qui indique sa propre taille de page utilise celle-ci à la place."],"A type key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change the key and try again.":["La clé d'un type doit commencer par une lettre minuscule et ne contenir ensuite que des lettres minuscules, des chiffres et des tirets. Modifiez la clé et réessayez."],"Actions":["Actions"],"Activate":["Activer"],"Activate %(theme)s":["Activer %(theme)s"],"Active":["Actif"],"Add":["Ajouter"],"Add %(layout)s":["Ajouter %(layout)s"],"Add %(name)s":["Ajouter %(name)s"],"Add %(type)s":["Ajouter %(type)s"],"Add Category":["Ajouter une catégorie"],"Add Content Type":["Ajouter un type de contenu"],"Add Field Group":["Ajouter un groupe de champs"],"Add Media File":["Ajouter un fichier média"],"Add Page":["Ajouter une page"],"Add Post":["Ajouter un article"],"Add User":["Ajouter un utilisateur"],"Add block":["Ajouter un bloc"],"Add choice":["Ajouter un choix"],"Add condition":["Ajouter une condition"],"Add field":["Ajouter un champ"],"Add field to %(field)s":["Ajouter un champ à %(field)s"],"Add layout":["Ajouter une disposition"],"Add layout to %(field)s":["Ajouter une disposition à %(field)s"],"Add one with %(button)s.":["Ajoutez un élément avec %(button)s."],"Add row":["Ajouter une ligne"],"Add rule set":["Ajouter un ensemble de règles"],"Add title":["Ajouter un titre"],"Add to %(field)s":["Ajouter à %(field)s"],"Address":["Adresse"],"Address updated.":["Adresse mise à jour."],"Adopt":["Adopter"],"Allow custom":["Autoriser les valeurs libres"],"Allow empty":["Autoriser le vide"],"Alt text":["Texte alternatif"],"An item cannot point at itself. Pick a different item for %(field)s.":["Un élément ne peut pas se viser lui-même. Choisissez un autre élément pour %(field)s."],"An item cannot sit inside itself, or inside one of the items nested under it. Pick a parent outside this branch.":["Un élément ne peut pas se trouver dans lui-même, ni dans un élément imbriqué sous lui. Choisissez un parent hors de cette branche."],"Another account wrote this, and only its author, an editor or an administrator may change it. You can still read it.":["Un autre compte a écrit ceci, et seuls son auteur, un éditeur ou un administrateur peuvent le modifier. Vous pouvez toujours le lire."],"Another account wrote this, so you are reading it rather than editing it.":["Un autre compte a écrit ceci, alors vous le lisez au lieu de le modifier."],"Another content type already answers under this route word. Pick a different word.":["Un autre type de contenu répond déjà sous ce mot de route. Choisissez un autre mot."],"Another content type already lives at the root of your site, and only one can. Give this type a route word, or hand the root over with Make default.":["Un autre type de contenu occupe déjà la racine de votre site, et un seul le peut. Donnez un mot de route à ce type, ou cédez la racine avec Rendre par défaut."],"Another content type already uses this key. Give this type a different name.":["Un autre type de contenu utilise déjà cette clé. Donnez un autre nom à ce type."],"Another item of this content type already uses that address. Pick a different slug and save again.":["Un autre élément de ce type de contenu utilise déjà cette adresse. Choisissez un autre slug et enregistrez de nouveau."],"Another update is currently in progress.":["Une autre mise à jour est actuellement en cours."],"Appears on":["Apparaît sur"],"Apply":["Appliquer"],"April":["avril"],"Are you sure you want to move \"%s\" to the trash?":["Voulez-vous vraiment déplacer « %s » dans la corbeille ?"],"Audio":["Audio"],"August":["août"],"Author":["Auteur/autrice"],"Back to %(type)s":["Retour aux %(type)s"],"Back to the latest posts":["Retour aux derniers articles"],"Block":["Bloc"],"Broken":["Cassé"],"Bundles of fields, shown where their rules place them.":["Ensembles de champs, affichés là où leurs règles les placent."],"Button group":["Groupe de boutons"],"Cancel":["Annuler"],"Caption":["Légende"],"Change %(name)s":["Modifier %(name)s"],"Change address":["Changer l'adresse"],"Change address…":["Changer l'adresse…"],"Change role":["Changer le rôle"],"Change the role of %(name)s":["Changer le rôle de %(name)s"],"Checkbox group":["Groupe de cases à cocher"],"Choices":["Choix"],"Choose %(field)s":["Choisir %(field)s"],"Choose how the site serves its content.":["Choisissez comment le site sert son contenu."],"Choose the language the admin reads in.":["Choisissez la langue dans laquelle se lit l'administration."],"Choose the role of %(name)s.":["Choisissez le rôle de %(name)s."],"Choose the version to update to.":["Choisissez la version vers laquelle mettre à jour."],"Clear %(field)s":["Vider %(field)s"],"Close":["Fermer"],"Close Settings":["Fermer les réglages"],"Closed":["Fermé"],"Condition":["Condition"],"Content":["Contenu"],"Content Types":["Types de contenu"],"Content nests at most %(max)s levels deep, and this item would sit deeper. Pick a parent closer to the top.":["Le contenu s'imbrique sur au plus %(max)s niveaux, et cet élément irait plus profond. Choisissez un parent plus proche du sommet."],"Content type":["Type de contenu"],"Content type updated.":["Type de contenu mis à jour."],"Could not load that post.":["Impossible de charger cet article."],"Could not save image. %s":["Impossible d’enregistrer le média. %s"],"Could not save media. %s":["Impossible d’enregistrer le média. %s"],"Could not save that post.":["Impossible d'enregistrer cet article."],"Create":["Créer"],"Create a field group":["Créer un groupe de champs"],"Create user":["Créer l'utilisateur"],"Created":["Date de création"],"Date added":["Date d’ajout"],"Date modified":["Date de modification"],"Deactivate":["Désactiver"],"Deactivate %(theme)s":["Désactiver %(theme)s"],"December":["décembre"],"Default":["Par défaut"],"Default content type updated.":["Type de contenu par défaut mis à jour."],"Definitions file":["Fichier de définitions"],"Delete":["Supprimer"],"Delete \"%(title)s\" for good? This cannot be undone.":["Supprimer « %(title)s » définitivement ? Cette action est irréversible."],"Delete %(field)s":["Supprimer %(field)s"],"Delete %(group)s":["Supprimer %(group)s"],"Delete %s content type for good? This cannot be undone.":["Supprimer %s type de contenu définitivement ? Cette action est irréversible.","Supprimer %s types de contenu définitivement ? Cette action est irréversible.","Supprimer %s types de contenu définitivement ? Cette action est irréversible."],"Delete %s item for good? This cannot be undone.":["Supprimer %s élément définitivement ? Cette action est irréversible.","Supprimer %s éléments définitivement ? Cette action est irréversible.","Supprimer %s éléments définitivement ? Cette action est irréversible."],"Delete permanently":["Supprimer définitivement"],"Delete the field":["Supprimer le champ"],"Delete the group":["Supprimer le groupe"],"Delete…":["Supprimer…"],"Describe":["Décrire"],"Describe the purpose of the image.":["Décrivez la fonction de l’image."],"Describe…":["Décrire…"],"Description":["Description"],"Description updated.":["Description mise à jour."],"Desktop":["Ordinateur"],"Destination":["Destination"],"Dimensions":["Dimensions"],"Disable":["Désactiver"],"Disable %(name)s":["Désactiver %(name)s"],"Disabled":["Désactivé"],"Dismiss":["Ignorer"],"Document":["Document"],"Documents":["Documents"],"Done. The site now holds what the file describes.":["Terminé. Le site contient maintenant ce que décrit le fichier."],"Draft saved.":["Brouillon enregistré."],"Drop files to upload":["Déposez vos fichiers pour les téléverser"],"Edit media":["Modifier le média"],"Edit…":["Modifier…"],"Email":["E-mail"],"Empty Trash":["Vider la corbeille"],"Empty trash":["Vider la corbeille"],"Enable":["Activer"],"Enable %(name)s":["Activer %(name)s"],"Every address of this type moves. Links kept elsewhere to the old addresses stop working.":["Toutes les adresses de ce type changent. Les liens conservés ailleurs vers les anciennes adresses cessent de fonctionner."],"Every content type":["Tous les types de contenu"],"Every field group needs a title. Give it a name and save again.":["Chaque groupe de champs a besoin d'un titre. Donnez-lui un nom et enregistrez à nouveau."],"Every item needs an author who exists. Pick one from the list and save again.":["Chaque élément a besoin d'un auteur existant. Choisissez-en un dans la liste et enregistrez de nouveau."],"Every kind of content this site holds.":["Toutes les sortes de contenu que ce site contient."],"Every value stored under this field goes with it, in every item the group reaches and in the revisions behind them.":["Toutes les valeurs enregistrées dans ce champ partent avec lui, dans chaque élément que le groupe atteint et dans les révisions qui les suivent."],"Excerpt":["Extrait"],"Export definitions":["Exporter les définitions"],"February":["février"],"Fewest files":["Minimum de fichiers"],"Fewest rows":["Minimum de lignes"],"Field":["Champ"],"Field Groups":["Groupes de champs"],"Field group":["Groupe de champs"],"Fields":["Champs"],"Fields of %(group)s":["Champs de %(group)s"],"File name":["Nom de fichier"],"File size":["Taille du fichier"],"File type":["Type de fichier"],"Filter by status":["Filtrer par état"],"Follow the browser":["Suivre le navigateur"],"From %(plugin)s":["De %(plugin)s"],"Full Size":["Taille originale"],"Gophenberg cannot start this update: %(reason)s":["Gophenberg ne peut pas lancer cette mise à jour : %(reason)s"],"Gophenberg checked for updates moments ago. Try again later.":["Gophenberg a cherché des mises à jour il y a un instant. Réessayez plus tard."],"Gophenberg could not read what your browser sent. Reload the page and try again, and tell whoever looks after your site if it keeps happening.":["Gophenberg n'a pas pu lire ce que votre navigateur a envoyé. Rechargez la page et réessayez, et prévenez la personne qui s'occupe de votre site si cela continue."],"Gophenberg could not read which item you picked as parent. Reload the page and pick it again.":["Gophenberg n'a pas pu lire quel élément vous avez choisi comme parent. Rechargez la page et choisissez-le de nouveau."],"Gophenberg does not accept %(extension)s files. Upload a picture, a video, an audio file, a PDF or a zip instead.":["Gophenberg n'accepte pas les fichiers %(extension)s. Téléversez plutôt une image, une vidéo, un fichier audio, un PDF ou un zip."],"Gophenberg does not answer in %(value)s. Pick one of the languages on the list.":["Gophenberg ne répond pas en %(value)s. Choisissez une des langues de la liste."],"Gophenberg has no autosave of yours for this item. Open the item to see the version that is stored.":["Gophenberg n'a aucun enregistrement automatique de vous pour cet élément. Ouvrez l'élément pour voir la version stockée."],"Gophenberg holds no kind of definition called %(subject)s, so nothing was taken over. Reload the page so the admin and the server agree again.":["Gophenberg ne connaît aucune sorte de définition nommée %(subject)s, rien n'a donc été repris. Rechargez la page pour que l'admin et le serveur se rejoignent à nouveau."],"Gophenberg keeps /%(path)s for itself or for another content type, so your content cannot answer there. Give the item a different slug.":["Gophenberg garde l'adresse /%(path)s pour lui ou pour un autre type de contenu, votre contenu ne peut donc pas y répondre. Donnez un autre slug à l'élément."],"Gophenberg keeps this word for itself, so no content type can answer under it. Pick a different route word.":["Gophenberg garde ce mot pour lui, aucun type de contenu ne peut donc répondre sous ce mot. Choisissez un autre mot de route."],"Group":["Groupe"],"Highest":["Maximum"],"Holds":["Contient"],"Images":["Images"],"Import definitions":["Importer les définitions"],"In list":["Dans la liste"],"Inactive":["Inactif"],"Installed":["Installé"],"Instructions":["Instructions"],"It moves here from another group and keeps its stored values.":["Il arrive ici depuis un autre groupe et garde ses valeurs enregistrées."],"It moves to another group and keeps its stored values.":["Il passe dans un autre groupe et garde ses valeurs enregistrées."],"It moves to another group, so its stored values do not follow.":["Il passe dans un autre groupe, donc ses valeurs enregistrées ne le suivent pas."],"It reads a relation inside a container, and only an import can point it elsewhere.":["Il lit une relation dans un conteneur, et seule une importation peut le faire pointer ailleurs."],"It starts on %(type)s, and the rules may be edited afterwards.":["Il commence sur %(type)s, et les règles peuvent être modifiées ensuite."],"Items could not be loaded.":["Les éléments n'ont pas pu être chargés."],"Its items sit inside one another, so it keeps nesting.":["Ses éléments se trouvent les uns dans les autres, donc il continue de s'imbriquer."],"Its kind changed, so its stored values go with it.":["Son type a changé, donc ses valeurs enregistrées partent avec lui."],"January":["janvier"],"July":["juillet"],"June":["juin"],"Keep %(field)s out of the list":["Garder %(field)s hors de la liste"],"Keep it":["Le conserver"],"Keep it here":["Le laisser ici"],"Key":["Clé"],"Label":["Libellé"],"Language":["Langue"],"Large":["Grande"],"Leave empty if decorative.":["Laissez vide si l’image est décorative"],"Let items nest":["Autoriser l'imbrication"],"Linked from":["Lié depuis"],"List view":["Vue en liste"],"Loading field groups.":["Chargement des groupes de champs."],"Loading the post.":["Chargement de l'article."],"Loading the screen.":["Chargement de l'écran."],"Loading themes.":["Chargement des thèmes."],"Loading users.":["Chargement des utilisateurs."],"Loading…":["Chargement…"],"Longest":["Longueur maximale"],"Lowest":["Minimum"],"Make %(field)s optional":["Rendre %(field)s facultatif"],"Make default":["Rendre par défaut"],"Make default…":["Rendre par défaut…"],"Make optional":["Rendre facultatif"],"Manage the categories on this site.":["Gérez les catégories de ce site."],"Manage the images and files this site holds.":["Gérez les images et les fichiers que ce site contient."],"Manage the items of this content type.":["Gérez les éléments de ce type de contenu."],"Manage the pages on this site.":["Gérez les pages de ce site."],"Manage the posts on this site.":["Gérez les articles de ce site."],"Many targets":["Plusieurs cibles"],"Many values":["Plusieurs valeurs"],"March":["mars"],"May":["mai"],"Media %(id)s":["Média %(id)s"],"Media Library":["Médiathèque"],"Media file updated.":["Fichier média mis à jour."],"Medium":["Moyenne"],"Mobile":["Mobile"],"Modified: %s":["Modifié : %s"],"Most files":["Maximum de fichiers"],"Most rows":["Maximum de lignes"],"Move":["Déplacer"],"Move \"%(title)s\" to the trash?":["Déplacer « %(title)s » dans la corbeille ?"],"Move %(field)s down":["Descendre %(field)s"],"Move %(field)s elsewhere":["Déplacer %(field)s ailleurs"],"Move %(field)s up":["Monter %(field)s"],"Move %(group)s down":["Descendre %(group)s"],"Move %(group)s up":["Monter %(group)s"],"Move %(name)s down":["Descendre %(name)s"],"Move %(name)s up":["Monter %(name)s"],"Move %s item to the trash?":["Déplacer %s élément dans la corbeille ?","Déplacer %s éléments dans la corbeille ?","Déplacer %s éléments dans la corbeille ?"],"Move every address":["Déplacer toutes les adresses"],"Move row down":["Descendre la ligne"],"Move row up":["Monter la ligne"],"Move the field":["Déplacer le champ"],"Move to trash":["Déplacer dans la corbeille"],"Name":["Nom"],"Name updated.":["Nom mis à jour."],"Nests":["Imbriqué"],"Newer posts":["Articles plus récents"],"No":["Non"],"No content types found.":["Aucun type de contenu trouvé."],"No description":["Aucune description"],"No file arrived with this upload. Choose a file and try again.":["Aucun fichier n'est arrivé avec ce téléversement. Choisissez un fichier et réessayez."],"No items found.":["Aucun élément trouvé."],"No items yet.":["Aucun élément pour l'instant."],"No media chosen":["Aucun média choisi"],"No media has been uploaded yet.":["Aucun média n'a encore été téléversé."],"No media items found.":["Aucun média n’a été trouvé."],"No media items yet.":["Aucun fichier média pour l’instant."],"No page lives at this address.":["Aucune page n'existe à cette adresse."],"No parent":["Pas de parent"],"No theme arrived with this upload. Choose a zip file and try again.":["Aucun thème n'est arrivé avec ce téléversement. Choisissez un fichier zip et réessayez."],"No themes are installed.":["Aucun thème n'est installé."],"No title":["Aucun titre"],"None":["Aucun"],"Not %(type)s":["Sauf %(type)s"],"Not found":["Introuvable"],"Not serving":["Hors service"],"Nothing declares that role.":["Rien ne déclare ce rôle."],"Nothing points here yet.":["Rien ne pointe encore ici."],"Nothing published here yet.":["Rien n'est encore publié ici."],"Nothing published yet.":["Rien n'est encore publié."],"November":["novembre"],"Nowhere":["Nulle part"],"Number":["Nombre"],"October":["octobre"],"Older posts":["Articles plus anciens"],"One frame of this animation holds more than %(max)s pixels, which is more than Gophenberg opens. Upload a smaller animation.":["Une image de cette animation dépasse %(max)s pixels, plus que ce que Gophenberg ouvre. Téléversez une animation plus petite."],"One of the filters on this list is not one Gophenberg understands. Clear the filters and try again.":["Un des filtres de cette liste n'est pas un filtre que Gophenberg comprend. Videz les filtres et réessayez."],"One of the items picked for %(field)s is not the kind of content that field points at. Reload the page and pick from the list the field offers.":["Un des éléments choisis pour %(field)s n'est pas le genre de contenu que vise ce champ. Rechargez la page et choisissez dans la liste que le champ propose."],"One of the items picked for %(field)s was not named in a way Gophenberg understands. Reload the page and pick the items again.":["Un des éléments choisis pour %(field)s n'était pas nommé d'une façon que Gophenberg comprend. Rechargez la page et choisissez de nouveau les éléments."],"One of the items picked no longer exists. Reload the page and pick again.":["Un des éléments choisis n'existe plus. Rechargez la page et choisissez de nouveau."],"One target":["Une cible"],"Only a relation field points at another content type or holds many items. Pick the Relation kind, or leave the target and the many option empty.":["Seul un champ de relation vise un autre type de contenu ou contient plusieurs éléments. Choisissez le genre Relation, ou laissez vides la cible et l'option plusieurs."],"Only one file may be placed here.":["Un seul fichier peut être placé ici."],"Only the content type at the root of your site may go without a route word. Give this type one and try again.":["Seul le type de contenu à la racine de votre site peut se passer de mot de route. Donnez-en un à ce type et réessayez."],"Open %(field)s in a new tab":["Ouvrir %(field)s dans un nouvel onglet"],"Order stored.":["Ordre enregistré."],"Other":["Autre"],"Out of list":["Hors de la liste"],"Parent":["Parent"],"Password":["Mot de passe"],"Pending":["En attente"],"Pending Review":["En attente de relecture"],"Permanently delete…":["Supprimer définitivement…"],"Picture quality":["Qualité d'image"],"Picture quality runs from 1 to %(max)s, so %(value)s is not a quality Gophenberg stores at. Pick a number inside that range.":["La qualité d'image va de 1 à %(max)s, donc %(value)s n'est pas une qualité avec laquelle Gophenberg enregistre. Choisissez un nombre dans cet intervalle."],"Placeholder":["Texte indicatif"],"Plural name":["Nom au pluriel"],"Points at":["Vise"],"Post published.":["Article publié."],"Posts per page":["Articles par page"],"Preview":["Prévisualiser"],"Private":["Privé"],"Publish":["Publier"],"Published: %s":["Publié : %s"],"Radio group":["Groupe de boutons radio"],"Reads from":["Lit depuis"],"Redo":["Rétablir"],"Relation":["Relation"],"Remove":["Retirer"],"Remove %(name)s":["Retirer %(name)s"],"Remove %(title)s":["Retirer %(title)s"],"Remove row":["Retirer la ligne"],"Rename":["Renommer"],"Rename %(field)s":["Renommer %(field)s"],"Rename…":["Renommer…"],"Require":["Rendre obligatoire"],"Require %(field)s":["Rendre %(field)s obligatoire"],"Required":["Obligatoire"],"Restore":["Rétablir"],"Role changed.":["Rôle modifié."],"Roll back to %(theme)s":["Revenir à %(theme)s"],"Roll back to the built-in renderer":["Revenir au moteur de rendu intégré"],"Route word":["Mot de route"],"Rule %(number)s of set %(set)s":["Règle %(number)s de l'ensemble %(set)s"],"Rule set %(number)s":["Ensemble de règles %(number)s"],"Rules":["Règles"],"Rules showing %(field)s":["Règles affichant %(field)s"],"Save":["Enregistrer"],"Save draft":["Enregistrer le brouillon"],"Save rules":["Enregistrer les règles"],"Save settings":["Enregistrer les réglages"],"Saved":["Enregistré"],"Saving":["Enregistrement"],"Scheduled":["Planifié"],"Scheduled: %s":["Planifié : %s"],"Search %(type)s…":["Rechercher des %(type)s…"],"Search audio":["Rechercher un audio"],"Search images":["Rechercher des images"],"Search media":["Rechercher des médias"],"Search videos":["Rechercher des vidéos"],"Select":["Sélectionner"],"September":["septembre"],"Serving":["En service"],"Settings":["Réglages"],"Settings of %(field)s":["Réglages de %(field)s"],"Shadowed":["Masqué"],"Show %(field)s in the list":["Afficher %(field)s dans la liste"],"Showing %(held)s of %(total)s.":["Affichage de %(held)s sur %(total)s."],"Singular name":["Nom au singulier"],"Slug":["Slug"],"Somebody must be able to manage users.":["Quelqu'un doit pouvoir gérer les utilisateurs."],"Someone else changed this media item while you were editing it. Reload the page and make your change again.":["Quelqu'un d'autre a modifié ce média pendant que vous le modifiiez. Rechargez la page et refaites votre modification."],"Someone else saved changes to this item while you were editing, so yours were not saved. Copy your text somewhere safe, then reload the item and put your changes back in.":["Quelqu'un d'autre a enregistré des modifications sur cet élément pendant que vous éditiez, les vôtres n'ont donc pas été enregistrées. Copiez votre texte en lieu sûr, puis rechargez l'élément et remettez vos modifications."],"Something went wrong inside Gophenberg. Try again in a moment, and tell whoever looks after your site if it keeps happening.":["Quelque chose a mal tourné dans Gophenberg. Réessayez dans un instant, et prévenez la personne qui s'occupe de votre site si cela continue."],"Something went wrong.":["Quelque chose a mal tourné."],"Something went wrong. Try again.":["Quelque chose a mal tourné. Réessayez."],"Sorry, you are not allowed to access this page.":["Désolé, vous n’avez pas l’autorisation d’accéder à cette page."],"Source":["Source"],"Steps of":["Pas de"],"Stop nesting":["Arrêter l'imbrication"],"Tablet":["Tablette"],"Text":["Texte"],"Text area":["Zone de texte"],"That email is already in use.":["Cet e-mail est déjà utilisé."],"That field group no longer exists. Reload the page to see the groups your site holds now.":["Ce groupe de champs n'existe plus. Rechargez la page pour voir les groupes que votre site contient maintenant."],"That field is not on this content type any more. Reload the page to see the fields it has now.":["Ce champ n'est plus sur ce type de contenu. Rechargez la page pour voir les champs qu'il possède maintenant."],"That revision does not exist, and older ones drop off once a type holds more than it keeps. Open the item to see which revisions are left.":["Cette révision n'existe pas, et les plus anciennes disparaissent quand un type en détient plus qu'il n'en conserve. Ouvrez l'élément pour voir les révisions restantes."],"That role holds more than your own.":["Ce rôle va au-delà du vôtre."],"That web address does not name a media item Gophenberg can look up. Open the media library and pick the item from there.":["Cette adresse web ne désigne aucun média que Gophenberg puisse trouver. Ouvrez la bibliothèque de médias et choisissez l'élément depuis là."],"That web address does not name a revision Gophenberg can look up. Open the item and pick a revision from its history.":["Cette adresse web ne désigne aucune révision que Gophenberg puisse trouver. Ouvrez l'élément et choisissez une révision dans son historique."],"That web address does not name an item Gophenberg can look up. Go back to the list and open the item from there.":["Cette adresse web ne désigne aucun élément que Gophenberg puisse trouver. Revenez à la liste et ouvrez l'élément depuis là."],"The %(count)s item in the trash is deleted for good. This cannot be undone.":["%(count)s élément de la corbeille est supprimé définitivement. Cette action est irréversible.","Les %(count)s éléments de la corbeille sont supprimés définitivement. Cette action est irréversible.","Les %(count)s éléments de la corbeille sont supprimés définitivement. Cette action est irréversible."],"The %(file)s calls this theme %(declared)s, but it is being installed as %(installed)s. Rename the zip file to match, or change the name inside %(file)s.":["Le fichier %(file)s appelle ce thème %(declared)s, mais il s'installe sous %(installed)s. Renommez le fichier zip pour qu'ils correspondent, ou changez le nom dans %(file)s."],"The %(file)s inside %(name)s is not valid JSON, so it could not be read. Fix that file and package the theme again.":["Le fichier %(file)s dans %(name)s n'est pas du JSON valide, il n'a donc pas pu être lu. Corrigez ce fichier et réempaquetez le thème."],"The %(file)s inside %(name)s names its theme kit as %(declared)s, which is not a version number. Build the theme again, and the build will fill that in.":["Le %(file)s dans %(name)s indique son kit de thèmes comme %(declared)s, ce qui n'est pas un numéro de version. Compilez le thème à nouveau et la compilation le renseignera."],"The %(origin)s plugin declared this, so it can only be changed in that plugin. Turning it off is still yours to do.":["L'extension %(origin)s l'a déclaré, donc il ne peut être modifié que dans cette extension. Le désactiver reste entre vos mains."],"The %(plugin)s plugin also declares %(name)s, and the site keeps the key.":["L'extension %(plugin)s déclare aussi %(name)s, et le site garde la clé."],"The %(plugin)s plugin declared these fields, so they change only in its code.":["L'extension %(plugin)s a déclaré ces champs, donc ils ne changent que dans son code."],"The %(plugin)s plugin declares %(name)s flat, and the site keeps it nesting while its items sit inside another.":["L'extension %(plugin)s déclare %(name)s sans imbrication, et le site continue de l'imbriquer tant que ses éléments se trouvent dans un autre."],"The browser kept an unsaved version of this post.":["Le navigateur a gardé une version non enregistrée de cet article."],"The built-in renderer is now serving the public site.":["Le moteur de rendu intégré sert désormais le site public."],"The built-in renderer is serving the public site.":["Le moteur de rendu intégré sert le site public."],"The content type this field points at is not named in a shape Gophenberg accepts. Give it a key that starts with a lowercase letter and then holds only lowercase letters, numbers and hyphens.":["Le type de contenu que vise ce champ n'est pas nommé sous une forme que Gophenberg accepte. Donnez-lui une clé qui commence par une lettre minuscule et ne contient ensuite que des lettres minuscules, des chiffres et des tirets."],"The content types could not be loaded, so where these groups appear and which fields they shadow are not shown.":["Les types de contenu n'ont pas pu être chargés, donc où ces groupes apparaissent et quels champs ils masquent ne sont pas affichés."],"The content types could not be loaded.":["Les types de contenu n'ont pas pu être chargés."],"The content types could not be refreshed, so where these groups appear and what they shadow may be out of date.":["Les types de contenu n'ont pas pu être actualisés, donc où ces groupes apparaissent et ce qu'ils masquent peut être obsolète."],"The draft could not be created.":["Le brouillon n'a pas pu être créé."],"The entry %(entry)s in this zip would be written outside the theme folder, so nothing was installed. Package the theme again.":["L'entrée %(entry)s de ce zip s'écrirait hors du dossier du thème, rien n'a donc été installé. Réempaquetez le thème."],"The field %(field)s cannot go, because %(by)s reads it. Change %(by)s first.":["Le champ %(field)s ne peut pas être retiré, car %(by)s le lit. Modifiez d'abord %(by)s."],"The field %(field)s cannot hold the value it was given. Check what kind of field it is and enter a value that matches.":["Le champ %(field)s ne peut pas contenir la valeur qui lui a été donnée. Vérifiez de quel genre de champ il s'agit et saisissez une valeur qui convient."],"The field %(field)s has to hold a value before this item can be published. Fill it in, or leave the item as a draft.":["Le champ %(field)s doit contenir une valeur avant que cet élément puisse être publié. Remplissez-le, ou laissez l'élément en brouillon."],"The field %(field)s in %(group)s still points at this content type. Delete that field first, then delete this type.":["Le champ %(field)s dans %(group)s vise encore ce type de contenu. Supprimez d'abord ce champ, puis supprimez ce type."],"The field %(field)s is hidden by its own rules right now, so it cannot hold a value. Show it again before filling it in.":["Le champ %(field)s est masqué par ses propres règles pour le moment, il ne peut donc contenir aucune valeur. Affichez-le de nouveau avant de le remplir."],"The field %(field)s points at one item only. Remove the extra choices and keep one.":["Le champ %(field)s ne vise qu'un seul élément. Retirez les choix en trop et gardez-en un."],"The field %(field)s points at other items, so it does not take a plain value. Pick the items it points at instead.":["Le champ %(field)s vise d'autres éléments, il n'accepte donc pas une valeur simple. Choisissez plutôt les éléments qu'il vise."],"The field %(field)s takes a list of items, and what arrived was not a list. Reload the page and pick the items again.":["Le champ %(field)s attend une liste d'éléments, et ce qui est arrivé n'était pas une liste. Rechargez la page et choisissez de nouveau les éléments."],"The field %(source)s cannot hold the value %(value)s, so no rule can compare it. Write a value of the kind %(source)s stores.":["Le champ %(source)s ne peut pas contenir la valeur %(value)s, aucune règle ne peut donc la comparer. Écrivez une valeur du type que %(source)s enregistre."],"The field groups could not be loaded.":["Les groupes de champs n'ont pas pu être chargés."],"The field groups could not be reached.":["Les groupes de champs n'ont pas pu être joints."],"The field keeps its values on the content both groups reach.":["Le champ garde ses valeurs sur le contenu que les deux groupes atteignent."],"The fields this group holds go with it, and so do the values every item stored in them.":["Les champs que ce groupe contient partent avec lui, ainsi que les valeurs que chaque élément y a enregistrées."],"The groups were reordered.":["Les groupes ont été réordonnés."],"The item could not be duplicated.":["L'élément n'a pas pu être dupliqué."],"The item could not be moved to the trash.":["L'élément n'a pas pu être déplacé dans la corbeille."],"The item could not be permanently deleted.":["L'élément n'a pas pu être supprimé définitivement."],"The item could not be restored.":["L'élément n'a pas pu être rétabli."],"The item you picked as parent holds another kind of content. Pick a parent of the same content type as this item.":["L'élément que vous avez choisi comme parent contient un autre genre de contenu. Choisissez un parent du même type de contenu que cet élément."],"The item you picked as parent is in the trash. Restore it, or pick another parent.":["L'élément que vous avez choisi comme parent est dans la corbeille. Rétablissez-le, ou choisissez un autre parent."],"The item you picked as parent no longer exists. Reload the page and pick another parent.":["L'élément que vous avez choisi comme parent n'existe plus. Rechargez la page et choisissez un autre parent."],"The media library could not be loaded.":["La bibliothèque de médias n'a pas pu être chargée."],"The name changes. Nothing stored under this field moves.":["Le nom change. Rien de ce qui est stocké sous ce champ ne bouge."],"The name could not be updated.":["Le nom n'a pas pu être mis à jour."],"The new order has to name every field group exactly once. Reload the page and move the groups again.":["Le nouvel ordre doit nommer chaque groupe de champs exactement une fois. Rechargez la page et déplacez les groupes à nouveau."],"The new order has to name every field of this content type exactly once. Reload the page and move the fields again.":["Le nouvel ordre doit nommer chaque champ de ce type de contenu exactement une fois. Rechargez la page et déplacez les champs à nouveau."],"The number of revisions to keep has to be a whole number, and it cannot be negative. Enter a different number.":["Le nombre de révisions à conserver doit être un nombre entier, et il ne peut pas être négatif. Saisissez un autre nombre."],"The registry could not be reached.":["Le registre n'a pas pu être joint."],"The role could not be changed.":["Le rôle n'a pas pu être modifié."],"The rule on %(source)s names nothing to match. Pick a value, or remove the rule.":["La règle sur %(source)s ne nomme rien à faire correspondre. Choisissez une valeur, ou retirez la règle."],"The rules could not be loaded.":["Les règles n'ont pas pu être chargées."],"The rules on %(field)s lead back to %(field)s itself, so nothing can decide whether to show it. Point one of them at another field.":["Les règles de %(field)s reviennent à %(field)s lui-même, rien ne peut donc décider de l'afficher. Faites pointer l'une d'elles vers un autre champ."],"The same item was picked twice for %(field)s. Pick each item once.":["Le même élément a été choisi deux fois pour %(field)s. Choisissez chaque élément une seule fois."],"The server could not be reached, so nothing was changed.":["Le serveur n'a pas pu être joint, rien n'a donc été changé."],"The server could not be reached, so nothing was saved.":["Le serveur n'a pas pu être joint, rien n'a donc été enregistré."],"The server could not be reached, so nothing was uploaded.":["Le serveur n'a pas pu être joint, rien n'a donc été téléversé."],"The setting %(setting)s cannot hold the value it was given. Check what this setting takes and try again.":["Le réglage %(setting)s ne peut pas contenir la valeur donnée. Vérifiez ce que ce réglage accepte et réessayez."],"The setting %(setting)s falls outside the others. Keep min below max and the default inside the bounds.":["Le réglage %(setting)s sort des autres. Gardez min sous max et la valeur par défaut dans les limites."],"The settings could not be read, so nothing can be changed here yet.":["Les réglages n'ont pas pu être lus, donc rien ne peut encore être modifié ici."],"The site default":["La langue par défaut du site"],"The site may already hold part of this file. Import it again to see what is left.":["Le site contient peut-être déjà une partie de ce fichier. Importez-le à nouveau pour voir ce qu'il reste."],"The source %(source)s does not offer %(operator)s. Pick one of the comparisons it does offer.":["La source %(source)s n'offre pas %(operator)s. Choisissez une des comparaisons qu'elle offre."],"The theme %(name)s carries no %(file)s, so Gophenberg cannot tell what it is. Put that file at the top level of the zip and package the theme again.":["Le thème %(name)s ne contient aucun %(file)s, Gophenberg ne peut donc pas savoir ce que c'est. Placez ce fichier au premier niveau du zip et réempaquetez le thème."],"The theme %(name)s could not be read, so it cannot serve your site. Upload it again, or choose another theme.":["Le thème %(name)s n'a pas pu être lu, il ne peut donc pas servir votre site. Téléversez-le de nouveau, ou choisissez un autre thème."],"The theme %(name)s did not start, so your site is still served by the theme it had. Ask whoever looks after your site to check the server log for what the theme reported.":["Le thème %(name)s n'a pas démarré, votre site est donc toujours servi par le thème qu'il avait. Demandez à la personne qui s'occupe de votre site de regarder ce que le thème a signalé dans le journal du serveur."],"The theme %(name)s does not carry %(path)s as a file, so there is nothing to run. Package the built theme rather than its source.":["Le thème %(name)s ne contient pas %(path)s comme fichier, il n'y a donc rien à exécuter. Empaquetez le thème construit plutôt que sa source."],"The theme %(name)s does not carry %(path)s as a folder, so its styles and scripts are missing. Package the built theme rather than its source.":["Le thème %(name)s ne contient pas %(path)s comme dossier, ses styles et ses scripts manquent donc. Empaquetez le thème construit plutôt que sa source."],"The theme %(name)s holds a link to another file at %(path)s, and a theme may only carry real files. Replace it and package the theme again.":["Le thème %(name)s contient un lien vers un autre fichier à %(path)s, et un thème ne peut contenir que de vrais fichiers. Remplacez-le et réempaquetez le thème."],"The theme %(name)s is serving your site right now, so it cannot be replaced. Deactivate it first, or install this copy under another name.":["Le thème %(name)s sert votre site en ce moment, il ne peut donc pas être remplacé. Désactivez-le d'abord, ou installez cette copie sous un autre nom."],"The theme %(name)s is the one chosen, but it is not installed, so your site is served by the built-in renderer. Upload the theme again, or choose another one.":["Le thème %(name)s est celui choisi, mais il n'est pas installé, votre site est donc servi par le moteur de rendu intégré. Téléversez de nouveau le thème, ou choisissez-en un autre."],"The theme %(name)s was built on theme kit %(declared)s, and this site serves %(served)s. Build the theme again against a kit this site serves, then install it.":["Le thème %(name)s a été compilé avec le kit de thèmes %(declared)s, et ce site sert %(served)s. Compilez le thème à nouveau avec un kit que ce site sert, puis installez-le."],"The trash could not be emptied.":["La corbeille n'a pas pu être vidée."],"The user could not be created.":["L'utilisateur n'a pas pu être créé."],"The values stored under it do not follow.":["Les valeurs enregistrées dessous ne le suivent pas."],"Theme":["Thème"],"Themes":["Thèmes"],"Themes could not be loaded.":["Les thèmes n'ont pas pu être chargés."],"There is no content type called %(value)s. Pick a type your site holds, or register it first.":["Il n'existe aucun type de contenu appelé %(value)s. Choisissez un type que votre site possède, ou déclarez-le d'abord."],"There is no content type called %(value)s. Pick one of the types your site holds and try again.":["Il n'existe aucun type de contenu appelé %(value)s. Choisissez un des types que votre site possède et réessayez."],"There is no kind of field called %(value)s. Pick one of these instead: %(allowed)s.":["Il n'existe aucun genre de champ appelé %(value)s. Choisissez plutôt parmi ceux-ci : %(allowed)s."],"There is no page kind called %(value)s. Pick one of these instead: %(allowed)s.":["Il n'existe aucun genre de page appelé %(value)s. Choisissez plutôt parmi ceux-ci : %(allowed)s."],"There is no status called %(value)s. Pick one of these instead: %(allowed)s.":["Il n'existe aucun état appelé %(value)s. Choisissez plutôt parmi ceux-ci : %(allowed)s."],"There is nothing at this address. Check the link and try again.":["Il n'y a rien à cette adresse. Vérifiez le lien et réessayez."],"There is nothing to roll back to, because your site has not been through a theme change yet. Install a theme first, then a rollback has somewhere to go.":["Il n'y a rien vers quoi revenir, car votre site n'a pas encore changé de thème. Installez d'abord un thème, et le retour aura alors une destination."],"These files could not be read.":["Ces fichiers n'ont pas pu être lus."],"These were left alone, because nobody confirmed losing them.":["Ceux-ci ont été laissés tels quels, car personne n'a confirmé leur perte."],"This content type does not exist any more. Reload the page to see the types your site holds now.":["Ce type de contenu n'existe plus. Rechargez la page pour voir les types que votre site possède maintenant."],"This content type does not nest, so one of its items cannot sit inside another. Mark the type as Nests first.":["Ce type de contenu ne s'imbrique pas, un de ses éléments ne peut donc pas se trouver dans un autre. Marquez d'abord le type comme Imbriqué."],"This content type has no field called %(field)s. Check the name, or declare the field on the type first.":["Ce type de contenu n'a aucun champ appelé %(field)s. Vérifiez le nom, ou déclarez d'abord le champ sur le type."],"This content type is turned off, so its content is not served or edited. Activate it first.":["Ce type de contenu est désactivé, son contenu n'est donc ni servi ni modifié. Activez-le d'abord."],"This content type still holds content, so it cannot be deleted. Move or delete its items first.":["Ce type de contenu contient encore du contenu, il ne peut donc pas être supprimé. Déplacez ou supprimez d'abord ses éléments."],"This definitions file is larger than the %(max)s bytes Gophenberg reads in one request. Split it, or raise the limit your site runs with.":["Ce fichier de définitions dépasse les %(max)s octets que Gophenberg lit en une requête. Divisez-le, ou relevez la limite de votre site."],"This field holds no layouts yet.":["Ce champ ne contient pas encore de dispositions."],"This field would stand inside too many containers. Put it nearer the top and try again.":["Ce champ se trouverait dans trop de conteneurs. Placez-le plus près du niveau supérieur et réessayez."],"This file could not be opened as a zip. Package the theme again and upload the new file.":["Ce fichier n'a pas pu être ouvert comme zip. Réempaquetez le thème et téléversez le nouveau fichier."],"This file is larger than the upload limit. Upload a smaller file.":["Ce fichier dépasse la limite de téléversement. Téléversez un fichier plus petit."],"This file is named %(extension)s but it holds %(detected)s content. Upload it under a name that matches what is inside it.":["Ce fichier est nommé %(extension)s mais il contient du %(detected)s. Téléversez-le sous un nom qui correspond à ce qu'il contient."],"This file matches the site, so there is nothing to change.":["Ce fichier correspond au site, il n'y a donc rien à changer."],"This file says it was written in format %(declared)s, which is not a version Gophenberg can read. Export the definitions again from the site they came from.":["Ce fichier indique le format %(declared)s, qui n'est pas une version que Gophenberg sait lire. Exportez à nouveau les définitions depuis le site d'origine."],"This file was written in format %(declared)s, and this release reads %(served)s. Upgrade the site the file came from, or this one.":["Ce fichier est écrit au format %(declared)s, et cette version lit %(served)s. Mettez à jour le site d'origine du fichier, ou celui-ci."],"This group holds no fields yet.":["Ce groupe ne contient pas encore de champs."],"This install of Gophenberg has no install root, so it updates by hand.":["Cette installation de Gophenberg n'a pas de racine d'installation, elle se met donc à jour à la main."],"This is a custom build of Gophenberg, so it updates by hand.":["Il s'agit d'une version compilée sur mesure de Gophenberg, elle se met donc à jour à la main."],"This item cannot go straight to that status from the one it holds now. Save it as a draft first, then try again.":["Cet élément ne peut pas passer directement à cet état depuis celui qu'il a maintenant. Enregistrez-le d'abord comme brouillon, puis réessayez."],"This item does not exist, and it may have been deleted. Go back to the list to see what is there.":["Cet élément n'existe pas, et il a peut-être été supprimé. Revenez à la liste pour voir ce qui s'y trouve."],"This item is already in the trash. Restore it first to work on it again.":["Cet élément est déjà dans la corbeille. Rétablissez-le d'abord pour y retravailler."],"This item is in the trash. Restore it first to work on it again.":["Cet élément est dans la corbeille. Rétablissez-le d'abord pour y retravailler."],"This item is not in the trash, so there is nothing to restore. Open it from the list to work on it as it is.":["Cet élément n'est pas dans la corbeille, il n'y a donc rien à rétablir. Ouvrez-le depuis la liste pour travailler dessus tel quel."],"This item still holds items nested inside it. Move or delete those first.":["Cet élément contient encore des éléments imbriqués. Déplacez-les ou supprimez-les d'abord."],"This kind of field holds no fields inside it. Put the field in a section or a repeater instead.":["Ce type de champ ne contient aucun champ. Placez le champ dans une section ou dans un répéteur."],"This media item is not in the library, and someone may have deleted it. Reload the page to see what is there now.":["Ce média n'est pas dans la bibliothèque, et quelqu'un l'a peut-être supprimé. Rechargez la page pour voir ce qui s'y trouve maintenant."],"This picture could not be read, so nothing was stored. The file may be damaged, so open it on your computer and upload it again.":["Cette image n'a pas pu être lue, rien n'a donc été stocké. Le fichier est peut-être endommagé, ouvrez-le sur votre ordinateur et téléversez-le de nouveau."],"This picture is %(width)s by %(height)s pixels, over the limit of %(max)s pixels in all. Make it smaller and upload it again.":["Cette image fait %(width)s par %(height)s pixels, au-delà de la limite de %(max)s pixels au total. Réduisez-la et téléversez-la de nouveau."],"This post changed elsewhere. Reload before saving again.":["Cet article a changé ailleurs. Rechargez avant d'enregistrer de nouveau."],"This request came from a page on another site, so it was refused. Open the admin on this site and try again.":["Cette requête vient d'une page d'un autre site, elle a donc été refusée. Ouvrez l'administration sur ce site et réessayez."],"This request carried %(attribute)s, which fields do not take. Reload the page so the admin and the server agree again.":["Cette requête portait %(attribute)s, que les champs n'acceptent pas. Rechargez la page pour que l'administration et le serveur concordent à nouveau."],"This request left out %(field)s, which Gophenberg needs. Reload the page and try again.":["Cette requête a omis %(field)s, dont Gophenberg a besoin. Rechargez la page et réessayez."],"This rule reads a source called %(source)s, and nothing on your site declares one. A plugin that added it may have been removed.":["Cette règle lit une source nommée %(source)s, et rien sur votre site n'en déclare. Une extension qui l'ajoutait a pu être retirée."],"This site cannot write to its themes folder, so no theme can be installed. Ask whoever looks after your site to make the folder that %(setting)s points at writable. A read-only mount is the usual cause.":["Ce site ne peut pas écrire dans son dossier de thèmes, aucun thème ne peut donc être installé. Demandez à la personne qui s'occupe de votre site de rendre accessible en écriture le dossier vers lequel pointe %(setting)s. Un montage en lecture seule en est la cause habituelle."],"This site has nowhere to keep themes, so no theme can be installed. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Ce site n'a nulle part où garder les thèmes, aucun thème ne peut donc être installé. Demandez à la personne qui s'occupe de votre site de faire pointer %(setting)s vers un dossier où le serveur peut écrire."],"This site has nowhere to store uploads, so nothing can be uploaded. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Ce site n'a nulle part où stocker les téléversements, rien ne peut donc être téléversé. Demandez à la personne qui s'occupe de votre site de faire pointer %(setting)s vers un dossier où le serveur peut écrire."],"This theme is over the limit of %(max)s bytes, packed or unpacked. Leave out what the theme does not need and package it again.":["Ce thème dépasse la limite de %(max)s octets, compressé ou non. Retirez ce dont le thème n'a pas besoin et réempaquetez-le."],"This type will answer under /%(word)s.":["Ce type répondra sous /%(word)s."],"This zip holds %(entries)s files, more than the %(max)s a theme may carry. Package only the built theme.":["Ce zip contient %(entries)s fichiers, plus que les %(max)s qu'un thème peut contenir. Empaquetez seulement le thème construit."],"Through":["Via"],"Thumbnail":["Miniature"],"Title":["Titre"],"Two field groups in this file share the key %(key)s. Give one of them a different key and import again.":["Deux groupes de champs de ce fichier partagent la clé %(key)s. Donnez une clé différente à l'un d'eux et importez à nouveau."],"Two rule sources are registered under the name %(param)s. A plugin is declaring a source your site already holds.":["Deux sources de règles sont enregistrées sous le nom %(param)s. Une extension déclare une source que votre site possède déjà."],"Type":["Type"],"Type a whole number in both boxes before saving.":["Saisissez un nombre entier dans les deux cases avant d'enregistrer."],"Undo":["Annuler"],"Unsaved changes":["Modifications non enregistrées"],"Update":["Mettre à jour"],"Update failed.":["La mise à jour a échoué."],"Upload theme":["Téléverser un thème"],"Uploaded %s file.":["%s fichier téléversé.","%s fichiers téléversés.","%s fichiers téléversés."],"Users":["Utilisateurs"],"Users could not be loaded.":["Les utilisateurs n'ont pas pu être chargés."],"Value":["Valeur"],"Version":["Version"],"Videos":["Vidéos"],"Visit Site":["Aller sur le site"],"Web address":["Adresse web"],"What each Linked from field reads":["Ce que lit chaque champ Lié depuis"],"What it points at changed, so its stored values go with it.":["Ce qu'il vise a changé, donc ses valeurs enregistrées partent avec lui."],"Where %(group)s appears":["Où apparaît %(group)s"],"Without a rule this field always shows.":["Sans aucune règle ce champ s'affiche toujours."],"Without a rule this group appears nowhere.":["Sans une règle ce groupe n'apparaît nulle part."],"Yes":["Oui"],"Yes or no":["Oui ou non"],"You cannot change your own role.":["Vous ne pouvez pas changer votre propre rôle."],"Your language":["Votre langue"],"Your role does not allow %(capability)s.":["Votre rôle ne permet pas %(capability)s."],"Your server pins the site to the theme %(name)s, so themes cannot be changed here. Ask whoever looks after your site to remove the pin.":["Votre serveur fixe le site au thème %(name)s, les thèmes ne peuvent donc pas être changés ici. Demandez à la personne qui s'occupe de votre site de retirer cette fixation."],"Your site always needs one default content type, so this one cannot be turned off or deleted. Make another type the default first.":["Votre site a toujours besoin d'un type de contenu par défaut, celui-ci ne peut donc être ni désactivé ni supprimé. Rendez d'abord un autre type par défaut."],"a content type":["un type de contenu"],"account\u0004Role":["Rôle"],"account\u0004Status":["État"],"action label\u0004Duplicate":["Dupliquer"],"action label\u0004Duplicate…":["Dupliquer…"],"address":["adresse"],"admin section\u0004Content Types":["Types de contenu"],"admin section\u0004Field Groups":["Groupes de champs"],"admin section\u0004Language":["Langue"],"admin section\u0004Media":["Médias"],"admin section\u0004Settings":["Réglages"],"admin section\u0004Themes":["Thèmes"],"column\u0004Date":["Date"],"content type\u0004\"%s\" permanently deleted.":["Type de contenu « %s » supprimé définitivement."],"content type\u0004\"%s\" successfully created.":["Type de contenu « %s » créé avec succès."],"content type\u0004Status":["État"],"field\u0004Name":["Nom"],"field group\u0004Status":["Statut"],"field type\u0004Choice":["Choix"],"field type\u0004Color":["Couleur"],"field type\u0004Date":["Date"],"field type\u0004Flexible content":["Contenu flexible"],"field type\u0004Gallery":["Galerie"],"field type\u0004Kind":["Genre"],"field type\u0004Layout":["Disposition"],"field type\u0004Link":["Lien"],"field type\u0004Media":["Média"],"field type\u0004Range":["Plage"],"field type\u0004Repeater":["Répéteur"],"field type\u0004Section":["Section"],"field type\u0004Select":["Liste déroulante"],"file size\u0004%1$s %2$s":["%1$s %2$s"],"file type group\u0004Archives":["Archives"],"file type group\u0004Audio":["Sons"],"file type group\u0004Video":["Vidéos"],"holds":["contient"],"holds anything":["contient quelque chose"],"https://www.w3.org/WAI/tutorials/images/decision-tree/":["https://www.w3.org/WAI/tutorials/images/decision-tree/"],"image dimensions\u0004%1$s × %2$s":["%1$s par %2$s"],"is":["est"],"is above":["est supérieur à"],"is below":["est inférieur à"],"is empty":["est vide"],"is not":["n'est pas"],"panel button label\u0004Settings":["Réglages"],"person\u0004Name":["Nom"],"post\u0004%s (Copy)":["%s (copie)"],"post\u0004Status":["État"],"post status\u0004Draft":["Brouillon"],"post status\u0004Published":["Publié"],"post status\u0004Trash":["Corbeille"],"post type general name\u0004Pages":["Pages"],"post type general name\u0004Posts":["Articles"],"post type singular name\u0004Page":["Page"],"post type singular name\u0004Post":["Article"],"relation target\u0004None":["Aucun"],"role\u0004Administrator":["Administrateur"],"role\u0004Author":["Auteur"],"role\u0004Editor":["Éditeur"],"role\u0004Member":["Membre"],"role\u0004No role":["Sans rôle"],"status filter\u0004All":["Tout"],"taxonomy general name\u0004Categories":["Catégories"],"taxonomy singular name\u0004Category":["Catégorie"],"theme\u0004Status":["État"],"trash\u0004Restore":["Rétablir"],"unit symbol\u0004B":["o"],"unit symbol\u0004EB":["Eo"],"unit symbol\u0004GB":["Go"],"unit symbol\u0004KB":["Ko"],"unit symbol\u0004MB":["Mo"],"unit symbol\u0004PB":["Po"],"unit symbol\u0004TB":["To"],"unit symbol\u0004YB":["Yo"],"unit symbol\u0004ZB":["Zo"],"v%(version)s":["v%(version)s"],"verb\u0004Trash…":["Mettre à la corbeille…"],"verb\u0004View":["Voir"],"{day} {month} {year}":["{day} {month} {year}"]} diff --git a/internal/i18n/catalogs/es-ES.json b/internal/i18n/catalogs/es-ES.json index d26fa461..491c9dac 100644 --- a/internal/i18n/catalogs/es-ES.json +++ b/internal/i18n/catalogs/es-ES.json @@ -1 +1 @@ -{"":{"lang":"es","plural-forms":"nplurals=3; plural=((n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2));"}," and ":[" y "]," or ":[" o "],"\"%s\" activated.":["Se ha activado «%s»."],"\"%s\" deactivated.":["Se ha desactivado «%s»."],"\"%s\" has been restored.":["«%s» se ha restaurado."],"\"%s\" moved to the trash.":["«%s» trasladado a la papelera."],"\"%s\" permanently deleted.":["«%s» borrado permanentemente."],"\"%s\" successfully created.":["«%s» creado correctamente."],"\"%s\" uploaded.":["«%s» subido."],"%(field)s address":["Dirección de %(field)s"],"%(field)s cannot move inside itself. Pick a container that is not one of its own.":["%(field)s no puede moverse dentro de sí mismo. Elige un contenedor que no sea uno de los suyos."],"%(field)s declared.":["%(field)s declarado."],"%(field)s deleted.":["%(field)s borrado."],"%(field)s does not match the format its field asks for. Check it and save again.":["%(field)s no tiene el formato que su campo requiere. Revísalo y guarda de nuevo."],"%(field)s goes no higher than %(limit)s. Lower the value and save again.":["%(field)s no pasa de %(limit)s. Baja el valor y guarda de nuevo."],"%(field)s goes no lower than %(limit)s. Raise the value and save again.":["%(field)s no baja de %(limit)s. Sube el valor y guarda de nuevo."],"%(field)s holds no value of its own, so it cannot be required. Make it optional.":["%(field)s no guarda ningún valor propio, así que no puede ser obligatorio. Hazlo opcional."],"%(field)s is optional again.":["%(field)s vuelve a ser opcional."],"%(field)s is required now.":["%(field)s ahora es obligatorio."],"%(field)s moved.":["%(field)s movido."],"%(field)s names the same one twice. Remove the repeat and save again.":["%(field)s nombra el mismo dos veces. Quita la repetición y guarda de nuevo."],"%(field)s needs at least %(limit)s files. Add one and save again.":["%(field)s necesita al menos %(limit)s archivos. Añade uno y guarda de nuevo."],"%(field)s needs at least %(limit)s rows. Add one and save again.":["%(field)s necesita al menos %(limit)s filas. Añade una y guarda de nuevo."],"%(field)s only takes one of its listed choices. Pick one and save again.":["%(field)s solo admite una de las opciones de su lista. Elige una y guarda de nuevo."],"%(field)s reads a relation field that is not there. Pick a group and a relation field it holds.":["%(field)s lee un campo de relación que no existe. Elige un grupo y un campo de relación que contenga."],"%(field)s reads a relation pointing at %(type)s, so it would list nothing here. Pick a relation pointing at the items this group is shown on.":["%(field)s lee una relación que apunta a %(type)s, así que aquí no mostraría nada. Elige una relación que apunte a los elementos donde se muestra este grupo."],"%(field)s renamed.":["%(field)s renombrado."],"%(field)s settled.":["%(field)s ajustado."],"%(field)s shows by its rules now.":["%(field)s ahora se muestra según sus reglas."],"%(field)s stands in the list.":["%(field)s aparece en la lista."],"%(field)s stays out of the list.":["%(field)s se queda fuera de la lista."],"%(field)s takes at most %(limit)s files. Remove one and save again.":["%(field)s acepta como mucho %(limit)s archivos. Quita uno y guarda de nuevo."],"%(field)s takes at most %(limit)s letters. Shorten what you wrote and save again.":["%(field)s acepta como mucho %(limit)s letras. Acorta lo que has escrito y guarda de nuevo."],"%(field)s takes at most %(limit)s rows. Remove one and save again.":["%(field)s admite como mucho %(limit)s filas. Quita una y guarda de nuevo."],"%(field)s title":["Título de %(field)s"],"%(file)s is larger than one upload may be.":["%(file)s es más grande de lo que una subida permite."],"%(file)s is not a file this block accepts.":["%(file)s no es un archivo que este bloque acepte."],"%(file)s: %(reason)s":["%(file)s: %(reason)s"],"%(group)s created.":["%(group)s creado."],"%(group)s now appears where you said.":["%(group)s ya aparece donde has dicho."],"%(group)s removed.":["%(group)s eliminado."],"%(group)s updated.":["%(group)s actualizado."],"%(key)s is served by %(winner)s, so %(loser)s does not show it on %(where)s.":["%(key)s lo sirve %(winner)s, así que %(loser)s no lo muestra en %(where)s."],"%(name)s %(version)s is serving the public site.":["%(name)s %(version)s está publicado en la web."],"%(name)s belongs to the site now.":["%(name)s ya pertenece al sitio."],"%(name)s came from the %(plugin)s plugin, which no longer declares it.":["%(name)s vino del plugin %(plugin)s, que ya no lo declara."],"%(name)s cannot be used as a theme name. Name the zip file after the theme, with no dot at the start and no slashes in it.":["%(name)s no se puede usar como nombre de tema. Nombra el archivo zip como el tema, sin punto al principio y sin barras."],"%(name)s moves to /%(word)s. Every address of both types changes.":["El tipo %(name)s pasa a /%(word)s. Cambian todas las direcciones de ambos tipos."],"%(name)s would move to /%(word)s, which Gophenberg keeps for itself. Change its plural name and try again.":["El tipo %(name)s pasaría a /%(word)s, que Gophenberg reserva para sí mismo. Cambia su nombre en plural e inténtalo de nuevo."],"%(source)s is %(value)s":["%(source)s es %(value)s"],"%(theme)s failed to start, so the built-in renderer is serving.":["%(theme)s no pudo arrancar, así que el renderizador ha tomado el relevo."],"%(theme)s is now serving the public site.":["%(theme)s ya sirve el sitio público."],"%(theme)s is serving the public site.":["%(theme)s sirve el sitio público."],"%(theme)s is still starting, so the built-in renderer is serving.":["%(theme)s todavía está arrancando, así que el renderizador ha tomado el relevo."],"%(theme)s was installed.":["%(theme)s se ha instalado."],"%(theme)s will not load, so the built-in renderer is serving.":["%(theme)s no se carga, así que el renderizador ha tomado el relevo."],"%(type)s answers on a new address, and stored links move with it.":["%(type)s responde en una dirección nueva, y los enlaces guardados se mueven con ella."],"%(type)s becomes the type the site opens on, and the old one takes an address.":["%(type)s pasa a ser el tipo con el que abre el sitio, y el anterior toma una dirección."],"%(type)s keeps nesting while %(items)s of its items sit inside another. Move those to the top level first, the ones in the trash included.":["%(type)s sigue anidando mientras %(items)s de sus elementos están dentro de otro. Muévelos primero al nivel superior, incluidos los de la papelera."],"%(type)s keeps nesting, because some of its items sit inside another.":["%(type)s sigue anidando, porque algunos de sus elementos están dentro de otro."],"%(type)s will answer at the root.":["El tipo %(type)s responderá en la raíz."],"%s content type activated.":["Se ha activado %s tipo de contenido.","Se han activado %s de tipos de contenido.","Se han activado %s tipos de contenido."],"%s content type could not be activated.":["No se ha podido activar %s tipo de contenido.","No se han podido activar %s de tipos de contenido.","No se han podido activar %s tipos de contenido."],"%s content type could not be deactivated.":["No se ha podido desactivar %s tipo de contenido.","No se han podido desactivar %s de tipos de contenido.","No se han podido desactivar %s tipos de contenido."],"%s content type could not be permanently deleted.":["No se ha podido borrar permanentemente %s tipo de contenido.","No se han podido borrar permanentemente %s de tipos de contenido.","No se han podido borrar permanentemente %s tipos de contenido."],"%s content type deactivated.":["Se ha desactivado %s tipo de contenido.","Se han desactivado %s de tipos de contenido.","Se han desactivado %s tipos de contenido."],"%s content type permanently deleted.":["Se ha borrado permanentemente %s tipo de contenido.","Se han borrado permanentemente %s de tipos de contenido.","Se han borrado permanentemente %s tipos de contenido."],"%s item could not be moved to the trash.":["No se ha podido mover %s elemento a la papelera.","No se han podido mover %s de elementos a la papelera.","No se han podido mover %s elementos a la papelera."],"%s item could not be permanently deleted.":["No se ha podido borrar permanentemente %s elemento.","No se han podido borrar permanentemente %s de elementos.","No se han podido borrar permanentemente %s elementos."],"%s item could not be restored.":["No se ha podido restaurar %s elemento.","No se han podido restaurar %s de elementos.","No se han podido restaurar %s elementos."],"%s item moved to the trash.":["%s elemento movido a la papelera.","%s de elementos movidos a la papelera.","%s elementos movidos a la papelera."],"%s item permanently deleted.":["%s elemento borrado permanentemente.","%s de elementos borrados permanentemente.","%s elementos borrados permanentemente."],"%s item stays in the trash.":["%s elemento sigue en la papelera.","%s de elementos siguen en la papelera.","%s elementos siguen en la papelera."],"%s page has been restored.":["Se ha restaurado %s página.","Se han restaurado %s de páginas.","Se han restaurado %s páginas."],"%s post has been restored.":["Se ha restaurado %s entrada.","Se han restaurado %s de entradas.","Se han restaurado %s entradas."],"(no title)":["(sin título)"],"A %(kind)s field does not take a setting called %(setting)s. Remove it and try again.":["Un campo %(kind)s no acepta un ajuste llamado %(setting)s. Quítalo y prueba de nuevo."],"A Linked from field stands on its own, not inside a section or a repeater. Declare %(field)s beside the container.":["Un campo Enlazado desde va por su cuenta, no dentro de una sección ni de un repetidor. Declara %(field)s junto al contenedor."],"A content type needs a singular and a plural name, like Guide and Guides. Fill both in and try again.":["Un tipo de contenido necesita un nombre en singular y otro en plural, como Guía y Guías. Rellena ambos e inténtalo de nuevo."],"A descriptive summary of the content type.":["Un resumen descriptivo del tipo de contenido."],"A field group key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the group in the file so its key comes out in that shape.":["La clave de un grupo de campos tiene que empezar por una letra minúscula y después llevar solo letras minúsculas, números y guiones. Cambia el nombre del grupo en el archivo para que su clave salga con esa forma."],"A field key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the field so its key comes out in that shape.":["La clave de un campo tiene que empezar por una letra minúscula y contener solo letras minúsculas, números y guiones. Renombra el campo para que su clave tenga esa forma."],"A field needs a name. Type one and try again.":["El campo necesita un nombre. Escribe uno e inténtalo de nuevo."],"A field with that name already exists on the items this group reaches. Pick another name.":["Ya existe un campo con ese nombre en los elementos que este grupo alcanza. Elige otro nombre."],"A flexible content field holds layouts, and %(field)s is not one. Add a layout first, then declare the field inside that layout.":["Un campo de contenido flexible contiene diseños, y %(field)s no lo es. Añade primero un diseño y después declara el campo dentro de ese diseño."],"A higher quality stores larger files, and it applies to pictures uploaded from now on.":["Una calidad más alta almacena archivos más grandes, y se aplica a las imágenes que subas a partir de ahora."],"A layout stands inside a flexible content field only. Put %(field)s inside one of those instead.":["Un diseño solo va dentro de un campo de contenido flexible. Pon %(field)s dentro de uno de ellos."],"A layout stands inside a flexible content field, never on its own. Declare a flexible content field first, then add %(field)s inside it.":["Un diseño va dentro de un campo de contenido flexible, nunca por su cuenta. Declara primero un campo de contenido flexible y después añade %(field)s dentro."],"A listing carries between 1 and %(max)s items, so %(value)s is not a size Gophenberg takes. Pick a number inside that range.":["Un listado muestra entre 1 y %(max)s elementos, así que %(value)s no es un tamaño que Gophenberg acepte. Elige un número dentro de ese rango."],"A relation field has to say which content type it points at. Pick that type and try again.":["Un campo de relación tiene que indicar a qué tipo de contenido apunta. Elige ese tipo e inténtalo de nuevo."],"A reload shows the admin in the language you chose.":["Al recargar verás el panel de administración en el idioma que has elegido."],"A route word has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change it and try again.":["La palabra de ruta de la URL tiene que empezar por una letra minúscula y contener solo letras minúsculas, números y guiones. Cámbiala e inténtalo de nuevo."],"A row of %(field)s has to name the layout it takes. Pick a layout for the row, or remove the row.":["Una fila de %(field)s tiene que nombrar el diseño que toma. Elige un diseño para la fila, o quita la fila."],"A row of %(field)s takes one layout and no more. Reload the page and build the row again.":["Una fila de %(field)s toma un solo diseño. Recarga la página y crea la fila otra vez."],"A rule matching any %(source)s cannot be turned into an exclusion. Leave it as is, or name the ones to leave out instead.":["Una regla que vale para cualquier %(source)s no se puede convertir en exclusión. Déjala como está, o nombra los que quieras dejar fuera."],"A theme naming its own page size uses that instead.":["Un tema que indica su propio tamaño de página usa ese en su lugar."],"A type key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change the key and try again.":["La clave de un tipo tiene que empezar por una letra minúscula y contener solo letras minúsculas, números y guiones. Cambia la clave e inténtalo de nuevo."],"Actions":["Acciones"],"Activate":["Activar"],"Activate %(theme)s":["Activar %(theme)s"],"Active":["Activo"],"Add":["Añadir"],"Add %(layout)s":["Añadir %(layout)s"],"Add %(name)s":["Añadir %(name)s"],"Add %(type)s":["Añadir %(type)s"],"Add Category":["Añadir categoría"],"Add Content Type":["Añadir tipo de contenido"],"Add Field Group":["Añadir grupo de campos"],"Add Media File":["Añadir medios"],"Add Page":["Añadir página"],"Add Post":["Añadir entrada"],"Add User":["Añadir usuario"],"Add block":["Añadir bloque"],"Add choice":["Añadir opción"],"Add condition":["Añadir condición"],"Add field":["Añadir campo"],"Add field to %(field)s":["Añadir campo a %(field)s"],"Add layout":["Añadir diseño"],"Add layout to %(field)s":["Añadir diseño a %(field)s"],"Add one with %(button)s.":["Añade un elemento con %(button)s."],"Add row":["Añadir fila"],"Add rule set":["Añadir conjunto de reglas"],"Add title":["Añadir título"],"Add to %(field)s":["Añadir a %(field)s"],"Address":["Dirección"],"Address updated.":["Dirección actualizada."],"Adopt":["Adoptar"],"Allow custom":["Permitir valores propios"],"Allow empty":["Permitir vacío"],"Alt text":["Texto alternativo"],"An item cannot point at itself. Pick a different item for %(field)s.":["Un elemento no puede apuntarse a sí mismo. Elige otro elemento para %(field)s."],"An item cannot sit inside itself, or inside one of the items nested under it. Pick a parent outside this branch.":["Un elemento no puede estar dentro de sí mismo, ni dentro de otro anidado bajo él. Elige un nivel superior fuera de esta rama."],"Another account wrote this, and only its author, an editor or an administrator may change it. You can still read it.":["Otra cuenta escribió esto, y solo su autor, un editor o un administrador pueden cambiarlo. Todavía puedes leerlo."],"Another account wrote this, so you are reading it rather than editing it.":["Otra cuenta escribió esto, así que lo estás leyendo, no editando."],"Another content type already answers under this route word. Pick a different word.":["Ya hay otro tipo de contenido que responde con esta palabra de ruta de URL. Elige una palabra distinta."],"Another content type already lives at the root of your site, and only one can. Give this type a route word, or hand the root over with Make default.":["Ya hay otro tipo de contenido en la raíz de tu sitio, y solo puede haber uno. Dale a este tipo de contenido una palabra de ruta de URL, o cede la raíz con Establecer como predeterminado."],"Another content type already uses this key. Give this type a different name.":["Ya hay otro tipo de contenido que usa esta clave. Dale a este tipo un nombre distinto."],"Another item of this content type already uses that address. Pick a different slug and save again.":["Ya hay otro elemento de este tipo de contenido que usa esa dirección. Elige un slug distinto y guarda de nuevo."],"Another update is currently in progress.":["En este momento hay otra actualización en marcha."],"Appears on":["Aparece en"],"Apply":["Aplicar"],"April":["abril"],"Are you sure you want to move \"%s\" to the trash?":["¿Seguro que quieres mover «%s» a la papelera?"],"Audio":["Audio"],"August":["agosto"],"Author":["Autor"],"Back to %(type)s":["Volver a %(type)s"],"Back to the latest posts":["Volver a las últimas entradas"],"Block":["Bloque"],"Broken":["Roto"],"Bundles of fields, shown where their rules place them.":["Conjuntos de campos, mostrados donde sus reglas los colocan."],"Button group":["Grupo de botones"],"Cancel":["Cancelar"],"Caption":["Leyenda"],"Change %(name)s":["Cambiar %(name)s"],"Change address":["Cambiar la dirección"],"Change address…":["Cambiar la dirección…"],"Change role":["Cambiar el rol"],"Change the role of %(name)s":["Cambiar el rol de %(name)s"],"Checkbox group":["Grupo de casillas"],"Choices":["Opciones"],"Choose %(field)s":["Elegir %(field)s"],"Choose how the site serves its content.":["Elige cómo el sitio sirve su contenido."],"Choose the language the admin reads in.":["Elige el idioma en el que se lee el panel de administración."],"Choose the role of %(name)s.":["Elige el rol de %(name)s."],"Choose the version to update to.":["Elige la versión a la que actualizar."],"Clear %(field)s":["Vaciar %(field)s"],"Close":["Cerrar"],"Close Settings":["Cerrar ajustes"],"Closed":["Cerrado"],"Condition":["Condición"],"Content":["Contenido"],"Content Types":["Tipos de contenido"],"Content nests at most %(max)s levels deep, and this item would sit deeper. Pick a parent closer to the top.":["El contenido se anida como mucho %(max)s niveles, y este elemento quedaría más abajo. Elige un nivel superior más cercano al inicio."],"Content type":["Tipo de contenido"],"Content type updated.":["Tipo de contenido actualizado."],"Could not delete that item.":["No se ha podido borrar ese elemento.","No se han podido borrar todos los elementos.","No se han podido borrar todos los elementos."],"Could not load that post.":["No se ha podido cargar esa entrada."],"Could not save that post.":["No se ha podido guardar esa entrada."],"Create":["Crear"],"Create a field group":["Crear un grupo de campos"],"Create user":["Crear usuario"],"Created":["Fecha de creación"],"Date added":["Fecha de subida"],"Deactivate":["Desactivar"],"Deactivate %(theme)s":["Desactivar %(theme)s"],"December":["diciembre"],"Default":["Por defecto"],"Default content type updated.":["Tipo de contenido predeterminado actualizado."],"Definitions file":["Archivo de definiciones"],"Delete":["Borrar"],"Delete \"%(title)s\" for good? This cannot be undone.":["¿Borrar permanentemente «%(title)s»? Esto no se puede deshacer."],"Delete %(field)s":["Borrar %(field)s"],"Delete %(group)s":["Eliminar %(group)s"],"Delete %(title)s for good? This cannot be undone.":["¿Borrar %(title)s para siempre? Esto no se puede deshacer."],"Delete %s content type for good? This cannot be undone.":["¿Borrar permanentemente %s tipo de contenido? Esto no se puede deshacer.","¿Borrar permanentemente %s de tipos de contenido? Esto no se puede deshacer.","¿Borrar permanentemente %s tipos de contenido? Esto no se puede deshacer."],"Delete %s item for good? This cannot be undone.":["¿Borrar permanentemente %s elemento? Esto no se puede deshacer.","¿Borrar permanentemente %s de elementos? Esto no se puede deshacer.","¿Borrar permanentemente %s elementos? Esto no se puede deshacer."],"Delete Permanently":["Borrar permanentemente"],"Delete the field":["Borrar el campo"],"Delete the group":["Eliminar el grupo"],"Delete this %(count)s item for good? This cannot be undone.":["¿Borrar este %(count)s elemento para siempre? Esto no se puede deshacer.","¿Borrar estos %(count)s elementos para siempre? Esto no se puede deshacer.","¿Borrar estos %(count)s elementos para siempre? Esto no se puede deshacer."],"Delete…":["Borrar…"],"Describe":["Describir"],"Describe…":["Describir…"],"Description":["Descripción"],"Description updated.":["Descripción actualizada."],"Desktop":["Escritorio"],"Destination":["Destino"],"Dimensions":["Dimensiones"],"Disable":["Desactivar"],"Disable %(name)s":["Desactivar a %(name)s"],"Disabled":["Desactivado"],"Dismiss":["Descartar"],"Document":["Documento"],"Done. The site now holds what the file describes.":["Hecho. El sitio ya tiene lo que describe el archivo."],"Draft saved.":["Borrador guardado."],"Drop files to upload":["Arrastra los archivos para subirlos"],"Email":["Correo electrónico"],"Empty Trash":["Vaciar papelera"],"Empty trash":["Vaciar papelera"],"Enable":["Activar"],"Enable %(name)s":["Activar a %(name)s"],"Every address of this type moves. Links kept elsewhere to the old addresses stop working.":["Cambian todas las direcciones de este tipo. Los enlaces guardados en otro sitio hacia las direcciones antiguas dejan de funcionar."],"Every content type":["Todos los tipos de contenido"],"Every field group needs a title. Give it a name and save again.":["Todo grupo de campos necesita un título. Ponle un nombre y guarda otra vez."],"Every item needs an author who exists. Pick one from the list and save again.":["Cada elemento necesita un autor que exista. Elige uno de la lista y guarda de nuevo."],"Every kind of content this site holds.":["Todos los tipos de contenido que hay en este sitio."],"Every value stored under this field goes with it, in every item the group reaches and in the revisions behind them.":["Todos los valores guardados en este campo se van con él, en cada elemento que el grupo alcanza y en las revisiones que hay detrás."],"Excerpt":["Extracto"],"Export definitions":["Exportar definiciones"],"February":["febrero"],"Fewest files":["Mínimo de archivos"],"Fewest rows":["Mínimo de filas"],"Field":["Campo"],"Field Groups":["Grupos de campos"],"Field group":["Grupo de campos"],"Fields":["Campos"],"Fields of %(group)s":["Campos de %(group)s"],"File name":["Nombre del archivo"],"File size":["Tamaño del archivo"],"File type":["Tipo de archivo"],"Files":["Archivos"],"Filter by status":["Filtrar por estado"],"Follow the browser":["Sigue al navegador"],"From %(plugin)s":["De %(plugin)s"],"Full Size":["Tamaño completo"],"Gophenberg cannot start this update: %(reason)s":["Gophenberg no puede empezar esta actualización: %(reason)s"],"Gophenberg checked for updates moments ago. Try again later.":["Gophenberg ha buscado actualizaciones hace un momento. Inténtalo de nuevo más tarde."],"Gophenberg could not read what your browser sent. Reload the page and try again, and tell whoever looks after your site if it keeps happening.":["Gophenberg no ha podido leer lo que ha enviado tu navegador. Recarga la página e inténtalo de nuevo, y avisa a quien gestiona tu sitio si sigue pasando."],"Gophenberg could not read which item you picked as parent. Reload the page and pick it again.":["Gophenberg no ha podido leer qué elemento has elegido como elemento superior. Recarga la página y vuelve a elegirlo."],"Gophenberg does not accept %(extension)s files. Upload a picture, a video, an audio file, a PDF or a zip instead.":["Gophenberg no acepta archivos %(extension)s. Sube una imagen, un vídeo, un audio, un PDF o un zip."],"Gophenberg does not answer in %(value)s. Pick one of the languages on the list.":["Gophenberg no responde en %(value)s. Elige uno de los idiomas de la lista."],"Gophenberg has no autosave of yours for this item. Open the item to see the version that is stored.":["Gophenberg no tiene ningún guardado automático tuyo de este elemento. Ábrelo para ver la versión guardada."],"Gophenberg holds no kind of definition called %(subject)s, so nothing was taken over. Reload the page so the admin and the server agree again.":["Gophenberg no tiene ninguna clase de definición llamada %(subject)s, así que no se ha adoptado nada. Recarga la página para que el admin y el servidor vuelvan a coincidir."],"Gophenberg keeps /%(path)s for itself or for another content type, so your content cannot answer there. Give the item a different slug.":["Gophenberg reserva la dirección /%(path)s para sí mismo o para otro tipo de contenido, así que tu contenido no puede usarla. Dale al elemento otro slug."],"Gophenberg keeps this word for itself, so no content type can answer under it. Pick a different route word.":["Gophenberg reserva esta palabra para sí mismo, así que ningún tipo de contenido puede usarla. Elige otra palabra de ruta de URL."],"Group":["Grupo"],"Highest":["Máximo"],"Holds":["Contiene"],"Images":["Imágenes"],"Import definitions":["Importar definiciones"],"In list":["En la lista"],"Inactive":["Inactivo"],"Installed":["Instalado"],"Instructions":["Instrucciones"],"It moves here from another group and keeps its stored values.":["Se mueve aquí desde otro grupo y conserva sus valores guardados."],"It moves to another group and keeps its stored values.":["Se mueve a otro grupo y conserva sus valores guardados."],"It moves to another group, so its stored values do not follow.":["Se mueve a otro grupo, así que sus valores guardados no le siguen."],"It reads a relation inside a container, and only an import can point it elsewhere.":["Lee una relación dentro de un contenedor, y solo una importación puede apuntarlo a otro sitio."],"It starts on %(type)s, and the rules may be edited afterwards.":["Empieza en %(type)s, y las reglas se pueden editar después."],"Items could not be loaded.":["No se han podido cargar los elementos."],"Its items sit inside one another, so it keeps nesting.":["Sus elementos están unos dentro de otros, así que sigue anidando."],"Its kind changed, so its stored values go with it.":["Su tipo ha cambiado, así que sus valores guardados se van con él."],"January":["enero"],"July":["julio"],"June":["junio"],"Keep %(field)s out of the list":["Dejar %(field)s fuera de la lista"],"Keep it":["Conservarlo"],"Keep it here":["Dejarlo aquí"],"Key":["Clave"],"Label":["Etiqueta"],"Language":["Idioma"],"Large":["Grande"],"Let items nest":["Permitir anidar"],"Linked from":["Enlazado desde"],"List view":["Vista de lista"],"Loading field groups.":["Cargando los grupos de campos."],"Loading the post.":["Cargando la entrada."],"Loading the screen.":["Cargando la pantalla."],"Loading themes.":["Cargando los temas."],"Loading users.":["Cargando los usuarios."],"Loading…":["Cargando…"],"Longest":["Largo máximo"],"Lowest":["Mínimo"],"Make %(field)s optional":["Hacer %(field)s opcional"],"Make default":["Establecer como predeterminado"],"Make default…":["Establecer como predeterminado…"],"Make optional":["Hacer opcional"],"Manage the categories on this site.":["Gestiona las categorías de este sitio."],"Manage the images and files this site holds.":["Gestiona las imágenes y los archivos que hay en este sitio."],"Manage the items of this content type.":["Gestiona los elementos de este tipo de contenido."],"Manage the pages on this site.":["Gestiona las páginas de este sitio."],"Manage the posts on this site.":["Gestiona las entradas de este sitio."],"Many targets":["Varios destinos"],"Many values":["Varios valores"],"March":["marzo"],"May":["mayo"],"Media %(id)s":["Medio %(id)s"],"Media Library":["Biblioteca de medios"],"Medium":["Mediano"],"Mobile":["Móvil"],"Modified: %s":["Modificado: %s"],"Most files":["Máximo de archivos"],"Most rows":["Máximo de filas"],"Move":["Mover"],"Move \"%(title)s\" to the trash?":["¿Mover «%(title)s» a la papelera?"],"Move %(field)s down":["Bajar %(field)s"],"Move %(field)s elsewhere":["Mover %(field)s a otro sitio"],"Move %(field)s up":["Subir %(field)s"],"Move %(group)s down":["Bajar %(group)s"],"Move %(group)s up":["Subir %(group)s"],"Move %(name)s down":["Bajar %(name)s"],"Move %(name)s up":["Subir %(name)s"],"Move %s item to the trash?":["¿Mover %s elemento a la papelera?","¿Mover %s de elementos a la papelera?","¿Mover %s elementos a la papelera?"],"Move every address":["Mover todas las direcciones"],"Move row down":["Bajar la fila"],"Move row up":["Subir la fila"],"Move the field":["Mover el campo"],"Move to trash":["Mover a la papelera"],"Name":["Nombre"],"Name updated.":["Nombre actualizado."],"Nests":["Anida"],"Newer posts":["Entradas más recientes"],"No":["No"],"No content types found.":["No se han encontrado tipos de contenido."],"No description":["Sin descripción"],"No file arrived with this upload. Choose a file and try again.":["No ha llegado ningún archivo con esta subida. Elige un archivo e inténtalo de nuevo."],"No items found.":["No se han encontrado elementos."],"No items yet.":["Todavía no hay elementos."],"No media chosen":["Ningún medio elegido"],"No media has been uploaded yet.":["Todavía no se ha subido ningún medio."],"No media items found.":["No se han encontrado archivos de medios."],"No media items yet.":["Todavía no hay archivos de medios."],"No page lives at this address.":["No existe ninguna página en esta dirección."],"No parent":["Sin elemento superior"],"No theme arrived with this upload. Choose a zip file and try again.":["No ha llegado ningún tema con esta subida. Elige un archivo zip e inténtalo de nuevo."],"No themes are installed.":["No hay ningún tema instalado."],"No title":["Sin título"],"None":["Ninguno"],"Not %(type)s":["Excepto %(type)s"],"Not found":["No encontrado"],"Not serving":["No está sirviendo"],"Nothing declares that role.":["Nada declara ese rol."],"Nothing points here yet.":["Todavía no apunta nada aquí."],"Nothing published here yet.":["Todavía no hay nada publicado aquí."],"Nothing published yet.":["Todavía no hay nada publicado."],"November":["noviembre"],"Nowhere":["En ningún sitio"],"Number":["Número"],"October":["octubre"],"Older posts":["Entradas anteriores"],"One frame of this animation holds more than %(max)s pixels, which is more than Gophenberg opens. Upload a smaller animation.":["Un fotograma de esta animación tiene más de %(max)s píxeles, más de lo que Gophenberg abre. Sube una animación más pequeña."],"One of the filters on this list is not one Gophenberg understands. Clear the filters and try again.":["Uno de los filtros de esta lista no es un filtro que Gophenberg entienda. Quita los filtros e inténtalo de nuevo."],"One of the items picked for %(field)s is not the kind of content that field points at. Reload the page and pick from the list the field offers.":["Uno de los elementos elegidos para %(field)s no es del tipo de contenido al que apunta ese campo. Recarga la página y elige de la lista que ofrece el campo."],"One of the items picked for %(field)s was not named in a way Gophenberg understands. Reload the page and pick the items again.":["Uno de los elementos elegidos para %(field)s no venía nombrado de una forma que Gophenberg entienda. Recarga la página y vuelve a elegir los elementos."],"One of the items picked no longer exists. Reload the page and pick again.":["Uno de los elementos elegidos ya no existe. Recarga la página y vuelve a elegir."],"One target":["Un destino"],"Only a relation field points at another content type or holds many items. Pick the Relation kind, or leave the target and the many option empty.":["Solo un campo de relación apunta a otro tipo de contenido o guarda varios elementos. Elige el tipo Relación, o deja vacío el destino y la opción de varios."],"Only one file may be placed here.":["Aquí solo se puede colocar un archivo."],"Only the content type at the root of your site may go without a route word. Give this type one and try again.":["Solo el tipo de contenido que está en la raíz de tu sitio puede ir sin palabra de ruta de URL. Dale una a este tipo e inténtalo de nuevo."],"Open %(field)s in a new tab":["Abrir %(field)s en una pestaña nueva"],"Order stored.":["Orden guardado."],"Other":["Otro"],"Out of list":["Fuera de la lista"],"Parent":["Elemento superior"],"Password":["Contraseña"],"Pending":["Pendiente"],"Pending Review":["Pendiente de revisión"],"Permanently delete":["Borrar permanentemente"],"Permanently delete…":["Borrar permanentemente…"],"Picture quality":["Calidad de imagen"],"Picture quality runs from 1 to %(max)s, so %(value)s is not a quality Gophenberg stores at. Pick a number inside that range.":["La calidad de imagen va de 1 a %(max)s, así que %(value)s no es una calidad con la que Gophenberg guarde. Elige un número dentro de ese rango."],"Placeholder":["Texto de ejemplo"],"Plural name":["Nombre en plural"],"Points at":["Apunta a"],"Post published.":["Entrada publicada."],"Posts per page":["Entradas por página"],"Preview":["Vista previa"],"Private":["Privada"],"Publish":["Publicar"],"Published: %s":["Publicado: %s"],"Radio group":["Grupo de botones de radio"],"Reads from":["Lee de"],"Redo":["Rehacer"],"Relation":["Relación"],"Remove":["Quitar"],"Remove %(name)s":["Quitar %(name)s"],"Remove %(title)s":["Quitar %(title)s"],"Remove row":["Quitar fila"],"Rename":["Renombrar"],"Rename %(field)s":["Renombrar %(field)s"],"Rename…":["Renombrar…"],"Require":["Hacer obligatorio"],"Require %(field)s":["Hacer %(field)s obligatorio"],"Required":["Obligatorio"],"Restore":["Restaurar"],"Role changed.":["Rol cambiado."],"Roll back to %(theme)s":["Revertir a %(theme)s"],"Roll back to the built-in renderer":["Revertir al renderizador integrado"],"Route word":["Palabra de ruta"],"Rule %(number)s of set %(set)s":["Regla %(number)s del conjunto %(set)s"],"Rule set %(number)s":["Conjunto de reglas %(number)s"],"Rules":["Reglas"],"Rules showing %(field)s":["Reglas que muestran %(field)s"],"Save":["Guardar"],"Save draft":["Guardar el borrador"],"Save rules":["Guardar las reglas"],"Save settings":["Guardar los ajustes"],"Saved":["Guardado"],"Saving":["Guardando"],"Scheduled":["Programada"],"Scheduled: %s":["Programado: %s"],"Search %(type)s…":["Buscar %(type)s…"],"Search audio":["Buscar audios"],"Search images":["Buscar imágenes"],"Search media":["Buscar medios"],"Search videos":["Buscar vídeos"],"Select":["Seleccionar"],"September":["septiembre"],"Serving":["Atendiendo"],"Settings":["Ajustes"],"Settings of %(field)s":["Ajustes de %(field)s"],"Shadowed":["Eclipsado"],"Show %(field)s in the list":["Mostrar %(field)s en la lista"],"Showing %(held)s of %(total)s.":["Mostrando %(held)s de %(total)s."],"Singular name":["Nombre en singular"],"Slug":["Slug"],"Somebody must be able to manage users.":["Alguien tiene que poder gestionar los usuarios."],"Someone else changed this media item while you were editing it. Reload the page and make your change again.":["Otra persona ha cambiado este medio mientras lo editabas. Recarga la página y vuelve a hacer tu cambio."],"Someone else saved changes to this item while you were editing, so yours were not saved. Copy your text somewhere safe, then reload the item and put your changes back in.":["Otra persona ha guardado cambios en este elemento mientras editabas, así que los tuyos no se han guardado. Copia tu texto a un lugar seguro, recarga el elemento y vuelve a colocarlos de vuelta."],"Something went wrong inside Gophenberg. Try again in a moment, and tell whoever looks after your site if it keeps happening.":["Algo ha fallado dentro de Gophenberg. Inténtalo de nuevo en un momento, y avisa a quien gestione tu sitio si sigue pasando."],"Something went wrong.":["Algo ha ido mal."],"Something went wrong. Try again.":["Algo ha ido mal. Inténtalo de nuevo."],"Sorry, you are not allowed to access this page.":["Lo siento, no tienes permisos para acceder a esta página."],"Source":["Origen"],"Steps of":["Pasos de"],"Stop nesting":["Dejar de anidar"],"Tablet":["Tableta"],"Text":["Texto"],"Text area":["Área de texto"],"That email is already in use.":["Ese correo electrónico ya está en uso."],"That field group no longer exists. Reload the page to see the groups your site holds now.":["Ese grupo de campos ya no existe. Recarga la página para ver los grupos que tiene ahora tu sitio."],"That field is not on this content type any more. Reload the page to see the fields it has now.":["Ese campo ya no está en este tipo de contenido. Recarga la página para ver los campos que tiene ahora."],"That revision does not exist, and older ones drop off once a type holds more than it keeps. Open the item to see which revisions are left.":["Esa revisión no existe, y las más antiguas se pierden cuando un tipo guarda más de las que conserva. Abre el elemento para ver qué revisiones quedan."],"That role holds more than your own.":["Ese rol abarca más que el tuyo."],"That web address does not name a media item Gophenberg can look up. Open the media library and pick the item from there.":["Esa dirección web no nombra ningún medio que Gophenberg pueda encontrar. Abre la biblioteca de medios y elige el elemento desde ahí."],"That web address does not name a revision Gophenberg can look up. Open the item and pick a revision from its history.":["Esa dirección web no alude a ninguna revisión que Gophenberg pueda identificar. Abre el elemento y elige una revisión de su historial."],"That web address does not name an item Gophenberg can look up. Go back to the list and open the item from there.":["Esa dirección web no alude a ningún elemento que Gophenberg pueda identificar. Vuelve a la lista y abre el elemento desde ahí."],"The %(count)s item in the trash is deleted for good. This cannot be undone.":["%(count)s elemento de la papelera se borrará permanentemente. Esto no se puede deshacer.","%(count)s de elementos de la papelera se borrarán permanentemente. Esto no se puede deshacer.","Los %(count)s elementos de la papelera se borrarán permanentemente. Esto no se puede deshacer."],"The %(file)s calls this theme %(declared)s, but it is being installed as %(installed)s. Rename the zip file to match, or change the name inside %(file)s.":["El archivo %(file)s llama a este tema %(declared)s, pero se está instalando como %(installed)s. Renombra el zip para que coincida, o cambia el nombre dentro de %(file)s."],"The %(file)s inside %(name)s is not valid JSON, so it could not be read. Fix that file and package the theme again.":["El archivo %(file)s dentro de %(name)s no es JSON válido, así que no se ha podido leer. Corrige ese archivo y vuelve a empaquetar el tema."],"The %(file)s inside %(name)s names its theme kit as %(declared)s, which is not a version number. Build the theme again, and the build will fill that in.":["El %(file)s dentro de %(name)s identifica su kit de temas como %(declared)s, que no srive como número de versión. Compila el tema de nuevo y la compilación lo rellenará."],"The %(origin)s plugin declared this, so it can only be changed in that plugin. Turning it off is still yours to do.":["El plugin %(origin)s lo declaró, así que solo se puede cambiar en ese plugin. Desactivarlo sigue estando en tu mano."],"The %(plugin)s plugin also declares %(name)s, and the site keeps the key.":["El plugin %(plugin)s también declara %(name)s, y el sitio se queda con la clave."],"The %(plugin)s plugin declared these fields, so they change only in its code.":["El plugin %(plugin)s declaró estos campos, así que solo cambian en su código."],"The %(plugin)s plugin declares %(name)s flat, and the site keeps it nesting while its items sit inside another.":["El plugin %(plugin)s declara %(name)s sin anidar, y el sitio lo mantiene anidando mientras sus elementos están dentro de otro."],"The browser kept an unsaved version of this post.":["El navegador ha guardado una versión sin guardar de esta entrada."],"The built-in renderer is now serving the public site.":["El renderizador integrado ya presta servicio al sitio público."],"The built-in renderer is serving the public site.":["El renderizador integrado presta servicio al sitio público."],"The content type this field points at is not named in a shape Gophenberg accepts. Give it a key that starts with a lowercase letter and then holds only lowercase letters, numbers and hyphens.":["El tipo de contenido al que apunta este campo no está nombrado con un formato que Gophenberg acepte. Dale una clave que empiece por una letra minúscula y contenga solo letras minúsculas, números y guiones."],"The content types could not be loaded, so where these groups appear and which fields they shadow are not shown.":["No se han podido cargar los tipos de contenido, así que no se muestra dónde aparecen estos grupos ni qué campos eclipsan."],"The content types could not be loaded.":["No se han podido cargar los tipos de contenido."],"The content types could not be refreshed, so where these groups appear and what they shadow may be out of date.":["No se han podido actualizar los tipos de contenido, así que dónde aparecen estos grupos y qué eclipsan puede estar desactualizado."],"The draft could not be created.":["No se ha podido crear el borrador."],"The entry %(entry)s in this zip would be written outside the theme folder, so nothing was installed. Package the theme again.":["La entrada %(entry)s de este zip se escribiría fuera de la carpeta del tema, así que no se ha instalado nada. Vuelve a empaquetar el tema."],"The field %(field)s cannot go, because %(by)s reads it. Change %(by)s first.":["El campo %(field)s no se puede quitar, porque %(by)s lo lee. Cambia antes %(by)s."],"The field %(field)s cannot hold the value it was given. Check what kind of field it is and enter a value that matches.":["El campo %(field)s no puede guardar el valor que se le ha dado. Comprueba de qué tipo de campo se trata e introduce un valor que encaje."],"The field %(field)s has to hold a value before this item can be published. Fill it in, or leave the item as a draft.":["El campo %(field)s tiene que tener un valor antes de poder publicar este elemento. Rellénalo, o deja el elemento como borrador."],"The field %(field)s in %(group)s still points at this content type. Delete that field first, then delete this type.":["El campo %(field)s de %(group)s todavía apunta a este tipo de contenido. Borra antes ese campo y luego borra este tipo."],"The field %(field)s is hidden by its own rules right now, so it cannot hold a value. Show it again before filling it in.":["El campo %(field)s está oculto por sus propias reglas ahora mismo, así que no puede guardar ningún valor. Vuelve a mostrarlo antes de rellenarlo."],"The field %(field)s points at one item only. Remove the extra choices and keep one.":["El campo %(field)s apunta a un solo elemento. Quita las opciones de más y deja una."],"The field %(field)s points at other items, so it does not take a plain value. Pick the items it points at instead.":["El campo %(field)s apunta a otros elementos, así que no admite un valor suelto. Elige en su lugar los elementos a los que apunta."],"The field %(field)s takes a list of items, and what arrived was not a list. Reload the page and pick the items again.":["El campo %(field)s admite una lista de elementos, y lo que ha llegado no era una lista. Recarga la página y vuelve a elegir los elementos."],"The field %(source)s cannot hold the value %(value)s, so no rule can compare it. Write a value of the kind %(source)s stores.":["El campo %(source)s no puede guardar el valor %(value)s, así que ninguna regla puede compararlo. Escribe un valor de la clase que %(source)s guarda."],"The field groups could not be loaded.":["No se han podido cargar los grupos de campos."],"The field groups could not be reached.":["No se ha podido contactar con los grupos de campos."],"The field keeps its values on the content both groups reach.":["El campo conserva sus valores en el contenido al que llegan ambos grupos."],"The fields this group holds go with it, and so do the values every item stored in them.":["Los campos que contiene este grupo se van con él, y también los valores que cada elemento guardó en ellos."],"The groups were reordered.":["Los grupos se han reordenado."],"The item could not be duplicated.":["No se ha podido duplicar el elemento."],"The item could not be moved to the trash.":["No se ha podido mover el elemento a la papelera."],"The item could not be permanently deleted.":["No se ha podido borrar permanentemente el elemento."],"The item could not be restored.":["No se ha podido restaurar el elemento."],"The item you picked as parent holds another kind of content. Pick a parent of the same content type as this item.":["El elemento que has elegido como superior tiene otro tipo de contenido. Elige un elemento superior del mismo tipo de contenido que este elemento."],"The item you picked as parent is in the trash. Restore it, or pick another parent.":["El elemento que has elegido como superior está en la papelera. Restáuralo, o elige otro elemento superior."],"The item you picked as parent no longer exists. Reload the page and pick another parent.":["El elemento que has elegido como superior ya no existe. Recarga la página y elige otro."],"The media library could not be loaded.":["No se ha podido cargar la biblioteca de medios."],"The name changes. Nothing stored under this field moves.":["El nombre cambia. Nada de lo guardado en este campo se mueve."],"The name could not be updated.":["No se ha podido actualizar el nombre."],"The new order has to name every field group exactly once. Reload the page and move the groups again.":["El nuevo orden tiene que nombrar cada grupo de campos exactamente una vez. Recarga la página y mueve los grupos otra vez."],"The new order has to name every field of this content type exactly once. Reload the page and move the fields again.":["El nuevo orden tiene que nombrar cada campo de este tipo de contenido exactamente una vez. Recarga la página y mueve los campos de nuevo."],"The number of revisions to keep has to be a whole number, and it cannot be negative. Enter a different number.":["El número de revisiones que se conservan tiene que ser un número entero, y no puede ser negativo. Introduce otro número."],"The registry could not be reached.":["No se ha podido acceder al registro."],"The role could not be changed.":["No se ha podido cambiar el rol."],"The rule on %(source)s names nothing to match. Pick a value, or remove the rule.":["La regla sobre %(source)s no nombra nada con lo que coincidir. Elige un valor, o quita la regla."],"The rules could not be loaded.":["No se han podido cargar las reglas."],"The rules on %(field)s lead back to %(field)s itself, so nothing can decide whether to show it. Point one of them at another field.":["Las reglas de %(field)s vuelven al propio %(field)s, así que nada puede decidir si mostrarlo. Apunta una de ellas a otro campo."],"The same item was picked twice for %(field)s. Pick each item once.":["El mismo elemento se ha elegido dos veces para %(field)s. Elige cada elemento una sola vez."],"The server could not be reached, so nothing was changed.":["No se ha podido acceder al servidor, así que no se ha cambiado nada."],"The server could not be reached, so nothing was saved.":["No se ha podido acceder al servidor, así que no se ha guardado nada."],"The server could not be reached, so nothing was uploaded.":["No se ha podido acceder al servidor, así que no se ha subido nada."],"The setting %(setting)s cannot hold the value it was given. Check what this setting takes and try again.":["El ajuste %(setting)s no puede contener el valor que se le dio. Comprueba qué acepta este ajuste y prueba de nuevo."],"The setting %(setting)s falls outside the others. Keep min below max and the default inside the bounds.":["El ajuste %(setting)s queda fuera de los demás. Mantén min por debajo de max y el valor por defecto dentro de los límites."],"The settings could not be read, so nothing can be changed here yet.":["No se han podido leer los ajustes, así que todavía no se puede cambiar nada aquí."],"The site default":["El idioma por defecto del sitio"],"The site may already hold part of this file. Import it again to see what is left.":["Puede que el sitio ya tenga parte de este archivo. Impórtalo de nuevo para ver lo que queda."],"The source %(source)s does not offer %(operator)s. Pick one of the comparisons it does offer.":["La fuente %(source)s no ofrece %(operator)s. Elige una de las comparaciones que sí ofrece."],"The theme %(name)s carries no %(file)s, so Gophenberg cannot tell what it is. Put that file at the top level of the zip and package the theme again.":["El tema %(name)s no lleva ningún %(file)s, así que Gophenberg no puede saber qué es. Pon ese archivo en el primer nivel del zip y vuelve a empaquetar el tema."],"The theme %(name)s could not be read, so it cannot serve your site. Upload it again, or choose another theme.":["El tema %(name)s no se ha podido leer, así que no puede servir tu sitio. Vuelve a subirlo, o elige otro tema."],"The theme %(name)s did not start, so your site is still served by the theme it had. Ask whoever looks after your site to check the server log for what the theme reported.":["El tema %(name)s no ha arrancado, así que tu sitio lo sigue sirviendo el tema que tenía. Pide a quien gestiona tu sitio que mire en el registro del servidor para saber qué problema ha informado el tema."],"The theme %(name)s does not carry %(path)s as a file, so there is nothing to run. Package the built theme rather than its source.":["El tema %(name)s no lleva %(path)s como archivo, así que no hay nada que ejecutar. Empaqueta el tema ya construido, no su código fuente."],"The theme %(name)s does not carry %(path)s as a folder, so its styles and scripts are missing. Package the built theme rather than its source.":["El tema %(name)s no lleva %(path)s como carpeta, así que le faltan los estilos y los scripts. Empaqueta el tema ya construido, no su código fuente."],"The theme %(name)s holds a link to another file at %(path)s, and a theme may only carry real files. Replace it and package the theme again.":["El tema %(name)s lleva un enlace a otro archivo en %(path)s, y un tema solo puede llevar archivos de verdad. Sustitúyelo y vuelve a empaquetar el tema."],"The theme %(name)s is serving your site right now, so it cannot be replaced. Deactivate it first, or install this copy under another name.":["El tema %(name)s sirve tu sitio ahora mismo, así que no se puede sustituir. Desactívalo primero, o instala esta copia con otro nombre."],"The theme %(name)s is the one chosen, but it is not installed, so your site is served by the built-in renderer. Upload the theme again, or choose another one.":["El tema %(name)s es el elegido, pero no está instalado, así que el renderizador integrado está prestando servicio a tu sitio. Vuelve a subir el tema, o elige otro."],"The theme %(name)s was built on theme kit %(declared)s, and this site serves %(served)s. Build the theme again against a kit this site serves, then install it.":["El tema %(name)s se compiló con el kit de temas %(declared)s y este sitio sirve %(served)s. Compila el tema de nuevo con un kit que este sitio sirva y después instálalo."],"The trash could not be emptied.":["No se ha podido vaciar la papelera."],"The user could not be created.":["No se ha podido crear el usuario."],"The values stored under it do not follow.":["Los valores guardados debajo no le siguen."],"Theme":["Tema"],"Themes":["Temas"],"Themes could not be loaded.":["No se han podido cargar los temas."],"There is no content type called %(value)s. Pick a type your site holds, or register it first.":["No existe ningún tipo de contenido llamado %(value)s. Elige un tipo que tenga tu sitio, o regístralo primero."],"There is no content type called %(value)s. Pick one of the types your site holds and try again.":["No existe ningún tipo de contenido llamado %(value)s. Elige uno de los tipos que tiene tu sitio e inténtalo de nuevo."],"There is no kind of field called %(value)s. Pick one of these instead: %(allowed)s.":["No existe ningún tipo de campo llamado %(value)s. Elige uno de estos: %(allowed)s."],"There is no page kind called %(value)s. Pick one of these instead: %(allowed)s.":["No existe ningún tipo de página llamado %(value)s. Elige uno de estos: %(allowed)s."],"There is no status called %(value)s. Pick one of these instead: %(allowed)s.":["No existe ningún estado llamado %(value)s. Elige uno de estos: %(allowed)s."],"There is nothing at this address. Check the link and try again.":["No hay nada en esta dirección. Comprueba el enlace e inténtalo de nuevo."],"There is nothing to roll back to, because your site has not been through a theme change yet. Install a theme first, then a rollback has somewhere to go.":["No hay nada a lo que retornar, porque tu sitio todavía no ha cambiado de tema. Instala un tema primero y así el retorno tendrá adónde ir."],"These files could not be read.":["No se han podido leer estos archivos."],"These were left alone, because nobody confirmed losing them.":["Estos se han dejado como estaban, porque nadie confirmó perderlos."],"This content type does not exist any more. Reload the page to see the types your site holds now.":["Este tipo de contenido ya no existe. Recarga la página para ver los tipos que tiene ahora tu sitio."],"This content type does not nest, so one of its items cannot sit inside another. Mark the type as Nests first.":["Este tipo de contenido no anida, así que uno de sus elementos no puede estar dentro de otro. Marca el tipo como Anida primero."],"This content type has no field called %(field)s. Check the name, or declare the field on the type first.":["Este tipo de contenido no tiene ningún campo llamado %(field)s. Comprueba el nombre, o declara antes el campo en el tipo."],"This content type is turned off, so its content is not served or edited. Activate it first.":["Este tipo de contenido está desactivado, así que su contenido ni se sirve ni se edita. Actívalo primero."],"This content type still holds content, so it cannot be deleted. Move or delete its items first.":["Este tipo de contenido todavía tiene contenido, así que no se puede borrar. Mueve o borra antes sus elementos."],"This definitions file is larger than the %(max)s bytes Gophenberg reads in one request. Split it, or raise the limit your site runs with.":["Este archivo de definiciones es mayor que los %(max)s bytes que Gophenberg lee en una petición. Divídelo, o sube el límite con el que funciona tu sitio."],"This field holds no layouts yet.":["Este campo todavía no tiene diseños."],"This field would stand inside too many containers. Put it nearer the top and try again.":["Este campo quedaría dentro de demasiados contenedores. Ponlo más cerca del nivel superior e inténtalo de nuevo."],"This file could not be opened as a zip. Package the theme again and upload the new file.":["Este archivo no se ha podido abrir como zip. Vuelve a empaquetar el tema y sube el archivo nuevo."],"This file is larger than the upload limit. Upload a smaller file.":["Este archivo es más grande que el límite de subida. Sube un archivo más pequeño."],"This file is named %(extension)s but it holds %(detected)s content. Upload it under a name that matches what is inside it.":["Este archivo se llama %(extension)s pero lleva contenido %(detected)s. Súbelo con un nombre que coincida con lo que lleva dentro."],"This file matches the site, so there is nothing to change.":["Este archivo coincide con el sitio, así que no hay nada que cambiar."],"This file says it was written in format %(declared)s, which is not a version Gophenberg can read. Export the definitions again from the site they came from.":["Este archivo dice estar escrito en el formato %(declared)s, que no es una versión que Gophenberg pueda leer. Vuelve a exportar las definiciones desde el sitio del que vinieron."],"This file was written in format %(declared)s, and this release reads %(served)s. Upgrade the site the file came from, or this one.":["Este archivo está escrito en el formato %(declared)s, y esta versión lee %(served)s. Actualiza el sitio del que viene el archivo, o este."],"This group holds no fields yet.":["Este grupo todavía no tiene campos."],"This install of Gophenberg has no install root, so it updates by hand.":["Esta instalación de Gophenberg no tiene raíz de instalación, así que se actualiza a mano."],"This is a custom build of Gophenberg, so it updates by hand.":["Esta es una compilación propia de Gophenberg, así que se actualiza a mano."],"This item cannot go straight to that status from the one it holds now. Save it as a draft first, then try again.":["Este elemento no puede pasar directamente a ese estado desde el que tiene ahora. Guárdalo antes como borrador e inténtalo de nuevo."],"This item changed while you were describing it. Reload and try again.":["Este elemento ha cambiado mientras lo describías. Recarga e inténtalo de nuevo."],"This item does not exist, and it may have been deleted. Go back to the list to see what is there.":["Este elemento no existe, y puede que se haya borrado. Vuelve a la lista para ver qué hay."],"This item is already in the trash. Restore it first to work on it again.":["Este elemento ya está en la papelera. Restáuralo primero para volver a trabajar en él."],"This item is in the trash. Restore it first to work on it again.":["Este elemento está en la papelera. Restáuralo primero para volver a trabajar en él."],"This item is not in the trash, so there is nothing to restore. Open it from the list to work on it as it is.":["Este elemento no está en la papelera, así que no hay nada que restaurar. Ábrelo desde la lista para trabajar en él tal como está."],"This item still holds items nested inside it. Move or delete those first.":["Este elemento todavía tiene elementos anidados dentro. Mueve o borra antes esos."],"This kind of field holds no fields inside it. Put the field in a section or a repeater instead.":["Este tipo de campo no contiene campos dentro. Pon el campo en una sección o en un repetidor."],"This media item is not in the library, and someone may have deleted it. Reload the page to see what is there now.":["Este medio no está en la biblioteca, y puede que alguien lo haya borrado. Recarga la página para ver qué hay ahora."],"This picture could not be read, so nothing was stored. The file may be damaged, so open it on your computer and upload it again.":["Esta imagen no se ha podido leer, así que no se ha guardado nada. Puede que el archivo esté dañado, así que ábrelo en tu ordenador y vuelve a subirlo."],"This picture is %(width)s by %(height)s pixels, over the limit of %(max)s pixels in all. Make it smaller and upload it again.":["Esta imagen tiene %(width)s por %(height)s píxeles, por encima del límite de %(max)s píxeles en total. Hazla más pequeña y vuelve a subirla."],"This post changed elsewhere. Reload before saving again.":["Esta entrada ha cambiado en otro sitio. Recarga antes de volver a guardar."],"This request came from a page on another site, so it was refused. Open the admin on this site and try again.":["Esta petición vino de una página de otro sitio, así que se rechazó. Abre la administración en este sitio e inténtalo de nuevo."],"This request carried %(attribute)s, which fields do not take. Reload the page so the admin and the server agree again.":["Esta petición llevaba %(attribute)s, que los campos no aceptan. Recarga la página para que la administración y el servidor vuelvan a coincidir."],"This request left out %(field)s, which Gophenberg needs. Reload the page and try again.":["En esta petición faltaba %(field)s, que Gophenberg necesita. Recarga la página e inténtalo de nuevo."],"This rule reads a source called %(source)s, and nothing on your site declares one. A plugin that added it may have been removed.":["Esta regla lee una fuente llamada %(source)s, y nada en tu sitio declara ninguna. Puede que se haya quitado un plugin que la añadía."],"This site cannot write to its themes folder, so no theme can be installed. Ask whoever looks after your site to make the folder that %(setting)s points at writable. A read-only mount is the usual cause.":["Este sitio no puede escribir en su carpeta de temas, así que no se puede instalar ninguno. Pide a quien gestione tu sitio que haga escribible la carpeta a la que apunta %(setting)s. La causa habitual es un montaje de solo lectura."],"This site has nowhere to keep themes, so no theme can be installed. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Este sitio no tiene dónde guardar los temas, así que no se puede instalar ninguno. Pide a quien gestione tu sitio que apunte %(setting)s a una carpeta en la que el servidor pueda escribir."],"This site has nowhere to store uploads, so nothing can be uploaded. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Este sitio no tiene dónde guardar las subidas, así que no se puede subir nada. Pide a quien gestione tu sitio que apunte %(setting)s a una carpeta en la que el servidor pueda escribir."],"This theme is over the limit of %(max)s bytes, packed or unpacked. Leave out what the theme does not need and package it again.":["Este tema pasa del límite de %(max)s bytes, comprimido o sin comprimir. Saca lo que el tema no necesite y vuelve a empaquetarlo."],"This type will answer under /%(word)s.":["Este tipo responderá como /%(word)s."],"This zip holds %(entries)s files, more than the %(max)s a theme may carry. Package only the built theme.":["Este zip lleva %(entries)s archivos, más de los %(max)s que puede llevar un tema. Empaqueta solo el tema ya construido."],"Through":["A través de"],"Thumbnail":["Miniatura"],"Title":["Título"],"Two field groups in this file share the key %(key)s. Give one of them a different key and import again.":["Dos grupos de campos de este archivo comparten la clave %(key)s. Dale una clave distinta a uno de ellos e importa de nuevo."],"Two rule sources are registered under the name %(param)s. A plugin is declaring a source your site already holds.":["Hay dos fuentes de reglas registradas con el nombre %(param)s. Un plugin declara una fuente que tu sitio ya tiene."],"Type":["Tipo"],"Type a whole number in both boxes before saving.":["Escribe un número entero en las dos casillas antes de guardar."],"Undo":["Deshacer"],"Unsaved changes":["Cambios sin guardar"],"Update":["Actualizar"],"Update failed.":["No se ha podido actualizar."],"Upload theme":["Subir tema"],"Uploaded %s file.":["%s archivo subido.","%s de archivos subidos.","%s archivos subidos."],"Users":["Usuarios"],"Users could not be loaded.":["No se han podido cargar los usuarios."],"Value":["Valor"],"Version":["Versión"],"Videos":["Vídeos"],"Visit Site":["Visitar el sitio"],"Web address":["Dirección web"],"What each Linked from field reads":["Qué lee cada campo Enlazado desde"],"What it points at changed, so its stored values go with it.":["Aquello a lo que apunta ha cambiado, así que sus valores guardados se van con él."],"Where %(group)s appears":["Dónde aparece %(group)s"],"Without a rule this field always shows.":["Sin ninguna regla este campo siempre se muestra."],"Without a rule this group appears nowhere.":["Sin una regla este grupo no aparece en ningún sitio."],"Yes":["Sí"],"Yes or no":["Sí o no"],"You cannot change your own role.":["No puedes cambiar tu propio rol."],"Your language":["Tu idioma"],"Your role does not allow %(capability)s.":["Tu rol no permite %(capability)s."],"Your server pins the site to the theme %(name)s, so themes cannot be changed here. Ask whoever looks after your site to remove the pin.":["Tu servidor bloque el sitio al tema %(name)s, así que los temas no se pueden cambiar desde aquí. Pide a quien gestiona tu sitio que quite ese bloqueo."],"Your site always needs one default content type, so this one cannot be turned off or deleted. Make another type the default first.":["Tu sitio siempre necesita un tipo de contenido por defecto, así que este no se puede desactivar ni borrar. Haz antes que otro tipo sea el que está por defecto."],"a content type":["un tipo de contenido"],"account\u0004Role":["Rol"],"account\u0004Status":["Estado"],"action label\u0004Duplicate":["Duplicar"],"action label\u0004Duplicate…":["Duplicar…"],"address":["dirección"],"admin section\u0004Content Types":["Tipos de contenido"],"admin section\u0004Field Groups":["Grupos de campos"],"admin section\u0004Language":["Idioma"],"admin section\u0004Media":["Medios"],"admin section\u0004Settings":["Ajustes"],"admin section\u0004Themes":["Temas"],"column\u0004Date":["Fecha"],"content type\u0004\"%s\" permanently deleted.":["Se ha borrado permanentemente el tipo de contenido «%s»."],"content type\u0004\"%s\" successfully created.":["Se ha creado correctamente el tipo de contenido «%s»."],"content type\u0004Status":["Estado"],"field\u0004Name":["Nombre"],"field group\u0004Status":["Estado"],"field type\u0004Choice":["Elección"],"field type\u0004Color":["Color"],"field type\u0004Date":["Fecha"],"field type\u0004Flexible content":["Contenido flexible"],"field type\u0004Gallery":["Galería"],"field type\u0004Kind":["Tipo"],"field type\u0004Layout":["Diseño"],"field type\u0004Link":["Enlace"],"field type\u0004Media":["Medio"],"field type\u0004Range":["Rango"],"field type\u0004Repeater":["Repetidor"],"field type\u0004Section":["Sección"],"field type\u0004Select":["Desplegable"],"holds":["contiene"],"holds anything":["contiene algo"],"is":["es"],"is above":["es mayor que"],"is below":["es menor que"],"is empty":["está vacío"],"is not":["no es"],"media family\u0004Kind":["Clase"],"panel button label\u0004Settings":["Ajustes"],"person\u0004Name":["Nombre"],"post\u0004%s (Copy)":["%s (copia)"],"post\u0004Status":["Estado"],"post status\u0004Draft":["Borrador"],"post status\u0004Published":["Publicada"],"post status\u0004Trash":["Papelera"],"post type general name\u0004Pages":["Páginas"],"post type general name\u0004Posts":["Entradas"],"post type singular name\u0004Page":["Página"],"post type singular name\u0004Post":["Entrada"],"relation target\u0004None":["Ninguno"],"role\u0004Administrator":["Administrador"],"role\u0004Author":["Autor"],"role\u0004Editor":["Editor"],"role\u0004Member":["Miembro"],"role\u0004No role":["Sin rol"],"status filter\u0004All":["Todo"],"taxonomy general name\u0004Categories":["Categorías"],"taxonomy singular name\u0004Category":["Categoría"],"theme\u0004Status":["Estado"],"trash\u0004Restore":["Restaurar"],"v%(version)s":["v%(version)s"],"verb\u0004Trash…":["Papelera…"],"verb\u0004View":["Ver"],"{day} {month} {year}":["{day} de {month} de {year}"]} +{"":{"lang":"es","plural-forms":"nplurals=3; plural=((n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2));"}," and ":[" y "]," or ":[" o "],"\"%s\" activated.":["Se ha activado «%s»."],"\"%s\" deactivated.":["Se ha desactivado «%s»."],"\"%s\" has been restored.":["«%s» se ha restaurado."],"\"%s\" moved to the trash.":["«%s» trasladado a la papelera."],"\"%s\" permanently deleted.":["«%s» borrado permanentemente."],"\"%s\" successfully created.":["«%s» creado correctamente."],"\"%s\" uploaded.":["«%s» subido."],"%(field)s address":["Dirección de %(field)s"],"%(field)s cannot move inside itself. Pick a container that is not one of its own.":["%(field)s no puede moverse dentro de sí mismo. Elige un contenedor que no sea uno de los suyos."],"%(field)s declared.":["%(field)s declarado."],"%(field)s deleted.":["%(field)s borrado."],"%(field)s does not match the format its field asks for. Check it and save again.":["%(field)s no tiene el formato que su campo requiere. Revísalo y guarda de nuevo."],"%(field)s goes no higher than %(limit)s. Lower the value and save again.":["%(field)s no pasa de %(limit)s. Baja el valor y guarda de nuevo."],"%(field)s goes no lower than %(limit)s. Raise the value and save again.":["%(field)s no baja de %(limit)s. Sube el valor y guarda de nuevo."],"%(field)s holds no value of its own, so it cannot be required. Make it optional.":["%(field)s no guarda ningún valor propio, así que no puede ser obligatorio. Hazlo opcional."],"%(field)s is optional again.":["%(field)s vuelve a ser opcional."],"%(field)s is required now.":["%(field)s ahora es obligatorio."],"%(field)s moved.":["%(field)s movido."],"%(field)s names the same one twice. Remove the repeat and save again.":["%(field)s nombra el mismo dos veces. Quita la repetición y guarda de nuevo."],"%(field)s needs at least %(limit)s files. Add one and save again.":["%(field)s necesita al menos %(limit)s archivos. Añade uno y guarda de nuevo."],"%(field)s needs at least %(limit)s rows. Add one and save again.":["%(field)s necesita al menos %(limit)s filas. Añade una y guarda de nuevo."],"%(field)s only takes one of its listed choices. Pick one and save again.":["%(field)s solo admite una de las opciones de su lista. Elige una y guarda de nuevo."],"%(field)s reads a relation field that is not there. Pick a group and a relation field it holds.":["%(field)s lee un campo de relación que no existe. Elige un grupo y un campo de relación que contenga."],"%(field)s reads a relation pointing at %(type)s, so it would list nothing here. Pick a relation pointing at the items this group is shown on.":["%(field)s lee una relación que apunta a %(type)s, así que aquí no mostraría nada. Elige una relación que apunte a los elementos donde se muestra este grupo."],"%(field)s renamed.":["%(field)s renombrado."],"%(field)s settled.":["%(field)s ajustado."],"%(field)s shows by its rules now.":["%(field)s ahora se muestra según sus reglas."],"%(field)s stands in the list.":["%(field)s aparece en la lista."],"%(field)s stays out of the list.":["%(field)s se queda fuera de la lista."],"%(field)s takes at most %(limit)s files. Remove one and save again.":["%(field)s acepta como mucho %(limit)s archivos. Quita uno y guarda de nuevo."],"%(field)s takes at most %(limit)s letters. Shorten what you wrote and save again.":["%(field)s acepta como mucho %(limit)s letras. Acorta lo que has escrito y guarda de nuevo."],"%(field)s takes at most %(limit)s rows. Remove one and save again.":["%(field)s admite como mucho %(limit)s filas. Quita una y guarda de nuevo."],"%(field)s title":["Título de %(field)s"],"%(file)s is larger than one upload may be.":["%(file)s es más grande de lo que una subida permite."],"%(file)s is not a file this block accepts.":["%(file)s no es un archivo que este bloque acepte."],"%(file)s: %(reason)s":["%(file)s: %(reason)s"],"%(group)s created.":["%(group)s creado."],"%(group)s now appears where you said.":["%(group)s ya aparece donde has dicho."],"%(group)s removed.":["%(group)s eliminado."],"%(group)s updated.":["%(group)s actualizado."],"%(key)s is served by %(winner)s, so %(loser)s does not show it on %(where)s.":["%(key)s lo sirve %(winner)s, así que %(loser)s no lo muestra en %(where)s."],"%(name)s %(version)s is serving the public site.":["%(name)s %(version)s está publicado en la web."],"%(name)s belongs to the site now.":["%(name)s ya pertenece al sitio."],"%(name)s came from the %(plugin)s plugin, which no longer declares it.":["%(name)s vino del plugin %(plugin)s, que ya no lo declara."],"%(name)s cannot be used as a theme name. Name the zip file after the theme, with no dot at the start and no slashes in it.":["%(name)s no se puede usar como nombre de tema. Nombra el archivo zip como el tema, sin punto al principio y sin barras."],"%(name)s moves to /%(word)s. Every address of both types changes.":["El tipo %(name)s pasa a /%(word)s. Cambian todas las direcciones de ambos tipos."],"%(name)s would move to /%(word)s, which Gophenberg keeps for itself. Change its plural name and try again.":["El tipo %(name)s pasaría a /%(word)s, que Gophenberg reserva para sí mismo. Cambia su nombre en plural e inténtalo de nuevo."],"%(source)s is %(value)s":["%(source)s es %(value)s"],"%(theme)s failed to start, so the built-in renderer is serving.":["%(theme)s no pudo arrancar, así que el renderizador ha tomado el relevo."],"%(theme)s is now serving the public site.":["%(theme)s ya sirve el sitio público."],"%(theme)s is serving the public site.":["%(theme)s sirve el sitio público."],"%(theme)s is still starting, so the built-in renderer is serving.":["%(theme)s todavía está arrancando, así que el renderizador ha tomado el relevo."],"%(theme)s was installed.":["%(theme)s se ha instalado."],"%(theme)s will not load, so the built-in renderer is serving.":["%(theme)s no se carga, así que el renderizador ha tomado el relevo."],"%(type)s answers on a new address, and stored links move with it.":["%(type)s responde en una dirección nueva, y los enlaces guardados se mueven con ella."],"%(type)s becomes the type the site opens on, and the old one takes an address.":["%(type)s pasa a ser el tipo con el que abre el sitio, y el anterior toma una dirección."],"%(type)s keeps nesting while %(items)s of its items sit inside another. Move those to the top level first, the ones in the trash included.":["%(type)s sigue anidando mientras %(items)s de sus elementos están dentro de otro. Muévelos primero al nivel superior, incluidos los de la papelera."],"%(type)s keeps nesting, because some of its items sit inside another.":["%(type)s sigue anidando, porque algunos de sus elementos están dentro de otro."],"%(type)s will answer at the root.":["El tipo %(type)s responderá en la raíz."],"%s content type activated.":["Se ha activado %s tipo de contenido.","Se han activado %s de tipos de contenido.","Se han activado %s tipos de contenido."],"%s content type could not be activated.":["No se ha podido activar %s tipo de contenido.","No se han podido activar %s de tipos de contenido.","No se han podido activar %s tipos de contenido."],"%s content type could not be deactivated.":["No se ha podido desactivar %s tipo de contenido.","No se han podido desactivar %s de tipos de contenido.","No se han podido desactivar %s tipos de contenido."],"%s content type could not be permanently deleted.":["No se ha podido borrar permanentemente %s tipo de contenido.","No se han podido borrar permanentemente %s de tipos de contenido.","No se han podido borrar permanentemente %s tipos de contenido."],"%s content type deactivated.":["Se ha desactivado %s tipo de contenido.","Se han desactivado %s de tipos de contenido.","Se han desactivado %s tipos de contenido."],"%s content type permanently deleted.":["Se ha borrado permanentemente %s tipo de contenido.","Se han borrado permanentemente %s de tipos de contenido.","Se han borrado permanentemente %s tipos de contenido."],"%s item could not be moved to the trash.":["No se ha podido mover %s elemento a la papelera.","No se han podido mover %s de elementos a la papelera.","No se han podido mover %s elementos a la papelera."],"%s item could not be permanently deleted.":["No se ha podido borrar permanentemente %s elemento.","No se han podido borrar permanentemente %s de elementos.","No se han podido borrar permanentemente %s elementos."],"%s item could not be restored.":["No se ha podido restaurar %s elemento.","No se han podido restaurar %s de elementos.","No se han podido restaurar %s elementos."],"%s item moved to the trash.":["%s elemento movido a la papelera.","%s de elementos movidos a la papelera.","%s elementos movidos a la papelera."],"%s item permanently deleted.":["%s elemento borrado permanentemente.","%s de elementos borrados permanentemente.","%s elementos borrados permanentemente."],"%s item stays in the trash.":["%s elemento sigue en la papelera.","%s de elementos siguen en la papelera.","%s elementos siguen en la papelera."],"%s page has been restored.":["Se ha restaurado %s página.","Se han restaurado %s de páginas.","Se han restaurado %s páginas."],"%s post has been restored.":["Se ha restaurado %s entrada.","Se han restaurado %s de entradas.","Se han restaurado %s entradas."],"(no title)":["(sin título)"],"A %(kind)s field does not take a setting called %(setting)s. Remove it and try again.":["Un campo %(kind)s no acepta un ajuste llamado %(setting)s. Quítalo y prueba de nuevo."],"A Linked from field stands on its own, not inside a section or a repeater. Declare %(field)s beside the container.":["Un campo Enlazado desde va por su cuenta, no dentro de una sección ni de un repetidor. Declara %(field)s junto al contenedor."],"A content type needs a singular and a plural name, like Guide and Guides. Fill both in and try again.":["Un tipo de contenido necesita un nombre en singular y otro en plural, como Guía y Guías. Rellena ambos e inténtalo de nuevo."],"A descriptive summary of the content type.":["Un resumen descriptivo del tipo de contenido."],"A field group key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the group in the file so its key comes out in that shape.":["La clave de un grupo de campos tiene que empezar por una letra minúscula y después llevar solo letras minúsculas, números y guiones. Cambia el nombre del grupo en el archivo para que su clave salga con esa forma."],"A field key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the field so its key comes out in that shape.":["La clave de un campo tiene que empezar por una letra minúscula y contener solo letras minúsculas, números y guiones. Renombra el campo para que su clave tenga esa forma."],"A field needs a name. Type one and try again.":["El campo necesita un nombre. Escribe uno e inténtalo de nuevo."],"A field with that name already exists on the items this group reaches. Pick another name.":["Ya existe un campo con ese nombre en los elementos que este grupo alcanza. Elige otro nombre."],"A flexible content field holds layouts, and %(field)s is not one. Add a layout first, then declare the field inside that layout.":["Un campo de contenido flexible contiene diseños, y %(field)s no lo es. Añade primero un diseño y después declara el campo dentro de ese diseño."],"A higher quality stores larger files, and it applies to pictures uploaded from now on.":["Una calidad más alta almacena archivos más grandes, y se aplica a las imágenes que subas a partir de ahora."],"A layout stands inside a flexible content field only. Put %(field)s inside one of those instead.":["Un diseño solo va dentro de un campo de contenido flexible. Pon %(field)s dentro de uno de ellos."],"A layout stands inside a flexible content field, never on its own. Declare a flexible content field first, then add %(field)s inside it.":["Un diseño va dentro de un campo de contenido flexible, nunca por su cuenta. Declara primero un campo de contenido flexible y después añade %(field)s dentro."],"A listing carries between 1 and %(max)s items, so %(value)s is not a size Gophenberg takes. Pick a number inside that range.":["Un listado muestra entre 1 y %(max)s elementos, así que %(value)s no es un tamaño que Gophenberg acepte. Elige un número dentro de ese rango."],"A relation field has to say which content type it points at. Pick that type and try again.":["Un campo de relación tiene que indicar a qué tipo de contenido apunta. Elige ese tipo e inténtalo de nuevo."],"A reload shows the admin in the language you chose.":["Al recargar verás el panel de administración en el idioma que has elegido."],"A route word has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change it and try again.":["La palabra de ruta de la URL tiene que empezar por una letra minúscula y contener solo letras minúsculas, números y guiones. Cámbiala e inténtalo de nuevo."],"A row of %(field)s has to name the layout it takes. Pick a layout for the row, or remove the row.":["Una fila de %(field)s tiene que nombrar el diseño que toma. Elige un diseño para la fila, o quita la fila."],"A row of %(field)s takes one layout and no more. Reload the page and build the row again.":["Una fila de %(field)s toma un solo diseño. Recarga la página y crea la fila otra vez."],"A rule matching any %(source)s cannot be turned into an exclusion. Leave it as is, or name the ones to leave out instead.":["Una regla que vale para cualquier %(source)s no se puede convertir en exclusión. Déjala como está, o nombra los que quieras dejar fuera."],"A theme naming its own page size uses that instead.":["Un tema que indica su propio tamaño de página usa ese en su lugar."],"A type key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change the key and try again.":["La clave de un tipo tiene que empezar por una letra minúscula y contener solo letras minúsculas, números y guiones. Cambia la clave e inténtalo de nuevo."],"Actions":["Acciones"],"Activate":["Activar"],"Activate %(theme)s":["Activar %(theme)s"],"Active":["Activo"],"Add":["Añadir"],"Add %(layout)s":["Añadir %(layout)s"],"Add %(name)s":["Añadir %(name)s"],"Add %(type)s":["Añadir %(type)s"],"Add Category":["Añadir categoría"],"Add Content Type":["Añadir tipo de contenido"],"Add Field Group":["Añadir grupo de campos"],"Add Media File":["Añadir medios"],"Add Page":["Añadir página"],"Add Post":["Añadir entrada"],"Add User":["Añadir usuario"],"Add block":["Añadir bloque"],"Add choice":["Añadir opción"],"Add condition":["Añadir condición"],"Add field":["Añadir campo"],"Add field to %(field)s":["Añadir campo a %(field)s"],"Add layout":["Añadir diseño"],"Add layout to %(field)s":["Añadir diseño a %(field)s"],"Add one with %(button)s.":["Añade un elemento con %(button)s."],"Add row":["Añadir fila"],"Add rule set":["Añadir conjunto de reglas"],"Add title":["Añadir título"],"Add to %(field)s":["Añadir a %(field)s"],"Address":["Dirección"],"Address updated.":["Dirección actualizada."],"Adopt":["Adoptar"],"Allow custom":["Permitir valores propios"],"Allow empty":["Permitir vacío"],"Alt text":["Texto alternativo"],"An item cannot point at itself. Pick a different item for %(field)s.":["Un elemento no puede apuntarse a sí mismo. Elige otro elemento para %(field)s."],"An item cannot sit inside itself, or inside one of the items nested under it. Pick a parent outside this branch.":["Un elemento no puede estar dentro de sí mismo, ni dentro de otro anidado bajo él. Elige un nivel superior fuera de esta rama."],"Another account wrote this, and only its author, an editor or an administrator may change it. You can still read it.":["Otra cuenta escribió esto, y solo su autor, un editor o un administrador pueden cambiarlo. Todavía puedes leerlo."],"Another account wrote this, so you are reading it rather than editing it.":["Otra cuenta escribió esto, así que lo estás leyendo, no editando."],"Another content type already answers under this route word. Pick a different word.":["Ya hay otro tipo de contenido que responde con esta palabra de ruta de URL. Elige una palabra distinta."],"Another content type already lives at the root of your site, and only one can. Give this type a route word, or hand the root over with Make default.":["Ya hay otro tipo de contenido en la raíz de tu sitio, y solo puede haber uno. Dale a este tipo de contenido una palabra de ruta de URL, o cede la raíz con Establecer como predeterminado."],"Another content type already uses this key. Give this type a different name.":["Ya hay otro tipo de contenido que usa esta clave. Dale a este tipo un nombre distinto."],"Another item of this content type already uses that address. Pick a different slug and save again.":["Ya hay otro elemento de este tipo de contenido que usa esa dirección. Elige un slug distinto y guarda de nuevo."],"Another update is currently in progress.":["En este momento hay otra actualización en marcha."],"Appears on":["Aparece en"],"Apply":["Aplicar"],"April":["abril"],"Are you sure you want to move \"%s\" to the trash?":["¿Seguro que quieres mover «%s» a la papelera?"],"Audio":["Audio"],"August":["agosto"],"Author":["Autor"],"Back to %(type)s":["Volver a %(type)s"],"Back to the latest posts":["Volver a las últimas entradas"],"Block":["Bloque"],"Broken":["Roto"],"Bundles of fields, shown where their rules place them.":["Conjuntos de campos, mostrados donde sus reglas los colocan."],"Button group":["Grupo de botones"],"Cancel":["Cancelar"],"Caption":["Leyenda"],"Change %(name)s":["Cambiar %(name)s"],"Change address":["Cambiar la dirección"],"Change address…":["Cambiar la dirección…"],"Change role":["Cambiar el rol"],"Change the role of %(name)s":["Cambiar el rol de %(name)s"],"Checkbox group":["Grupo de casillas"],"Choices":["Opciones"],"Choose %(field)s":["Elegir %(field)s"],"Choose how the site serves its content.":["Elige cómo el sitio sirve su contenido."],"Choose the language the admin reads in.":["Elige el idioma en el que se lee el panel de administración."],"Choose the role of %(name)s.":["Elige el rol de %(name)s."],"Choose the version to update to.":["Elige la versión a la que actualizar."],"Clear %(field)s":["Vaciar %(field)s"],"Close":["Cerrar"],"Close Settings":["Cerrar ajustes"],"Closed":["Cerrado"],"Condition":["Condición"],"Content":["Contenido"],"Content Types":["Tipos de contenido"],"Content nests at most %(max)s levels deep, and this item would sit deeper. Pick a parent closer to the top.":["El contenido se anida como mucho %(max)s niveles, y este elemento quedaría más abajo. Elige un nivel superior más cercano al inicio."],"Content type":["Tipo de contenido"],"Content type updated.":["Tipo de contenido actualizado."],"Could not load that post.":["No se ha podido cargar esa entrada."],"Could not save image. %s":["No se ha podido guardar la imagen. %s"],"Could not save media. %s":["No se ha podido guardar el archivo multimedia. %s"],"Could not save that post.":["No se ha podido guardar esa entrada."],"Create":["Crear"],"Create a field group":["Crear un grupo de campos"],"Create user":["Crear usuario"],"Created":["Fecha de creación"],"Date added":["Fecha de inclusión"],"Date modified":["Fecha de modificación"],"Deactivate":["Desactivar"],"Deactivate %(theme)s":["Desactivar %(theme)s"],"December":["diciembre"],"Default":["Por defecto"],"Default content type updated.":["Tipo de contenido predeterminado actualizado."],"Definitions file":["Archivo de definiciones"],"Delete":["Borrar"],"Delete \"%(title)s\" for good? This cannot be undone.":["¿Borrar permanentemente «%(title)s»? Esto no se puede deshacer."],"Delete %(field)s":["Borrar %(field)s"],"Delete %(group)s":["Eliminar %(group)s"],"Delete %s content type for good? This cannot be undone.":["¿Borrar permanentemente %s tipo de contenido? Esto no se puede deshacer.","¿Borrar permanentemente %s de tipos de contenido? Esto no se puede deshacer.","¿Borrar permanentemente %s tipos de contenido? Esto no se puede deshacer."],"Delete %s item for good? This cannot be undone.":["¿Borrar permanentemente %s elemento? Esto no se puede deshacer.","¿Borrar permanentemente %s de elementos? Esto no se puede deshacer.","¿Borrar permanentemente %s elementos? Esto no se puede deshacer."],"Delete permanently":["Borrar permanentemente"],"Delete the field":["Borrar el campo"],"Delete the group":["Eliminar el grupo"],"Delete…":["Borrar…"],"Describe":["Describir"],"Describe the purpose of the image.":["Describe el propósito de la imagen."],"Describe…":["Describir…"],"Description":["Descripción"],"Description updated.":["Descripción actualizada."],"Desktop":["Escritorio"],"Destination":["Destino"],"Dimensions":["Dimensiones"],"Disable":["Desactivar"],"Disable %(name)s":["Desactivar a %(name)s"],"Disabled":["Desactivado"],"Dismiss":["Descartar"],"Document":["Documento"],"Documents":["Documentos"],"Done. The site now holds what the file describes.":["Hecho. El sitio ya tiene lo que describe el archivo."],"Draft saved.":["Borrador guardado."],"Drop files to upload":["Arrastra los archivos para subirlos"],"Edit media":["Editar los medios"],"Edit…":["Editar…"],"Email":["Correo electrónico"],"Empty Trash":["Vaciar papelera"],"Empty trash":["Vaciar papelera"],"Enable":["Activar"],"Enable %(name)s":["Activar a %(name)s"],"Every address of this type moves. Links kept elsewhere to the old addresses stop working.":["Cambian todas las direcciones de este tipo. Los enlaces guardados en otro sitio hacia las direcciones antiguas dejan de funcionar."],"Every content type":["Todos los tipos de contenido"],"Every field group needs a title. Give it a name and save again.":["Todo grupo de campos necesita un título. Ponle un nombre y guarda otra vez."],"Every item needs an author who exists. Pick one from the list and save again.":["Cada elemento necesita un autor que exista. Elige uno de la lista y guarda de nuevo."],"Every kind of content this site holds.":["Todos los tipos de contenido que hay en este sitio."],"Every value stored under this field goes with it, in every item the group reaches and in the revisions behind them.":["Todos los valores guardados en este campo se van con él, en cada elemento que el grupo alcanza y en las revisiones que hay detrás."],"Excerpt":["Extracto"],"Export definitions":["Exportar definiciones"],"February":["febrero"],"Fewest files":["Mínimo de archivos"],"Fewest rows":["Mínimo de filas"],"Field":["Campo"],"Field Groups":["Grupos de campos"],"Field group":["Grupo de campos"],"Fields":["Campos"],"Fields of %(group)s":["Campos de %(group)s"],"File name":["Nombre del archivo"],"File size":["Tamaño del archivo"],"File type":["Tipo de archivo"],"Filter by status":["Filtrar por estado"],"Follow the browser":["Sigue al navegador"],"From %(plugin)s":["De %(plugin)s"],"Full Size":["Tamaño completo"],"Gophenberg cannot start this update: %(reason)s":["Gophenberg no puede empezar esta actualización: %(reason)s"],"Gophenberg checked for updates moments ago. Try again later.":["Gophenberg ha buscado actualizaciones hace un momento. Inténtalo de nuevo más tarde."],"Gophenberg could not read what your browser sent. Reload the page and try again, and tell whoever looks after your site if it keeps happening.":["Gophenberg no ha podido leer lo que ha enviado tu navegador. Recarga la página e inténtalo de nuevo, y avisa a quien gestiona tu sitio si sigue pasando."],"Gophenberg could not read which item you picked as parent. Reload the page and pick it again.":["Gophenberg no ha podido leer qué elemento has elegido como elemento superior. Recarga la página y vuelve a elegirlo."],"Gophenberg does not accept %(extension)s files. Upload a picture, a video, an audio file, a PDF or a zip instead.":["Gophenberg no acepta archivos %(extension)s. Sube una imagen, un vídeo, un audio, un PDF o un zip."],"Gophenberg does not answer in %(value)s. Pick one of the languages on the list.":["Gophenberg no responde en %(value)s. Elige uno de los idiomas de la lista."],"Gophenberg has no autosave of yours for this item. Open the item to see the version that is stored.":["Gophenberg no tiene ningún guardado automático tuyo de este elemento. Ábrelo para ver la versión guardada."],"Gophenberg holds no kind of definition called %(subject)s, so nothing was taken over. Reload the page so the admin and the server agree again.":["Gophenberg no tiene ninguna clase de definición llamada %(subject)s, así que no se ha adoptado nada. Recarga la página para que el admin y el servidor vuelvan a coincidir."],"Gophenberg keeps /%(path)s for itself or for another content type, so your content cannot answer there. Give the item a different slug.":["Gophenberg reserva la dirección /%(path)s para sí mismo o para otro tipo de contenido, así que tu contenido no puede usarla. Dale al elemento otro slug."],"Gophenberg keeps this word for itself, so no content type can answer under it. Pick a different route word.":["Gophenberg reserva esta palabra para sí mismo, así que ningún tipo de contenido puede usarla. Elige otra palabra de ruta de URL."],"Group":["Grupo"],"Highest":["Máximo"],"Holds":["Contiene"],"Images":["Imágenes"],"Import definitions":["Importar definiciones"],"In list":["En la lista"],"Inactive":["Inactivo"],"Installed":["Instalado"],"Instructions":["Instrucciones"],"It moves here from another group and keeps its stored values.":["Se mueve aquí desde otro grupo y conserva sus valores guardados."],"It moves to another group and keeps its stored values.":["Se mueve a otro grupo y conserva sus valores guardados."],"It moves to another group, so its stored values do not follow.":["Se mueve a otro grupo, así que sus valores guardados no le siguen."],"It reads a relation inside a container, and only an import can point it elsewhere.":["Lee una relación dentro de un contenedor, y solo una importación puede apuntarlo a otro sitio."],"It starts on %(type)s, and the rules may be edited afterwards.":["Empieza en %(type)s, y las reglas se pueden editar después."],"Items could not be loaded.":["No se han podido cargar los elementos."],"Its items sit inside one another, so it keeps nesting.":["Sus elementos están unos dentro de otros, así que sigue anidando."],"Its kind changed, so its stored values go with it.":["Su tipo ha cambiado, así que sus valores guardados se van con él."],"January":["enero"],"July":["julio"],"June":["junio"],"Keep %(field)s out of the list":["Dejar %(field)s fuera de la lista"],"Keep it":["Conservarlo"],"Keep it here":["Dejarlo aquí"],"Key":["Clave"],"Label":["Etiqueta"],"Language":["Idioma"],"Large":["Grande"],"Leave empty if decorative.":["Déjalo vacío si es decorativo."],"Let items nest":["Permitir anidar"],"Linked from":["Enlazado desde"],"List view":["Vista de lista"],"Loading field groups.":["Cargando los grupos de campos."],"Loading the post.":["Cargando la entrada."],"Loading the screen.":["Cargando la pantalla."],"Loading themes.":["Cargando los temas."],"Loading users.":["Cargando los usuarios."],"Loading…":["Cargando…"],"Longest":["Largo máximo"],"Lowest":["Mínimo"],"Make %(field)s optional":["Hacer %(field)s opcional"],"Make default":["Establecer como predeterminado"],"Make default…":["Establecer como predeterminado…"],"Make optional":["Hacer opcional"],"Manage the categories on this site.":["Gestiona las categorías de este sitio."],"Manage the images and files this site holds.":["Gestiona las imágenes y los archivos que hay en este sitio."],"Manage the items of this content type.":["Gestiona los elementos de este tipo de contenido."],"Manage the pages on this site.":["Gestiona las páginas de este sitio."],"Manage the posts on this site.":["Gestiona las entradas de este sitio."],"Many targets":["Varios destinos"],"Many values":["Varios valores"],"March":["marzo"],"May":["mayo"],"Media %(id)s":["Medio %(id)s"],"Media Library":["Biblioteca de medios"],"Media file updated.":["Archivo de medios actualizado."],"Medium":["Mediano"],"Mobile":["Móvil"],"Modified: %s":["Modificado: %s"],"Most files":["Máximo de archivos"],"Most rows":["Máximo de filas"],"Move":["Mover"],"Move \"%(title)s\" to the trash?":["¿Mover «%(title)s» a la papelera?"],"Move %(field)s down":["Bajar %(field)s"],"Move %(field)s elsewhere":["Mover %(field)s a otro sitio"],"Move %(field)s up":["Subir %(field)s"],"Move %(group)s down":["Bajar %(group)s"],"Move %(group)s up":["Subir %(group)s"],"Move %(name)s down":["Bajar %(name)s"],"Move %(name)s up":["Subir %(name)s"],"Move %s item to the trash?":["¿Mover %s elemento a la papelera?","¿Mover %s de elementos a la papelera?","¿Mover %s elementos a la papelera?"],"Move every address":["Mover todas las direcciones"],"Move row down":["Bajar la fila"],"Move row up":["Subir la fila"],"Move the field":["Mover el campo"],"Move to trash":["Mover a la papelera"],"Name":["Nombre"],"Name updated.":["Nombre actualizado."],"Nests":["Anida"],"Newer posts":["Entradas más recientes"],"No":["No"],"No content types found.":["No se han encontrado tipos de contenido."],"No description":["Sin descripción"],"No file arrived with this upload. Choose a file and try again.":["No ha llegado ningún archivo con esta subida. Elige un archivo e inténtalo de nuevo."],"No items found.":["No se han encontrado elementos."],"No items yet.":["Todavía no hay elementos."],"No media chosen":["Ningún medio elegido"],"No media has been uploaded yet.":["Todavía no se ha subido ningún medio."],"No media items found.":["No se han encontrado archivos de medios."],"No media items yet.":["Todavía no hay archivos de medios."],"No page lives at this address.":["No existe ninguna página en esta dirección."],"No parent":["Sin elemento superior"],"No theme arrived with this upload. Choose a zip file and try again.":["No ha llegado ningún tema con esta subida. Elige un archivo zip e inténtalo de nuevo."],"No themes are installed.":["No hay ningún tema instalado."],"No title":["Sin título"],"None":["Ninguno"],"Not %(type)s":["Excepto %(type)s"],"Not found":["No encontrado"],"Not serving":["No está sirviendo"],"Nothing declares that role.":["Nada declara ese rol."],"Nothing points here yet.":["Todavía no apunta nada aquí."],"Nothing published here yet.":["Todavía no hay nada publicado aquí."],"Nothing published yet.":["Todavía no hay nada publicado."],"November":["noviembre"],"Nowhere":["En ningún sitio"],"Number":["Número"],"October":["octubre"],"Older posts":["Entradas anteriores"],"One frame of this animation holds more than %(max)s pixels, which is more than Gophenberg opens. Upload a smaller animation.":["Un fotograma de esta animación tiene más de %(max)s píxeles, más de lo que Gophenberg abre. Sube una animación más pequeña."],"One of the filters on this list is not one Gophenberg understands. Clear the filters and try again.":["Uno de los filtros de esta lista no es un filtro que Gophenberg entienda. Quita los filtros e inténtalo de nuevo."],"One of the items picked for %(field)s is not the kind of content that field points at. Reload the page and pick from the list the field offers.":["Uno de los elementos elegidos para %(field)s no es del tipo de contenido al que apunta ese campo. Recarga la página y elige de la lista que ofrece el campo."],"One of the items picked for %(field)s was not named in a way Gophenberg understands. Reload the page and pick the items again.":["Uno de los elementos elegidos para %(field)s no venía nombrado de una forma que Gophenberg entienda. Recarga la página y vuelve a elegir los elementos."],"One of the items picked no longer exists. Reload the page and pick again.":["Uno de los elementos elegidos ya no existe. Recarga la página y vuelve a elegir."],"One target":["Un destino"],"Only a relation field points at another content type or holds many items. Pick the Relation kind, or leave the target and the many option empty.":["Solo un campo de relación apunta a otro tipo de contenido o guarda varios elementos. Elige el tipo Relación, o deja vacío el destino y la opción de varios."],"Only one file may be placed here.":["Aquí solo se puede colocar un archivo."],"Only the content type at the root of your site may go without a route word. Give this type one and try again.":["Solo el tipo de contenido que está en la raíz de tu sitio puede ir sin palabra de ruta de URL. Dale una a este tipo e inténtalo de nuevo."],"Open %(field)s in a new tab":["Abrir %(field)s en una pestaña nueva"],"Order stored.":["Orden guardado."],"Other":["Otro"],"Out of list":["Fuera de la lista"],"Parent":["Elemento superior"],"Password":["Contraseña"],"Pending":["Pendiente"],"Pending Review":["Pendiente de revisión"],"Permanently delete…":["Eliminar permanentemente…"],"Picture quality":["Calidad de imagen"],"Picture quality runs from 1 to %(max)s, so %(value)s is not a quality Gophenberg stores at. Pick a number inside that range.":["La calidad de imagen va de 1 a %(max)s, así que %(value)s no es una calidad con la que Gophenberg guarde. Elige un número dentro de ese rango."],"Placeholder":["Texto de ejemplo"],"Plural name":["Nombre en plural"],"Points at":["Apunta a"],"Post published.":["Entrada publicada."],"Posts per page":["Entradas por página"],"Preview":["Vista previa"],"Private":["Privada"],"Publish":["Publicar"],"Published: %s":["Publicado: %s"],"Radio group":["Grupo de botones de radio"],"Reads from":["Lee de"],"Redo":["Rehacer"],"Relation":["Relación"],"Remove":["Quitar"],"Remove %(name)s":["Quitar %(name)s"],"Remove %(title)s":["Quitar %(title)s"],"Remove row":["Quitar fila"],"Rename":["Renombrar"],"Rename %(field)s":["Renombrar %(field)s"],"Rename…":["Renombrar…"],"Require":["Hacer obligatorio"],"Require %(field)s":["Hacer %(field)s obligatorio"],"Required":["Obligatorio"],"Restore":["Restaurar"],"Role changed.":["Rol cambiado."],"Roll back to %(theme)s":["Revertir a %(theme)s"],"Roll back to the built-in renderer":["Revertir al renderizador integrado"],"Route word":["Palabra de ruta"],"Rule %(number)s of set %(set)s":["Regla %(number)s del conjunto %(set)s"],"Rule set %(number)s":["Conjunto de reglas %(number)s"],"Rules":["Reglas"],"Rules showing %(field)s":["Reglas que muestran %(field)s"],"Save":["Guardar"],"Save draft":["Guardar el borrador"],"Save rules":["Guardar las reglas"],"Save settings":["Guardar los ajustes"],"Saved":["Guardado"],"Saving":["Guardando"],"Scheduled":["Programada"],"Scheduled: %s":["Programado: %s"],"Search %(type)s…":["Buscar %(type)s…"],"Search audio":["Buscar audios"],"Search images":["Buscar imágenes"],"Search media":["Buscar medios"],"Search videos":["Buscar vídeos"],"Select":["Seleccionar"],"September":["septiembre"],"Serving":["Atendiendo"],"Settings":["Ajustes"],"Settings of %(field)s":["Ajustes de %(field)s"],"Shadowed":["Eclipsado"],"Show %(field)s in the list":["Mostrar %(field)s en la lista"],"Showing %(held)s of %(total)s.":["Mostrando %(held)s de %(total)s."],"Singular name":["Nombre en singular"],"Slug":["Slug"],"Somebody must be able to manage users.":["Alguien tiene que poder gestionar los usuarios."],"Someone else changed this media item while you were editing it. Reload the page and make your change again.":["Otra persona ha cambiado este medio mientras lo editabas. Recarga la página y vuelve a hacer tu cambio."],"Someone else saved changes to this item while you were editing, so yours were not saved. Copy your text somewhere safe, then reload the item and put your changes back in.":["Otra persona ha guardado cambios en este elemento mientras editabas, así que los tuyos no se han guardado. Copia tu texto a un lugar seguro, recarga el elemento y vuelve a colocarlos de vuelta."],"Something went wrong inside Gophenberg. Try again in a moment, and tell whoever looks after your site if it keeps happening.":["Algo ha fallado dentro de Gophenberg. Inténtalo de nuevo en un momento, y avisa a quien gestione tu sitio si sigue pasando."],"Something went wrong.":["Algo ha ido mal."],"Something went wrong. Try again.":["Algo ha ido mal. Inténtalo de nuevo."],"Sorry, you are not allowed to access this page.":["Lo siento, no tienes permisos para acceder a esta página."],"Source":["Origen"],"Steps of":["Pasos de"],"Stop nesting":["Dejar de anidar"],"Tablet":["Tableta"],"Text":["Texto"],"Text area":["Área de texto"],"That email is already in use.":["Ese correo electrónico ya está en uso."],"That field group no longer exists. Reload the page to see the groups your site holds now.":["Ese grupo de campos ya no existe. Recarga la página para ver los grupos que tiene ahora tu sitio."],"That field is not on this content type any more. Reload the page to see the fields it has now.":["Ese campo ya no está en este tipo de contenido. Recarga la página para ver los campos que tiene ahora."],"That revision does not exist, and older ones drop off once a type holds more than it keeps. Open the item to see which revisions are left.":["Esa revisión no existe, y las más antiguas se pierden cuando un tipo guarda más de las que conserva. Abre el elemento para ver qué revisiones quedan."],"That role holds more than your own.":["Ese rol abarca más que el tuyo."],"That web address does not name a media item Gophenberg can look up. Open the media library and pick the item from there.":["Esa dirección web no nombra ningún medio que Gophenberg pueda encontrar. Abre la biblioteca de medios y elige el elemento desde ahí."],"That web address does not name a revision Gophenberg can look up. Open the item and pick a revision from its history.":["Esa dirección web no alude a ninguna revisión que Gophenberg pueda identificar. Abre el elemento y elige una revisión de su historial."],"That web address does not name an item Gophenberg can look up. Go back to the list and open the item from there.":["Esa dirección web no alude a ningún elemento que Gophenberg pueda identificar. Vuelve a la lista y abre el elemento desde ahí."],"The %(count)s item in the trash is deleted for good. This cannot be undone.":["%(count)s elemento de la papelera se borrará permanentemente. Esto no se puede deshacer.","%(count)s de elementos de la papelera se borrarán permanentemente. Esto no se puede deshacer.","Los %(count)s elementos de la papelera se borrarán permanentemente. Esto no se puede deshacer."],"The %(file)s calls this theme %(declared)s, but it is being installed as %(installed)s. Rename the zip file to match, or change the name inside %(file)s.":["El archivo %(file)s llama a este tema %(declared)s, pero se está instalando como %(installed)s. Renombra el zip para que coincida, o cambia el nombre dentro de %(file)s."],"The %(file)s inside %(name)s is not valid JSON, so it could not be read. Fix that file and package the theme again.":["El archivo %(file)s dentro de %(name)s no es JSON válido, así que no se ha podido leer. Corrige ese archivo y vuelve a empaquetar el tema."],"The %(file)s inside %(name)s names its theme kit as %(declared)s, which is not a version number. Build the theme again, and the build will fill that in.":["El %(file)s dentro de %(name)s identifica su kit de temas como %(declared)s, que no srive como número de versión. Compila el tema de nuevo y la compilación lo rellenará."],"The %(origin)s plugin declared this, so it can only be changed in that plugin. Turning it off is still yours to do.":["El plugin %(origin)s lo declaró, así que solo se puede cambiar en ese plugin. Desactivarlo sigue estando en tu mano."],"The %(plugin)s plugin also declares %(name)s, and the site keeps the key.":["El plugin %(plugin)s también declara %(name)s, y el sitio se queda con la clave."],"The %(plugin)s plugin declared these fields, so they change only in its code.":["El plugin %(plugin)s declaró estos campos, así que solo cambian en su código."],"The %(plugin)s plugin declares %(name)s flat, and the site keeps it nesting while its items sit inside another.":["El plugin %(plugin)s declara %(name)s sin anidar, y el sitio lo mantiene anidando mientras sus elementos están dentro de otro."],"The browser kept an unsaved version of this post.":["El navegador ha guardado una versión sin guardar de esta entrada."],"The built-in renderer is now serving the public site.":["El renderizador integrado ya presta servicio al sitio público."],"The built-in renderer is serving the public site.":["El renderizador integrado presta servicio al sitio público."],"The content type this field points at is not named in a shape Gophenberg accepts. Give it a key that starts with a lowercase letter and then holds only lowercase letters, numbers and hyphens.":["El tipo de contenido al que apunta este campo no está nombrado con un formato que Gophenberg acepte. Dale una clave que empiece por una letra minúscula y contenga solo letras minúsculas, números y guiones."],"The content types could not be loaded, so where these groups appear and which fields they shadow are not shown.":["No se han podido cargar los tipos de contenido, así que no se muestra dónde aparecen estos grupos ni qué campos eclipsan."],"The content types could not be loaded.":["No se han podido cargar los tipos de contenido."],"The content types could not be refreshed, so where these groups appear and what they shadow may be out of date.":["No se han podido actualizar los tipos de contenido, así que dónde aparecen estos grupos y qué eclipsan puede estar desactualizado."],"The draft could not be created.":["No se ha podido crear el borrador."],"The entry %(entry)s in this zip would be written outside the theme folder, so nothing was installed. Package the theme again.":["La entrada %(entry)s de este zip se escribiría fuera de la carpeta del tema, así que no se ha instalado nada. Vuelve a empaquetar el tema."],"The field %(field)s cannot go, because %(by)s reads it. Change %(by)s first.":["El campo %(field)s no se puede quitar, porque %(by)s lo lee. Cambia antes %(by)s."],"The field %(field)s cannot hold the value it was given. Check what kind of field it is and enter a value that matches.":["El campo %(field)s no puede guardar el valor que se le ha dado. Comprueba de qué tipo de campo se trata e introduce un valor que encaje."],"The field %(field)s has to hold a value before this item can be published. Fill it in, or leave the item as a draft.":["El campo %(field)s tiene que tener un valor antes de poder publicar este elemento. Rellénalo, o deja el elemento como borrador."],"The field %(field)s in %(group)s still points at this content type. Delete that field first, then delete this type.":["El campo %(field)s de %(group)s todavía apunta a este tipo de contenido. Borra antes ese campo y luego borra este tipo."],"The field %(field)s is hidden by its own rules right now, so it cannot hold a value. Show it again before filling it in.":["El campo %(field)s está oculto por sus propias reglas ahora mismo, así que no puede guardar ningún valor. Vuelve a mostrarlo antes de rellenarlo."],"The field %(field)s points at one item only. Remove the extra choices and keep one.":["El campo %(field)s apunta a un solo elemento. Quita las opciones de más y deja una."],"The field %(field)s points at other items, so it does not take a plain value. Pick the items it points at instead.":["El campo %(field)s apunta a otros elementos, así que no admite un valor suelto. Elige en su lugar los elementos a los que apunta."],"The field %(field)s takes a list of items, and what arrived was not a list. Reload the page and pick the items again.":["El campo %(field)s admite una lista de elementos, y lo que ha llegado no era una lista. Recarga la página y vuelve a elegir los elementos."],"The field %(source)s cannot hold the value %(value)s, so no rule can compare it. Write a value of the kind %(source)s stores.":["El campo %(source)s no puede guardar el valor %(value)s, así que ninguna regla puede compararlo. Escribe un valor de la clase que %(source)s guarda."],"The field groups could not be loaded.":["No se han podido cargar los grupos de campos."],"The field groups could not be reached.":["No se ha podido contactar con los grupos de campos."],"The field keeps its values on the content both groups reach.":["El campo conserva sus valores en el contenido al que llegan ambos grupos."],"The fields this group holds go with it, and so do the values every item stored in them.":["Los campos que contiene este grupo se van con él, y también los valores que cada elemento guardó en ellos."],"The groups were reordered.":["Los grupos se han reordenado."],"The item could not be duplicated.":["No se ha podido duplicar el elemento."],"The item could not be moved to the trash.":["No se ha podido mover el elemento a la papelera."],"The item could not be permanently deleted.":["No se ha podido borrar permanentemente el elemento."],"The item could not be restored.":["No se ha podido restaurar el elemento."],"The item you picked as parent holds another kind of content. Pick a parent of the same content type as this item.":["El elemento que has elegido como superior tiene otro tipo de contenido. Elige un elemento superior del mismo tipo de contenido que este elemento."],"The item you picked as parent is in the trash. Restore it, or pick another parent.":["El elemento que has elegido como superior está en la papelera. Restáuralo, o elige otro elemento superior."],"The item you picked as parent no longer exists. Reload the page and pick another parent.":["El elemento que has elegido como superior ya no existe. Recarga la página y elige otro."],"The media library could not be loaded.":["No se ha podido cargar la biblioteca de medios."],"The name changes. Nothing stored under this field moves.":["El nombre cambia. Nada de lo guardado en este campo se mueve."],"The name could not be updated.":["No se ha podido actualizar el nombre."],"The new order has to name every field group exactly once. Reload the page and move the groups again.":["El nuevo orden tiene que nombrar cada grupo de campos exactamente una vez. Recarga la página y mueve los grupos otra vez."],"The new order has to name every field of this content type exactly once. Reload the page and move the fields again.":["El nuevo orden tiene que nombrar cada campo de este tipo de contenido exactamente una vez. Recarga la página y mueve los campos de nuevo."],"The number of revisions to keep has to be a whole number, and it cannot be negative. Enter a different number.":["El número de revisiones que se conservan tiene que ser un número entero, y no puede ser negativo. Introduce otro número."],"The registry could not be reached.":["No se ha podido acceder al registro."],"The role could not be changed.":["No se ha podido cambiar el rol."],"The rule on %(source)s names nothing to match. Pick a value, or remove the rule.":["La regla sobre %(source)s no nombra nada con lo que coincidir. Elige un valor, o quita la regla."],"The rules could not be loaded.":["No se han podido cargar las reglas."],"The rules on %(field)s lead back to %(field)s itself, so nothing can decide whether to show it. Point one of them at another field.":["Las reglas de %(field)s vuelven al propio %(field)s, así que nada puede decidir si mostrarlo. Apunta una de ellas a otro campo."],"The same item was picked twice for %(field)s. Pick each item once.":["El mismo elemento se ha elegido dos veces para %(field)s. Elige cada elemento una sola vez."],"The server could not be reached, so nothing was changed.":["No se ha podido acceder al servidor, así que no se ha cambiado nada."],"The server could not be reached, so nothing was saved.":["No se ha podido acceder al servidor, así que no se ha guardado nada."],"The server could not be reached, so nothing was uploaded.":["No se ha podido acceder al servidor, así que no se ha subido nada."],"The setting %(setting)s cannot hold the value it was given. Check what this setting takes and try again.":["El ajuste %(setting)s no puede contener el valor que se le dio. Comprueba qué acepta este ajuste y prueba de nuevo."],"The setting %(setting)s falls outside the others. Keep min below max and the default inside the bounds.":["El ajuste %(setting)s queda fuera de los demás. Mantén min por debajo de max y el valor por defecto dentro de los límites."],"The settings could not be read, so nothing can be changed here yet.":["No se han podido leer los ajustes, así que todavía no se puede cambiar nada aquí."],"The site default":["El idioma por defecto del sitio"],"The site may already hold part of this file. Import it again to see what is left.":["Puede que el sitio ya tenga parte de este archivo. Impórtalo de nuevo para ver lo que queda."],"The source %(source)s does not offer %(operator)s. Pick one of the comparisons it does offer.":["La fuente %(source)s no ofrece %(operator)s. Elige una de las comparaciones que sí ofrece."],"The theme %(name)s carries no %(file)s, so Gophenberg cannot tell what it is. Put that file at the top level of the zip and package the theme again.":["El tema %(name)s no lleva ningún %(file)s, así que Gophenberg no puede saber qué es. Pon ese archivo en el primer nivel del zip y vuelve a empaquetar el tema."],"The theme %(name)s could not be read, so it cannot serve your site. Upload it again, or choose another theme.":["El tema %(name)s no se ha podido leer, así que no puede servir tu sitio. Vuelve a subirlo, o elige otro tema."],"The theme %(name)s did not start, so your site is still served by the theme it had. Ask whoever looks after your site to check the server log for what the theme reported.":["El tema %(name)s no ha arrancado, así que tu sitio lo sigue sirviendo el tema que tenía. Pide a quien gestiona tu sitio que mire en el registro del servidor para saber qué problema ha informado el tema."],"The theme %(name)s does not carry %(path)s as a file, so there is nothing to run. Package the built theme rather than its source.":["El tema %(name)s no lleva %(path)s como archivo, así que no hay nada que ejecutar. Empaqueta el tema ya construido, no su código fuente."],"The theme %(name)s does not carry %(path)s as a folder, so its styles and scripts are missing. Package the built theme rather than its source.":["El tema %(name)s no lleva %(path)s como carpeta, así que le faltan los estilos y los scripts. Empaqueta el tema ya construido, no su código fuente."],"The theme %(name)s holds a link to another file at %(path)s, and a theme may only carry real files. Replace it and package the theme again.":["El tema %(name)s lleva un enlace a otro archivo en %(path)s, y un tema solo puede llevar archivos de verdad. Sustitúyelo y vuelve a empaquetar el tema."],"The theme %(name)s is serving your site right now, so it cannot be replaced. Deactivate it first, or install this copy under another name.":["El tema %(name)s sirve tu sitio ahora mismo, así que no se puede sustituir. Desactívalo primero, o instala esta copia con otro nombre."],"The theme %(name)s is the one chosen, but it is not installed, so your site is served by the built-in renderer. Upload the theme again, or choose another one.":["El tema %(name)s es el elegido, pero no está instalado, así que el renderizador integrado está prestando servicio a tu sitio. Vuelve a subir el tema, o elige otro."],"The theme %(name)s was built on theme kit %(declared)s, and this site serves %(served)s. Build the theme again against a kit this site serves, then install it.":["El tema %(name)s se compiló con el kit de temas %(declared)s y este sitio sirve %(served)s. Compila el tema de nuevo con un kit que este sitio sirva y después instálalo."],"The trash could not be emptied.":["No se ha podido vaciar la papelera."],"The user could not be created.":["No se ha podido crear el usuario."],"The values stored under it do not follow.":["Los valores guardados debajo no le siguen."],"Theme":["Tema"],"Themes":["Temas"],"Themes could not be loaded.":["No se han podido cargar los temas."],"There is no content type called %(value)s. Pick a type your site holds, or register it first.":["No existe ningún tipo de contenido llamado %(value)s. Elige un tipo que tenga tu sitio, o regístralo primero."],"There is no content type called %(value)s. Pick one of the types your site holds and try again.":["No existe ningún tipo de contenido llamado %(value)s. Elige uno de los tipos que tiene tu sitio e inténtalo de nuevo."],"There is no kind of field called %(value)s. Pick one of these instead: %(allowed)s.":["No existe ningún tipo de campo llamado %(value)s. Elige uno de estos: %(allowed)s."],"There is no page kind called %(value)s. Pick one of these instead: %(allowed)s.":["No existe ningún tipo de página llamado %(value)s. Elige uno de estos: %(allowed)s."],"There is no status called %(value)s. Pick one of these instead: %(allowed)s.":["No existe ningún estado llamado %(value)s. Elige uno de estos: %(allowed)s."],"There is nothing at this address. Check the link and try again.":["No hay nada en esta dirección. Comprueba el enlace e inténtalo de nuevo."],"There is nothing to roll back to, because your site has not been through a theme change yet. Install a theme first, then a rollback has somewhere to go.":["No hay nada a lo que retornar, porque tu sitio todavía no ha cambiado de tema. Instala un tema primero y así el retorno tendrá adónde ir."],"These files could not be read.":["No se han podido leer estos archivos."],"These were left alone, because nobody confirmed losing them.":["Estos se han dejado como estaban, porque nadie confirmó perderlos."],"This content type does not exist any more. Reload the page to see the types your site holds now.":["Este tipo de contenido ya no existe. Recarga la página para ver los tipos que tiene ahora tu sitio."],"This content type does not nest, so one of its items cannot sit inside another. Mark the type as Nests first.":["Este tipo de contenido no anida, así que uno de sus elementos no puede estar dentro de otro. Marca el tipo como Anida primero."],"This content type has no field called %(field)s. Check the name, or declare the field on the type first.":["Este tipo de contenido no tiene ningún campo llamado %(field)s. Comprueba el nombre, o declara antes el campo en el tipo."],"This content type is turned off, so its content is not served or edited. Activate it first.":["Este tipo de contenido está desactivado, así que su contenido ni se sirve ni se edita. Actívalo primero."],"This content type still holds content, so it cannot be deleted. Move or delete its items first.":["Este tipo de contenido todavía tiene contenido, así que no se puede borrar. Mueve o borra antes sus elementos."],"This definitions file is larger than the %(max)s bytes Gophenberg reads in one request. Split it, or raise the limit your site runs with.":["Este archivo de definiciones es mayor que los %(max)s bytes que Gophenberg lee en una petición. Divídelo, o sube el límite con el que funciona tu sitio."],"This field holds no layouts yet.":["Este campo todavía no tiene diseños."],"This field would stand inside too many containers. Put it nearer the top and try again.":["Este campo quedaría dentro de demasiados contenedores. Ponlo más cerca del nivel superior e inténtalo de nuevo."],"This file could not be opened as a zip. Package the theme again and upload the new file.":["Este archivo no se ha podido abrir como zip. Vuelve a empaquetar el tema y sube el archivo nuevo."],"This file is larger than the upload limit. Upload a smaller file.":["Este archivo es más grande que el límite de subida. Sube un archivo más pequeño."],"This file is named %(extension)s but it holds %(detected)s content. Upload it under a name that matches what is inside it.":["Este archivo se llama %(extension)s pero lleva contenido %(detected)s. Súbelo con un nombre que coincida con lo que lleva dentro."],"This file matches the site, so there is nothing to change.":["Este archivo coincide con el sitio, así que no hay nada que cambiar."],"This file says it was written in format %(declared)s, which is not a version Gophenberg can read. Export the definitions again from the site they came from.":["Este archivo dice estar escrito en el formato %(declared)s, que no es una versión que Gophenberg pueda leer. Vuelve a exportar las definiciones desde el sitio del que vinieron."],"This file was written in format %(declared)s, and this release reads %(served)s. Upgrade the site the file came from, or this one.":["Este archivo está escrito en el formato %(declared)s, y esta versión lee %(served)s. Actualiza el sitio del que viene el archivo, o este."],"This group holds no fields yet.":["Este grupo todavía no tiene campos."],"This install of Gophenberg has no install root, so it updates by hand.":["Esta instalación de Gophenberg no tiene raíz de instalación, así que se actualiza a mano."],"This is a custom build of Gophenberg, so it updates by hand.":["Esta es una compilación propia de Gophenberg, así que se actualiza a mano."],"This item cannot go straight to that status from the one it holds now. Save it as a draft first, then try again.":["Este elemento no puede pasar directamente a ese estado desde el que tiene ahora. Guárdalo antes como borrador e inténtalo de nuevo."],"This item does not exist, and it may have been deleted. Go back to the list to see what is there.":["Este elemento no existe, y puede que se haya borrado. Vuelve a la lista para ver qué hay."],"This item is already in the trash. Restore it first to work on it again.":["Este elemento ya está en la papelera. Restáuralo primero para volver a trabajar en él."],"This item is in the trash. Restore it first to work on it again.":["Este elemento está en la papelera. Restáuralo primero para volver a trabajar en él."],"This item is not in the trash, so there is nothing to restore. Open it from the list to work on it as it is.":["Este elemento no está en la papelera, así que no hay nada que restaurar. Ábrelo desde la lista para trabajar en él tal como está."],"This item still holds items nested inside it. Move or delete those first.":["Este elemento todavía tiene elementos anidados dentro. Mueve o borra antes esos."],"This kind of field holds no fields inside it. Put the field in a section or a repeater instead.":["Este tipo de campo no contiene campos dentro. Pon el campo en una sección o en un repetidor."],"This media item is not in the library, and someone may have deleted it. Reload the page to see what is there now.":["Este medio no está en la biblioteca, y puede que alguien lo haya borrado. Recarga la página para ver qué hay ahora."],"This picture could not be read, so nothing was stored. The file may be damaged, so open it on your computer and upload it again.":["Esta imagen no se ha podido leer, así que no se ha guardado nada. Puede que el archivo esté dañado, así que ábrelo en tu ordenador y vuelve a subirlo."],"This picture is %(width)s by %(height)s pixels, over the limit of %(max)s pixels in all. Make it smaller and upload it again.":["Esta imagen tiene %(width)s por %(height)s píxeles, por encima del límite de %(max)s píxeles en total. Hazla más pequeña y vuelve a subirla."],"This post changed elsewhere. Reload before saving again.":["Esta entrada ha cambiado en otro sitio. Recarga antes de volver a guardar."],"This request came from a page on another site, so it was refused. Open the admin on this site and try again.":["Esta petición vino de una página de otro sitio, así que se rechazó. Abre la administración en este sitio e inténtalo de nuevo."],"This request carried %(attribute)s, which fields do not take. Reload the page so the admin and the server agree again.":["Esta petición llevaba %(attribute)s, que los campos no aceptan. Recarga la página para que la administración y el servidor vuelvan a coincidir."],"This request left out %(field)s, which Gophenberg needs. Reload the page and try again.":["En esta petición faltaba %(field)s, que Gophenberg necesita. Recarga la página e inténtalo de nuevo."],"This rule reads a source called %(source)s, and nothing on your site declares one. A plugin that added it may have been removed.":["Esta regla lee una fuente llamada %(source)s, y nada en tu sitio declara ninguna. Puede que se haya quitado un plugin que la añadía."],"This site cannot write to its themes folder, so no theme can be installed. Ask whoever looks after your site to make the folder that %(setting)s points at writable. A read-only mount is the usual cause.":["Este sitio no puede escribir en su carpeta de temas, así que no se puede instalar ninguno. Pide a quien gestione tu sitio que haga escribible la carpeta a la que apunta %(setting)s. La causa habitual es un montaje de solo lectura."],"This site has nowhere to keep themes, so no theme can be installed. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Este sitio no tiene dónde guardar los temas, así que no se puede instalar ninguno. Pide a quien gestione tu sitio que apunte %(setting)s a una carpeta en la que el servidor pueda escribir."],"This site has nowhere to store uploads, so nothing can be uploaded. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Este sitio no tiene dónde guardar las subidas, así que no se puede subir nada. Pide a quien gestione tu sitio que apunte %(setting)s a una carpeta en la que el servidor pueda escribir."],"This theme is over the limit of %(max)s bytes, packed or unpacked. Leave out what the theme does not need and package it again.":["Este tema pasa del límite de %(max)s bytes, comprimido o sin comprimir. Saca lo que el tema no necesite y vuelve a empaquetarlo."],"This type will answer under /%(word)s.":["Este tipo responderá como /%(word)s."],"This zip holds %(entries)s files, more than the %(max)s a theme may carry. Package only the built theme.":["Este zip lleva %(entries)s archivos, más de los %(max)s que puede llevar un tema. Empaqueta solo el tema ya construido."],"Through":["A través de"],"Thumbnail":["Miniatura"],"Title":["Título"],"Two field groups in this file share the key %(key)s. Give one of them a different key and import again.":["Dos grupos de campos de este archivo comparten la clave %(key)s. Dale una clave distinta a uno de ellos e importa de nuevo."],"Two rule sources are registered under the name %(param)s. A plugin is declaring a source your site already holds.":["Hay dos fuentes de reglas registradas con el nombre %(param)s. Un plugin declara una fuente que tu sitio ya tiene."],"Type":["Tipo"],"Type a whole number in both boxes before saving.":["Escribe un número entero en las dos casillas antes de guardar."],"Undo":["Deshacer"],"Unsaved changes":["Cambios sin guardar"],"Update":["Actualizar"],"Update failed.":["No se ha podido actualizar."],"Upload theme":["Subir tema"],"Uploaded %s file.":["%s archivo subido.","%s de archivos subidos.","%s archivos subidos."],"Users":["Usuarios"],"Users could not be loaded.":["No se han podido cargar los usuarios."],"Value":["Valor"],"Version":["Versión"],"Videos":["Vídeos"],"Visit Site":["Visitar el sitio"],"Web address":["Dirección web"],"What each Linked from field reads":["Qué lee cada campo Enlazado desde"],"What it points at changed, so its stored values go with it.":["Aquello a lo que apunta ha cambiado, así que sus valores guardados se van con él."],"Where %(group)s appears":["Dónde aparece %(group)s"],"Without a rule this field always shows.":["Sin ninguna regla este campo siempre se muestra."],"Without a rule this group appears nowhere.":["Sin una regla este grupo no aparece en ningún sitio."],"Yes":["Sí"],"Yes or no":["Sí o no"],"You cannot change your own role.":["No puedes cambiar tu propio rol."],"Your language":["Tu idioma"],"Your role does not allow %(capability)s.":["Tu rol no permite %(capability)s."],"Your server pins the site to the theme %(name)s, so themes cannot be changed here. Ask whoever looks after your site to remove the pin.":["Tu servidor bloque el sitio al tema %(name)s, así que los temas no se pueden cambiar desde aquí. Pide a quien gestiona tu sitio que quite ese bloqueo."],"Your site always needs one default content type, so this one cannot be turned off or deleted. Make another type the default first.":["Tu sitio siempre necesita un tipo de contenido por defecto, así que este no se puede desactivar ni borrar. Haz antes que otro tipo sea el que está por defecto."],"a content type":["un tipo de contenido"],"account\u0004Role":["Rol"],"account\u0004Status":["Estado"],"action label\u0004Duplicate":["Duplicar"],"action label\u0004Duplicate…":["Duplicar…"],"address":["dirección"],"admin section\u0004Content Types":["Tipos de contenido"],"admin section\u0004Field Groups":["Grupos de campos"],"admin section\u0004Language":["Idioma"],"admin section\u0004Media":["Medios"],"admin section\u0004Settings":["Ajustes"],"admin section\u0004Themes":["Temas"],"column\u0004Date":["Fecha"],"content type\u0004\"%s\" permanently deleted.":["Se ha borrado permanentemente el tipo de contenido «%s»."],"content type\u0004\"%s\" successfully created.":["Se ha creado correctamente el tipo de contenido «%s»."],"content type\u0004Status":["Estado"],"field\u0004Name":["Nombre"],"field group\u0004Status":["Estado"],"field type\u0004Choice":["Elección"],"field type\u0004Color":["Color"],"field type\u0004Date":["Fecha"],"field type\u0004Flexible content":["Contenido flexible"],"field type\u0004Gallery":["Galería"],"field type\u0004Kind":["Tipo"],"field type\u0004Layout":["Diseño"],"field type\u0004Link":["Enlace"],"field type\u0004Media":["Medio"],"field type\u0004Range":["Rango"],"field type\u0004Repeater":["Repetidor"],"field type\u0004Section":["Sección"],"field type\u0004Select":["Desplegable"],"file size\u0004%1$s %2$s":["%1$s %2$s"],"file type group\u0004Archives":["Archivos"],"file type group\u0004Audio":["Audio"],"file type group\u0004Video":["Vídeo"],"holds":["contiene"],"holds anything":["contiene algo"],"https://www.w3.org/WAI/tutorials/images/decision-tree/":["https://www.w3.org/WAI/tutorials/images/decision-tree/"],"image dimensions\u0004%1$s × %2$s":["%1$s × %2$s"],"is":["es"],"is above":["es mayor que"],"is below":["es menor que"],"is empty":["está vacío"],"is not":["no es"],"panel button label\u0004Settings":["Ajustes"],"person\u0004Name":["Nombre"],"post\u0004%s (Copy)":["%s (copia)"],"post\u0004Status":["Estado"],"post status\u0004Draft":["Borrador"],"post status\u0004Published":["Publicada"],"post status\u0004Trash":["Papelera"],"post type general name\u0004Pages":["Páginas"],"post type general name\u0004Posts":["Entradas"],"post type singular name\u0004Page":["Página"],"post type singular name\u0004Post":["Entrada"],"relation target\u0004None":["Ninguno"],"role\u0004Administrator":["Administrador"],"role\u0004Author":["Autor"],"role\u0004Editor":["Editor"],"role\u0004Member":["Miembro"],"role\u0004No role":["Sin rol"],"status filter\u0004All":["Todo"],"taxonomy general name\u0004Categories":["Categorías"],"taxonomy singular name\u0004Category":["Categoría"],"theme\u0004Status":["Estado"],"trash\u0004Restore":["Restaurar"],"unit symbol\u0004B":["B"],"unit symbol\u0004EB":["EB"],"unit symbol\u0004GB":["GB"],"unit symbol\u0004KB":["KB"],"unit symbol\u0004MB":["MB"],"unit symbol\u0004PB":["PB"],"unit symbol\u0004TB":["TB"],"unit symbol\u0004YB":["YB"],"unit symbol\u0004ZB":["ZB"],"v%(version)s":["v%(version)s"],"verb\u0004Trash…":["Papelera…"],"verb\u0004View":["Ver"],"{day} {month} {year}":["{day} de {month} de {year}"]} diff --git a/internal/i18n/catalogs/fr-FR.json b/internal/i18n/catalogs/fr-FR.json index b2c4a848..89c8e8dd 100644 --- a/internal/i18n/catalogs/fr-FR.json +++ b/internal/i18n/catalogs/fr-FR.json @@ -1 +1 @@ -{"":{"lang":"fr","plural-forms":"nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;"}," and ":[" et "]," or ":[" ou "],"\"%s\" activated.":["Type de contenu « %s » activé."],"\"%s\" deactivated.":["Type de contenu « %s » désactivé."],"\"%s\" has been restored.":["« %s » a été rétabli."],"\"%s\" moved to the trash.":["« %s » déplacé dans la corbeille."],"\"%s\" permanently deleted.":["« %s » supprimé définitivement."],"\"%s\" successfully created.":["« %s » créé avec succès."],"\"%s\" uploaded.":["« %s » téléversé."],"%(field)s address":["Adresse de %(field)s"],"%(field)s cannot move inside itself. Pick a container that is not one of its own.":["%(field)s ne peut pas être déplacé à l'intérieur de lui-même. Choisissez un conteneur qui n'est pas l'un des siens."],"%(field)s declared.":["%(field)s déclaré."],"%(field)s deleted.":["%(field)s supprimé."],"%(field)s does not match the format its field asks for. Check it and save again.":["%(field)s ne respecte pas le format demandé par son champ. Vérifiez-le et enregistrez de nouveau."],"%(field)s goes no higher than %(limit)s. Lower the value and save again.":["%(field)s ne dépasse pas %(limit)s. Baissez la valeur et enregistrez de nouveau."],"%(field)s goes no lower than %(limit)s. Raise the value and save again.":["%(field)s ne descend pas sous %(limit)s. Augmentez la valeur et enregistrez de nouveau."],"%(field)s holds no value of its own, so it cannot be required. Make it optional.":["%(field)s ne contient aucune valeur propre, il ne peut donc pas être obligatoire. Rendez-le facultatif."],"%(field)s is optional again.":["%(field)s est de nouveau facultatif."],"%(field)s is required now.":["%(field)s est maintenant obligatoire."],"%(field)s moved.":["%(field)s déplacé."],"%(field)s names the same one twice. Remove the repeat and save again.":["%(field)s nomme le même deux fois. Retirez la répétition et enregistrez de nouveau."],"%(field)s needs at least %(limit)s files. Add one and save again.":["%(field)s demande au moins %(limit)s fichiers. Ajoutez-en un et enregistrez."],"%(field)s needs at least %(limit)s rows. Add one and save again.":["%(field)s demande au moins %(limit)s lignes. Ajoutez-en une et enregistrez."],"%(field)s only takes one of its listed choices. Pick one and save again.":["%(field)s accepte uniquement un des choix de sa liste. Choisissez-en un et enregistrez de nouveau."],"%(field)s reads a relation field that is not there. Pick a group and a relation field it holds.":["%(field)s lit un champ de relation qui n'existe pas. Choisissez un groupe et un champ de relation qu'il contient."],"%(field)s reads a relation pointing at %(type)s, so it would list nothing here. Pick a relation pointing at the items this group is shown on.":["%(field)s lit une relation qui pointe vers %(type)s, elle n'afficherait donc rien ici. Choisissez une relation qui pointe vers les éléments où ce groupe s'affiche."],"%(field)s renamed.":["%(field)s renommé."],"%(field)s settled.":["%(field)s réglé."],"%(field)s shows by its rules now.":["%(field)s s'affiche désormais selon ses règles."],"%(field)s stands in the list.":["%(field)s figure dans la liste."],"%(field)s stays out of the list.":["%(field)s reste hors de la liste."],"%(field)s takes at most %(limit)s files. Remove one and save again.":["%(field)s accepte au plus %(limit)s fichiers. Retirez-en un et enregistrez."],"%(field)s takes at most %(limit)s letters. Shorten what you wrote and save again.":["%(field)s accepte au plus %(limit)s lettres. Raccourcissez ce que vous avez écrit et enregistrez de nouveau."],"%(field)s takes at most %(limit)s rows. Remove one and save again.":["%(field)s accepte au plus %(limit)s lignes. Retirez-en une et enregistrez."],"%(field)s title":["Titre de %(field)s"],"%(file)s is larger than one upload may be.":["%(file)s dépasse la taille maximale d'un téléversement."],"%(file)s is not a file this block accepts.":["%(file)s n'est pas un fichier que ce bloc accepte."],"%(file)s: %(reason)s":["%(file)s : %(reason)s"],"%(group)s created.":["%(group)s créé."],"%(group)s now appears where you said.":["%(group)s apparaît maintenant là où vous avez dit."],"%(group)s removed.":["%(group)s supprimé."],"%(group)s updated.":["%(group)s mis à jour."],"%(key)s is served by %(winner)s, so %(loser)s does not show it on %(where)s.":["%(key)s est servi par %(winner)s, donc %(loser)s ne le montre pas sur %(where)s."],"%(name)s %(version)s is serving the public site.":["%(name)s %(version)s sert le site public."],"%(name)s belongs to the site now.":["%(name)s appartient désormais au site."],"%(name)s came from the %(plugin)s plugin, which no longer declares it.":["%(name)s venait de l'extension %(plugin)s, qui ne le déclare plus."],"%(name)s cannot be used as a theme name. Name the zip file after the theme, with no dot at the start and no slashes in it.":["%(name)s ne peut pas servir de nom de thème. Nommez le fichier zip comme le thème, sans point au début et sans barre oblique."],"%(name)s moves to /%(word)s. Every address of both types changes.":["Le type %(name)s passe à /%(word)s. Toutes les adresses des deux types changent."],"%(name)s would move to /%(word)s, which Gophenberg keeps for itself. Change its plural name and try again.":["Le type %(name)s passerait à /%(word)s, que Gophenberg garde pour lui. Modifiez son nom au pluriel et réessayez."],"%(source)s is %(value)s":["%(source)s est %(value)s"],"%(theme)s failed to start, so the built-in renderer is serving.":["%(theme)s n'a pas pu démarrer, le moteur de rendu intégré sert donc le site."],"%(theme)s is now serving the public site.":["%(theme)s sert désormais le site public."],"%(theme)s is serving the public site.":["%(theme)s sert le site public."],"%(theme)s is still starting, so the built-in renderer is serving.":["%(theme)s démarre encore, le moteur de rendu intégré sert donc le site."],"%(theme)s was installed.":["%(theme)s a été installé."],"%(theme)s will not load, so the built-in renderer is serving.":["%(theme)s ne se charge pas, le moteur de rendu intégré sert donc le site."],"%(type)s answers on a new address, and stored links move with it.":["%(type)s répond à une nouvelle adresse, et les liens enregistrés la suivent."],"%(type)s becomes the type the site opens on, and the old one takes an address.":["%(type)s devient le type sur lequel le site ouvre, et l'ancien prend une adresse."],"%(type)s keeps nesting while %(items)s of its items sit inside another. Move those to the top level first, the ones in the trash included.":["%(type)s continue de s'imbriquer tant que %(items)s de ses éléments se trouvent dans un autre. Déplacez-les d'abord au niveau supérieur, ceux de la corbeille compris."],"%(type)s keeps nesting, because some of its items sit inside another.":["%(type)s continue de s'imbriquer, car certains de ses éléments se trouvent dans un autre."],"%(type)s will answer at the root.":["Le type %(type)s répondra à la racine."],"%s content type activated.":["%s type de contenu activé.","%s types de contenu activés.","%s types de contenu activés."],"%s content type could not be activated.":["%s type de contenu n'a pas pu être activé.","%s types de contenu n'ont pas pu être activés.","%s types de contenu n'ont pas pu être activés."],"%s content type could not be deactivated.":["%s type de contenu n'a pas pu être désactivé.","%s types de contenu n'ont pas pu être désactivés.","%s types de contenu n'ont pas pu être désactivés."],"%s content type could not be permanently deleted.":["%s type de contenu n'a pas pu être supprimé définitivement.","%s types de contenu n'ont pas pu être supprimés définitivement.","%s types de contenu n'ont pas pu être supprimés définitivement."],"%s content type deactivated.":["%s type de contenu désactivé.","%s types de contenu désactivés.","%s types de contenu désactivés."],"%s content type permanently deleted.":["%s type de contenu supprimé définitivement.","%s types de contenu supprimés définitivement.","%s types de contenu supprimés définitivement."],"%s item could not be moved to the trash.":["%s élément n'a pas pu être déplacé dans la corbeille.","%s éléments n'ont pas pu être déplacés dans la corbeille.","%s éléments n'ont pas pu être déplacés dans la corbeille."],"%s item could not be permanently deleted.":["%s élément n'a pas pu être supprimé définitivement.","%s éléments n'ont pas pu être supprimés définitivement.","%s éléments n'ont pas pu être supprimés définitivement."],"%s item could not be restored.":["%s élément n'a pas pu être rétabli.","%s éléments n'ont pas pu être rétablis.","%s éléments n'ont pas pu être rétablis."],"%s item moved to the trash.":["%s élément déplacé dans la corbeille.","%s éléments déplacés dans la corbeille.","%s éléments déplacés dans la corbeille."],"%s item permanently deleted.":["%s élément supprimé définitivement.","%s éléments supprimés définitivement.","%s éléments supprimés définitivement."],"%s item stays in the trash.":["%s élément reste dans la corbeille.","%s éléments restent dans la corbeille.","%s éléments restent dans la corbeille."],"%s page has been restored.":["%s page a été rétablie.","%s pages ont été rétablies.","%s pages ont été rétablies."],"%s post has been restored.":["%s article a été rétabli.","%s articles ont été rétablis.","%s articles ont été rétablis."],"(no title)":["(sans titre)"],"A %(kind)s field does not take a setting called %(setting)s. Remove it and try again.":["Un champ %(kind)s n'accepte pas de réglage nommé %(setting)s. Retirez-le et réessayez."],"A Linked from field stands on its own, not inside a section or a repeater. Declare %(field)s beside the container.":["Un champ Lié depuis se place seul, ni dans une section ni dans un répéteur. Déclarez %(field)s à côté du conteneur."],"A content type needs a singular and a plural name, like Guide and Guides. Fill both in and try again.":["Un type de contenu a besoin d'un nom au singulier et d'un nom au pluriel, comme Guide et Guides. Remplissez les deux et réessayez."],"A descriptive summary of the content type.":["Un résumé descriptif du type de contenu."],"A field group key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the group in the file so its key comes out in that shape.":["La clé d'un groupe de champs doit commencer par une lettre minuscule puis ne contenir que des lettres minuscules, des chiffres et des tirets. Renommez le groupe dans le fichier pour que sa clé prenne cette forme."],"A field key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the field so its key comes out in that shape.":["La clé d'un champ doit commencer par une lettre minuscule et ne contenir ensuite que des lettres minuscules, des chiffres et des tirets. Renommez le champ pour que sa clé prenne cette forme."],"A field needs a name. Type one and try again.":["Un champ a besoin d'un nom. Saisissez-en un et réessayez."],"A field with that name already exists on the items this group reaches. Pick another name.":["Un champ portant ce nom existe déjà sur les éléments que ce groupe atteint. Choisissez un autre nom."],"A flexible content field holds layouts, and %(field)s is not one. Add a layout first, then declare the field inside that layout.":["Un champ de contenu flexible contient des dispositions, et %(field)s n'en est pas une. Ajoutez d'abord une disposition, puis déclarez le champ dans cette disposition."],"A higher quality stores larger files, and it applies to pictures uploaded from now on.":["Une qualité plus élevée stocke des fichiers plus grands, et elle s'applique aux images téléversées à partir de maintenant."],"A layout stands inside a flexible content field only. Put %(field)s inside one of those instead.":["Une disposition ne se place que dans un champ de contenu flexible. Placez %(field)s dans l'un d'eux."],"A layout stands inside a flexible content field, never on its own. Declare a flexible content field first, then add %(field)s inside it.":["Une disposition se place dans un champ de contenu flexible, jamais seule. Déclarez d'abord un champ de contenu flexible, puis ajoutez %(field)s dedans."],"A listing carries between 1 and %(max)s items, so %(value)s is not a size Gophenberg takes. Pick a number inside that range.":["Une liste affiche entre 1 et %(max)s éléments, donc %(value)s n'est pas une taille que Gophenberg accepte. Choisissez un nombre dans cet intervalle."],"A relation field has to say which content type it points at. Pick that type and try again.":["Un champ de relation doit indiquer le type de contenu qu'il vise. Choisissez ce type et réessayez."],"A reload shows the admin in the language you chose.":["Un rechargement affiche l'administration dans la langue que vous avez choisie."],"A route word has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change it and try again.":["Un mot de route doit commencer par une lettre minuscule et ne contenir ensuite que des lettres minuscules, des chiffres et des tirets. Modifiez-le et réessayez."],"A row of %(field)s has to name the layout it takes. Pick a layout for the row, or remove the row.":["Une ligne de %(field)s doit nommer la disposition qu'elle prend. Choisissez une disposition pour la ligne, ou retirez la ligne."],"A row of %(field)s takes one layout and no more. Reload the page and build the row again.":["Une ligne de %(field)s prend une seule disposition. Rechargez la page et reconstruisez la ligne."],"A rule matching any %(source)s cannot be turned into an exclusion. Leave it as is, or name the ones to leave out instead.":["Une règle valable pour n'importe quel %(source)s ne peut pas devenir une exclusion. Laissez-la telle quelle, ou nommez ceux à écarter."],"A theme naming its own page size uses that instead.":["Un thème qui indique sa propre taille de page utilise celle-ci à la place."],"A type key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change the key and try again.":["La clé d'un type doit commencer par une lettre minuscule et ne contenir ensuite que des lettres minuscules, des chiffres et des tirets. Modifiez la clé et réessayez."],"Actions":["Actions"],"Activate":["Activer"],"Activate %(theme)s":["Activer %(theme)s"],"Active":["Actif"],"Add":["Ajouter"],"Add %(layout)s":["Ajouter %(layout)s"],"Add %(name)s":["Ajouter %(name)s"],"Add %(type)s":["Ajouter %(type)s"],"Add Category":["Ajouter une catégorie"],"Add Content Type":["Ajouter un type de contenu"],"Add Field Group":["Ajouter un groupe de champs"],"Add Media File":["Ajouter un fichier média"],"Add Page":["Ajouter une page"],"Add Post":["Ajouter un article"],"Add User":["Ajouter un utilisateur"],"Add block":["Ajouter un bloc"],"Add choice":["Ajouter un choix"],"Add condition":["Ajouter une condition"],"Add field":["Ajouter un champ"],"Add field to %(field)s":["Ajouter un champ à %(field)s"],"Add layout":["Ajouter une disposition"],"Add layout to %(field)s":["Ajouter une disposition à %(field)s"],"Add one with %(button)s.":["Ajoutez un élément avec %(button)s."],"Add row":["Ajouter une ligne"],"Add rule set":["Ajouter un ensemble de règles"],"Add title":["Ajouter un titre"],"Add to %(field)s":["Ajouter à %(field)s"],"Address":["Adresse"],"Address updated.":["Adresse mise à jour."],"Adopt":["Adopter"],"Allow custom":["Autoriser les valeurs libres"],"Allow empty":["Autoriser le vide"],"Alt text":["Texte alternatif"],"An item cannot point at itself. Pick a different item for %(field)s.":["Un élément ne peut pas se viser lui-même. Choisissez un autre élément pour %(field)s."],"An item cannot sit inside itself, or inside one of the items nested under it. Pick a parent outside this branch.":["Un élément ne peut pas se trouver dans lui-même, ni dans un élément imbriqué sous lui. Choisissez un parent hors de cette branche."],"Another account wrote this, and only its author, an editor or an administrator may change it. You can still read it.":["Un autre compte a écrit ceci, et seuls son auteur, un éditeur ou un administrateur peuvent le modifier. Vous pouvez toujours le lire."],"Another account wrote this, so you are reading it rather than editing it.":["Un autre compte a écrit ceci, alors vous le lisez au lieu de le modifier."],"Another content type already answers under this route word. Pick a different word.":["Un autre type de contenu répond déjà sous ce mot de route. Choisissez un autre mot."],"Another content type already lives at the root of your site, and only one can. Give this type a route word, or hand the root over with Make default.":["Un autre type de contenu occupe déjà la racine de votre site, et un seul le peut. Donnez un mot de route à ce type, ou cédez la racine avec Rendre par défaut."],"Another content type already uses this key. Give this type a different name.":["Un autre type de contenu utilise déjà cette clé. Donnez un autre nom à ce type."],"Another item of this content type already uses that address. Pick a different slug and save again.":["Un autre élément de ce type de contenu utilise déjà cette adresse. Choisissez un autre slug et enregistrez de nouveau."],"Another update is currently in progress.":["Une autre mise à jour est actuellement en cours."],"Appears on":["Apparaît sur"],"Apply":["Appliquer"],"April":["avril"],"Are you sure you want to move \"%s\" to the trash?":["Voulez-vous vraiment déplacer « %s » dans la corbeille ?"],"Audio":["Audio"],"August":["août"],"Author":["Auteur"],"Back to %(type)s":["Retour aux %(type)s"],"Back to the latest posts":["Retour aux derniers articles"],"Block":["Bloc"],"Broken":["Cassé"],"Bundles of fields, shown where their rules place them.":["Ensembles de champs, affichés là où leurs règles les placent."],"Button group":["Groupe de boutons"],"Cancel":["Annuler"],"Caption":["Légende"],"Change %(name)s":["Modifier %(name)s"],"Change address":["Changer l'adresse"],"Change address…":["Changer l'adresse…"],"Change role":["Changer le rôle"],"Change the role of %(name)s":["Changer le rôle de %(name)s"],"Checkbox group":["Groupe de cases à cocher"],"Choices":["Choix"],"Choose %(field)s":["Choisir %(field)s"],"Choose how the site serves its content.":["Choisissez comment le site sert son contenu."],"Choose the language the admin reads in.":["Choisissez la langue dans laquelle se lit l'administration."],"Choose the role of %(name)s.":["Choisissez le rôle de %(name)s."],"Choose the version to update to.":["Choisissez la version vers laquelle mettre à jour."],"Clear %(field)s":["Vider %(field)s"],"Close":["Fermer"],"Close Settings":["Fermer les réglages"],"Closed":["Fermé"],"Condition":["Condition"],"Content":["Contenu"],"Content Types":["Types de contenu"],"Content nests at most %(max)s levels deep, and this item would sit deeper. Pick a parent closer to the top.":["Le contenu s'imbrique sur au plus %(max)s niveaux, et cet élément irait plus profond. Choisissez un parent plus proche du sommet."],"Content type":["Type de contenu"],"Content type updated.":["Type de contenu mis à jour."],"Could not delete that item.":["Impossible de supprimer cet élément.","Impossible de supprimer tous les éléments.","Impossible de supprimer tous les éléments."],"Could not load that post.":["Impossible de charger cet article."],"Could not save that post.":["Impossible d'enregistrer cet article."],"Create":["Créer"],"Create a field group":["Créer un groupe de champs"],"Create user":["Créer l'utilisateur"],"Created":["Date de création"],"Date added":["Date d'ajout"],"Deactivate":["Désactiver"],"Deactivate %(theme)s":["Désactiver %(theme)s"],"December":["décembre"],"Default":["Par défaut"],"Default content type updated.":["Type de contenu par défaut mis à jour."],"Definitions file":["Fichier de définitions"],"Delete":["Supprimer"],"Delete \"%(title)s\" for good? This cannot be undone.":["Supprimer « %(title)s » définitivement ? Cette action est irréversible."],"Delete %(field)s":["Supprimer %(field)s"],"Delete %(group)s":["Supprimer %(group)s"],"Delete %(title)s for good? This cannot be undone.":["Supprimer %(title)s définitivement ? Cette action est irréversible."],"Delete %s content type for good? This cannot be undone.":["Supprimer %s type de contenu définitivement ? Cette action est irréversible.","Supprimer %s types de contenu définitivement ? Cette action est irréversible.","Supprimer %s types de contenu définitivement ? Cette action est irréversible."],"Delete %s item for good? This cannot be undone.":["Supprimer %s élément définitivement ? Cette action est irréversible.","Supprimer %s éléments définitivement ? Cette action est irréversible.","Supprimer %s éléments définitivement ? Cette action est irréversible."],"Delete Permanently":["Supprimer définitivement"],"Delete the field":["Supprimer le champ"],"Delete the group":["Supprimer le groupe"],"Delete this %(count)s item for good? This cannot be undone.":["Supprimer définitivement ce %(count)s élément ? Cette action est irréversible.","Supprimer définitivement ces %(count)s éléments ? Cette action est irréversible.","Supprimer définitivement ces %(count)s éléments ? Cette action est irréversible."],"Delete…":["Supprimer…"],"Describe":["Décrire"],"Describe…":["Décrire…"],"Description":["Description"],"Description updated.":["Description mise à jour."],"Desktop":["Ordinateur"],"Destination":["Destination"],"Dimensions":["Dimensions"],"Disable":["Désactiver"],"Disable %(name)s":["Désactiver %(name)s"],"Disabled":["Désactivé"],"Dismiss":["Ignorer"],"Document":["Document"],"Done. The site now holds what the file describes.":["Terminé. Le site contient maintenant ce que décrit le fichier."],"Draft saved.":["Brouillon enregistré."],"Drop files to upload":["Déposez vos fichiers pour les téléverser"],"Email":["E-mail"],"Empty Trash":["Vider la corbeille"],"Empty trash":["Vider la corbeille"],"Enable":["Activer"],"Enable %(name)s":["Activer %(name)s"],"Every address of this type moves. Links kept elsewhere to the old addresses stop working.":["Toutes les adresses de ce type changent. Les liens conservés ailleurs vers les anciennes adresses cessent de fonctionner."],"Every content type":["Tous les types de contenu"],"Every field group needs a title. Give it a name and save again.":["Chaque groupe de champs a besoin d'un titre. Donnez-lui un nom et enregistrez à nouveau."],"Every item needs an author who exists. Pick one from the list and save again.":["Chaque élément a besoin d'un auteur existant. Choisissez-en un dans la liste et enregistrez de nouveau."],"Every kind of content this site holds.":["Toutes les sortes de contenu que ce site contient."],"Every value stored under this field goes with it, in every item the group reaches and in the revisions behind them.":["Toutes les valeurs enregistrées dans ce champ partent avec lui, dans chaque élément que le groupe atteint et dans les révisions qui les suivent."],"Excerpt":["Extrait"],"Export definitions":["Exporter les définitions"],"February":["février"],"Fewest files":["Minimum de fichiers"],"Fewest rows":["Minimum de lignes"],"Field":["Champ"],"Field Groups":["Groupes de champs"],"Field group":["Groupe de champs"],"Fields":["Champs"],"Fields of %(group)s":["Champs de %(group)s"],"File name":["Nom du fichier"],"File size":["Taille du fichier"],"File type":["Type de fichier"],"Files":["Fichiers"],"Filter by status":["Filtrer par état"],"Follow the browser":["Suivre le navigateur"],"From %(plugin)s":["De %(plugin)s"],"Full Size":["Taille originale"],"Gophenberg cannot start this update: %(reason)s":["Gophenberg ne peut pas lancer cette mise à jour : %(reason)s"],"Gophenberg checked for updates moments ago. Try again later.":["Gophenberg a cherché des mises à jour il y a un instant. Réessayez plus tard."],"Gophenberg could not read what your browser sent. Reload the page and try again, and tell whoever looks after your site if it keeps happening.":["Gophenberg n'a pas pu lire ce que votre navigateur a envoyé. Rechargez la page et réessayez, et prévenez la personne qui s'occupe de votre site si cela continue."],"Gophenberg could not read which item you picked as parent. Reload the page and pick it again.":["Gophenberg n'a pas pu lire quel élément vous avez choisi comme parent. Rechargez la page et choisissez-le de nouveau."],"Gophenberg does not accept %(extension)s files. Upload a picture, a video, an audio file, a PDF or a zip instead.":["Gophenberg n'accepte pas les fichiers %(extension)s. Téléversez plutôt une image, une vidéo, un fichier audio, un PDF ou un zip."],"Gophenberg does not answer in %(value)s. Pick one of the languages on the list.":["Gophenberg ne répond pas en %(value)s. Choisissez une des langues de la liste."],"Gophenberg has no autosave of yours for this item. Open the item to see the version that is stored.":["Gophenberg n'a aucun enregistrement automatique de vous pour cet élément. Ouvrez l'élément pour voir la version stockée."],"Gophenberg holds no kind of definition called %(subject)s, so nothing was taken over. Reload the page so the admin and the server agree again.":["Gophenberg ne connaît aucune sorte de définition nommée %(subject)s, rien n'a donc été repris. Rechargez la page pour que l'admin et le serveur se rejoignent à nouveau."],"Gophenberg keeps /%(path)s for itself or for another content type, so your content cannot answer there. Give the item a different slug.":["Gophenberg garde l'adresse /%(path)s pour lui ou pour un autre type de contenu, votre contenu ne peut donc pas y répondre. Donnez un autre slug à l'élément."],"Gophenberg keeps this word for itself, so no content type can answer under it. Pick a different route word.":["Gophenberg garde ce mot pour lui, aucun type de contenu ne peut donc répondre sous ce mot. Choisissez un autre mot de route."],"Group":["Groupe"],"Highest":["Maximum"],"Holds":["Contient"],"Images":["Images"],"Import definitions":["Importer les définitions"],"In list":["Dans la liste"],"Inactive":["Inactif"],"Installed":["Installé"],"Instructions":["Instructions"],"It moves here from another group and keeps its stored values.":["Il arrive ici depuis un autre groupe et garde ses valeurs enregistrées."],"It moves to another group and keeps its stored values.":["Il passe dans un autre groupe et garde ses valeurs enregistrées."],"It moves to another group, so its stored values do not follow.":["Il passe dans un autre groupe, donc ses valeurs enregistrées ne le suivent pas."],"It reads a relation inside a container, and only an import can point it elsewhere.":["Il lit une relation dans un conteneur, et seule une importation peut le faire pointer ailleurs."],"It starts on %(type)s, and the rules may be edited afterwards.":["Il commence sur %(type)s, et les règles peuvent être modifiées ensuite."],"Items could not be loaded.":["Les éléments n'ont pas pu être chargés."],"Its items sit inside one another, so it keeps nesting.":["Ses éléments se trouvent les uns dans les autres, donc il continue de s'imbriquer."],"Its kind changed, so its stored values go with it.":["Son type a changé, donc ses valeurs enregistrées partent avec lui."],"January":["janvier"],"July":["juillet"],"June":["juin"],"Keep %(field)s out of the list":["Garder %(field)s hors de la liste"],"Keep it":["Le conserver"],"Keep it here":["Le laisser ici"],"Key":["Clé"],"Label":["Libellé"],"Language":["Langue"],"Large":["Grande"],"Let items nest":["Autoriser l'imbrication"],"Linked from":["Lié depuis"],"List view":["Vue en liste"],"Loading field groups.":["Chargement des groupes de champs."],"Loading the post.":["Chargement de l'article."],"Loading the screen.":["Chargement de l'écran."],"Loading themes.":["Chargement des thèmes."],"Loading users.":["Chargement des utilisateurs."],"Loading…":["Chargement…"],"Longest":["Longueur maximale"],"Lowest":["Minimum"],"Make %(field)s optional":["Rendre %(field)s facultatif"],"Make default":["Rendre par défaut"],"Make default…":["Rendre par défaut…"],"Make optional":["Rendre facultatif"],"Manage the categories on this site.":["Gérez les catégories de ce site."],"Manage the images and files this site holds.":["Gérez les images et les fichiers que ce site contient."],"Manage the items of this content type.":["Gérez les éléments de ce type de contenu."],"Manage the pages on this site.":["Gérez les pages de ce site."],"Manage the posts on this site.":["Gérez les articles de ce site."],"Many targets":["Plusieurs cibles"],"Many values":["Plusieurs valeurs"],"March":["mars"],"May":["mai"],"Media %(id)s":["Média %(id)s"],"Media Library":["Bibliothèque de médias"],"Medium":["Moyenne"],"Mobile":["Mobile"],"Modified: %s":["Modifié : %s"],"Most files":["Maximum de fichiers"],"Most rows":["Maximum de lignes"],"Move":["Déplacer"],"Move \"%(title)s\" to the trash?":["Déplacer « %(title)s » dans la corbeille ?"],"Move %(field)s down":["Descendre %(field)s"],"Move %(field)s elsewhere":["Déplacer %(field)s ailleurs"],"Move %(field)s up":["Monter %(field)s"],"Move %(group)s down":["Descendre %(group)s"],"Move %(group)s up":["Monter %(group)s"],"Move %(name)s down":["Descendre %(name)s"],"Move %(name)s up":["Monter %(name)s"],"Move %s item to the trash?":["Déplacer %s élément dans la corbeille ?","Déplacer %s éléments dans la corbeille ?","Déplacer %s éléments dans la corbeille ?"],"Move every address":["Déplacer toutes les adresses"],"Move row down":["Descendre la ligne"],"Move row up":["Monter la ligne"],"Move the field":["Déplacer le champ"],"Move to trash":["Déplacer dans la corbeille"],"Name":["Nom"],"Name updated.":["Nom mis à jour."],"Nests":["Imbriqué"],"Newer posts":["Articles plus récents"],"No":["Non"],"No content types found.":["Aucun type de contenu trouvé."],"No description":["Aucune description"],"No file arrived with this upload. Choose a file and try again.":["Aucun fichier n'est arrivé avec ce téléversement. Choisissez un fichier et réessayez."],"No items found.":["Aucun élément trouvé."],"No items yet.":["Aucun élément pour l'instant."],"No media chosen":["Aucun média choisi"],"No media has been uploaded yet.":["Aucun média n'a encore été téléversé."],"No media items found.":["Aucun fichier média trouvé."],"No media items yet.":["Aucun fichier média pour l’instant."],"No page lives at this address.":["Aucune page n'existe à cette adresse."],"No parent":["Pas de parent"],"No theme arrived with this upload. Choose a zip file and try again.":["Aucun thème n'est arrivé avec ce téléversement. Choisissez un fichier zip et réessayez."],"No themes are installed.":["Aucun thème n'est installé."],"No title":["Sans titre"],"None":["Aucun"],"Not %(type)s":["Sauf %(type)s"],"Not found":["Introuvable"],"Not serving":["Hors service"],"Nothing declares that role.":["Rien ne déclare ce rôle."],"Nothing points here yet.":["Rien ne pointe encore ici."],"Nothing published here yet.":["Rien n'est encore publié ici."],"Nothing published yet.":["Rien n'est encore publié."],"November":["novembre"],"Nowhere":["Nulle part"],"Number":["Nombre"],"October":["octobre"],"Older posts":["Articles plus anciens"],"One frame of this animation holds more than %(max)s pixels, which is more than Gophenberg opens. Upload a smaller animation.":["Une image de cette animation dépasse %(max)s pixels, plus que ce que Gophenberg ouvre. Téléversez une animation plus petite."],"One of the filters on this list is not one Gophenberg understands. Clear the filters and try again.":["Un des filtres de cette liste n'est pas un filtre que Gophenberg comprend. Videz les filtres et réessayez."],"One of the items picked for %(field)s is not the kind of content that field points at. Reload the page and pick from the list the field offers.":["Un des éléments choisis pour %(field)s n'est pas le genre de contenu que vise ce champ. Rechargez la page et choisissez dans la liste que le champ propose."],"One of the items picked for %(field)s was not named in a way Gophenberg understands. Reload the page and pick the items again.":["Un des éléments choisis pour %(field)s n'était pas nommé d'une façon que Gophenberg comprend. Rechargez la page et choisissez de nouveau les éléments."],"One of the items picked no longer exists. Reload the page and pick again.":["Un des éléments choisis n'existe plus. Rechargez la page et choisissez de nouveau."],"One target":["Une cible"],"Only a relation field points at another content type or holds many items. Pick the Relation kind, or leave the target and the many option empty.":["Seul un champ de relation vise un autre type de contenu ou contient plusieurs éléments. Choisissez le genre Relation, ou laissez vides la cible et l'option plusieurs."],"Only one file may be placed here.":["Un seul fichier peut être placé ici."],"Only the content type at the root of your site may go without a route word. Give this type one and try again.":["Seul le type de contenu à la racine de votre site peut se passer de mot de route. Donnez-en un à ce type et réessayez."],"Open %(field)s in a new tab":["Ouvrir %(field)s dans un nouvel onglet"],"Order stored.":["Ordre enregistré."],"Other":["Autre"],"Out of list":["Hors de la liste"],"Parent":["Parent"],"Password":["Mot de passe"],"Pending":["En attente"],"Pending Review":["En attente de relecture"],"Permanently delete":["Supprimer définitivement"],"Permanently delete…":["Supprimer définitivement…"],"Picture quality":["Qualité d'image"],"Picture quality runs from 1 to %(max)s, so %(value)s is not a quality Gophenberg stores at. Pick a number inside that range.":["La qualité d'image va de 1 à %(max)s, donc %(value)s n'est pas une qualité avec laquelle Gophenberg enregistre. Choisissez un nombre dans cet intervalle."],"Placeholder":["Texte indicatif"],"Plural name":["Nom au pluriel"],"Points at":["Vise"],"Post published.":["Article publié."],"Posts per page":["Articles par page"],"Preview":["Prévisualiser"],"Private":["Privé"],"Publish":["Publier"],"Published: %s":["Publié : %s"],"Radio group":["Groupe de boutons radio"],"Reads from":["Lit depuis"],"Redo":["Rétablir"],"Relation":["Relation"],"Remove":["Retirer"],"Remove %(name)s":["Retirer %(name)s"],"Remove %(title)s":["Retirer %(title)s"],"Remove row":["Retirer la ligne"],"Rename":["Renommer"],"Rename %(field)s":["Renommer %(field)s"],"Rename…":["Renommer…"],"Require":["Rendre obligatoire"],"Require %(field)s":["Rendre %(field)s obligatoire"],"Required":["Obligatoire"],"Restore":["Rétablir"],"Role changed.":["Rôle modifié."],"Roll back to %(theme)s":["Revenir à %(theme)s"],"Roll back to the built-in renderer":["Revenir au moteur de rendu intégré"],"Route word":["Mot de route"],"Rule %(number)s of set %(set)s":["Règle %(number)s de l'ensemble %(set)s"],"Rule set %(number)s":["Ensemble de règles %(number)s"],"Rules":["Règles"],"Rules showing %(field)s":["Règles affichant %(field)s"],"Save":["Enregistrer"],"Save draft":["Enregistrer le brouillon"],"Save rules":["Enregistrer les règles"],"Save settings":["Enregistrer les réglages"],"Saved":["Enregistré"],"Saving":["Enregistrement"],"Scheduled":["Planifié"],"Scheduled: %s":["Planifié : %s"],"Search %(type)s…":["Rechercher des %(type)s…"],"Search audio":["Rechercher un audio"],"Search images":["Rechercher des images"],"Search media":["Rechercher des médias"],"Search videos":["Rechercher des vidéos"],"Select":["Sélectionner"],"September":["septembre"],"Serving":["En service"],"Settings":["Réglages"],"Settings of %(field)s":["Réglages de %(field)s"],"Shadowed":["Masqué"],"Show %(field)s in the list":["Afficher %(field)s dans la liste"],"Showing %(held)s of %(total)s.":["Affichage de %(held)s sur %(total)s."],"Singular name":["Nom au singulier"],"Slug":["Slug"],"Somebody must be able to manage users.":["Quelqu'un doit pouvoir gérer les utilisateurs."],"Someone else changed this media item while you were editing it. Reload the page and make your change again.":["Quelqu'un d'autre a modifié ce média pendant que vous le modifiiez. Rechargez la page et refaites votre modification."],"Someone else saved changes to this item while you were editing, so yours were not saved. Copy your text somewhere safe, then reload the item and put your changes back in.":["Quelqu'un d'autre a enregistré des modifications sur cet élément pendant que vous éditiez, les vôtres n'ont donc pas été enregistrées. Copiez votre texte en lieu sûr, puis rechargez l'élément et remettez vos modifications."],"Something went wrong inside Gophenberg. Try again in a moment, and tell whoever looks after your site if it keeps happening.":["Quelque chose a mal tourné dans Gophenberg. Réessayez dans un instant, et prévenez la personne qui s'occupe de votre site si cela continue."],"Something went wrong.":["Quelque chose a mal tourné."],"Something went wrong. Try again.":["Quelque chose a mal tourné. Réessayez."],"Sorry, you are not allowed to access this page.":["Désolé, vous n’avez pas l’autorisation d’accéder à cette page."],"Source":["Source"],"Steps of":["Pas de"],"Stop nesting":["Arrêter l'imbrication"],"Tablet":["Tablette"],"Text":["Texte"],"Text area":["Zone de texte"],"That email is already in use.":["Cet e-mail est déjà utilisé."],"That field group no longer exists. Reload the page to see the groups your site holds now.":["Ce groupe de champs n'existe plus. Rechargez la page pour voir les groupes que votre site contient maintenant."],"That field is not on this content type any more. Reload the page to see the fields it has now.":["Ce champ n'est plus sur ce type de contenu. Rechargez la page pour voir les champs qu'il possède maintenant."],"That revision does not exist, and older ones drop off once a type holds more than it keeps. Open the item to see which revisions are left.":["Cette révision n'existe pas, et les plus anciennes disparaissent quand un type en détient plus qu'il n'en conserve. Ouvrez l'élément pour voir les révisions restantes."],"That role holds more than your own.":["Ce rôle va au-delà du vôtre."],"That web address does not name a media item Gophenberg can look up. Open the media library and pick the item from there.":["Cette adresse web ne désigne aucun média que Gophenberg puisse trouver. Ouvrez la bibliothèque de médias et choisissez l'élément depuis là."],"That web address does not name a revision Gophenberg can look up. Open the item and pick a revision from its history.":["Cette adresse web ne désigne aucune révision que Gophenberg puisse trouver. Ouvrez l'élément et choisissez une révision dans son historique."],"That web address does not name an item Gophenberg can look up. Go back to the list and open the item from there.":["Cette adresse web ne désigne aucun élément que Gophenberg puisse trouver. Revenez à la liste et ouvrez l'élément depuis là."],"The %(count)s item in the trash is deleted for good. This cannot be undone.":["%(count)s élément de la corbeille est supprimé définitivement. Cette action est irréversible.","Les %(count)s éléments de la corbeille sont supprimés définitivement. Cette action est irréversible.","Les %(count)s éléments de la corbeille sont supprimés définitivement. Cette action est irréversible."],"The %(file)s calls this theme %(declared)s, but it is being installed as %(installed)s. Rename the zip file to match, or change the name inside %(file)s.":["Le fichier %(file)s appelle ce thème %(declared)s, mais il s'installe sous %(installed)s. Renommez le fichier zip pour qu'ils correspondent, ou changez le nom dans %(file)s."],"The %(file)s inside %(name)s is not valid JSON, so it could not be read. Fix that file and package the theme again.":["Le fichier %(file)s dans %(name)s n'est pas du JSON valide, il n'a donc pas pu être lu. Corrigez ce fichier et réempaquetez le thème."],"The %(file)s inside %(name)s names its theme kit as %(declared)s, which is not a version number. Build the theme again, and the build will fill that in.":["Le %(file)s dans %(name)s indique son kit de thèmes comme %(declared)s, ce qui n'est pas un numéro de version. Compilez le thème à nouveau et la compilation le renseignera."],"The %(origin)s plugin declared this, so it can only be changed in that plugin. Turning it off is still yours to do.":["L'extension %(origin)s l'a déclaré, donc il ne peut être modifié que dans cette extension. Le désactiver reste entre vos mains."],"The %(plugin)s plugin also declares %(name)s, and the site keeps the key.":["L'extension %(plugin)s déclare aussi %(name)s, et le site garde la clé."],"The %(plugin)s plugin declared these fields, so they change only in its code.":["L'extension %(plugin)s a déclaré ces champs, donc ils ne changent que dans son code."],"The %(plugin)s plugin declares %(name)s flat, and the site keeps it nesting while its items sit inside another.":["L'extension %(plugin)s déclare %(name)s sans imbrication, et le site continue de l'imbriquer tant que ses éléments se trouvent dans un autre."],"The browser kept an unsaved version of this post.":["Le navigateur a gardé une version non enregistrée de cet article."],"The built-in renderer is now serving the public site.":["Le moteur de rendu intégré sert désormais le site public."],"The built-in renderer is serving the public site.":["Le moteur de rendu intégré sert le site public."],"The content type this field points at is not named in a shape Gophenberg accepts. Give it a key that starts with a lowercase letter and then holds only lowercase letters, numbers and hyphens.":["Le type de contenu que vise ce champ n'est pas nommé sous une forme que Gophenberg accepte. Donnez-lui une clé qui commence par une lettre minuscule et ne contient ensuite que des lettres minuscules, des chiffres et des tirets."],"The content types could not be loaded, so where these groups appear and which fields they shadow are not shown.":["Les types de contenu n'ont pas pu être chargés, donc où ces groupes apparaissent et quels champs ils masquent ne sont pas affichés."],"The content types could not be loaded.":["Les types de contenu n'ont pas pu être chargés."],"The content types could not be refreshed, so where these groups appear and what they shadow may be out of date.":["Les types de contenu n'ont pas pu être actualisés, donc où ces groupes apparaissent et ce qu'ils masquent peut être obsolète."],"The draft could not be created.":["Le brouillon n'a pas pu être créé."],"The entry %(entry)s in this zip would be written outside the theme folder, so nothing was installed. Package the theme again.":["L'entrée %(entry)s de ce zip s'écrirait hors du dossier du thème, rien n'a donc été installé. Réempaquetez le thème."],"The field %(field)s cannot go, because %(by)s reads it. Change %(by)s first.":["Le champ %(field)s ne peut pas être retiré, car %(by)s le lit. Modifiez d'abord %(by)s."],"The field %(field)s cannot hold the value it was given. Check what kind of field it is and enter a value that matches.":["Le champ %(field)s ne peut pas contenir la valeur qui lui a été donnée. Vérifiez de quel genre de champ il s'agit et saisissez une valeur qui convient."],"The field %(field)s has to hold a value before this item can be published. Fill it in, or leave the item as a draft.":["Le champ %(field)s doit contenir une valeur avant que cet élément puisse être publié. Remplissez-le, ou laissez l'élément en brouillon."],"The field %(field)s in %(group)s still points at this content type. Delete that field first, then delete this type.":["Le champ %(field)s dans %(group)s vise encore ce type de contenu. Supprimez d'abord ce champ, puis supprimez ce type."],"The field %(field)s is hidden by its own rules right now, so it cannot hold a value. Show it again before filling it in.":["Le champ %(field)s est masqué par ses propres règles pour le moment, il ne peut donc contenir aucune valeur. Affichez-le de nouveau avant de le remplir."],"The field %(field)s points at one item only. Remove the extra choices and keep one.":["Le champ %(field)s ne vise qu'un seul élément. Retirez les choix en trop et gardez-en un."],"The field %(field)s points at other items, so it does not take a plain value. Pick the items it points at instead.":["Le champ %(field)s vise d'autres éléments, il n'accepte donc pas une valeur simple. Choisissez plutôt les éléments qu'il vise."],"The field %(field)s takes a list of items, and what arrived was not a list. Reload the page and pick the items again.":["Le champ %(field)s attend une liste d'éléments, et ce qui est arrivé n'était pas une liste. Rechargez la page et choisissez de nouveau les éléments."],"The field %(source)s cannot hold the value %(value)s, so no rule can compare it. Write a value of the kind %(source)s stores.":["Le champ %(source)s ne peut pas contenir la valeur %(value)s, aucune règle ne peut donc la comparer. Écrivez une valeur du type que %(source)s enregistre."],"The field groups could not be loaded.":["Les groupes de champs n'ont pas pu être chargés."],"The field groups could not be reached.":["Les groupes de champs n'ont pas pu être joints."],"The field keeps its values on the content both groups reach.":["Le champ garde ses valeurs sur le contenu que les deux groupes atteignent."],"The fields this group holds go with it, and so do the values every item stored in them.":["Les champs que ce groupe contient partent avec lui, ainsi que les valeurs que chaque élément y a enregistrées."],"The groups were reordered.":["Les groupes ont été réordonnés."],"The item could not be duplicated.":["L'élément n'a pas pu être dupliqué."],"The item could not be moved to the trash.":["L'élément n'a pas pu être déplacé dans la corbeille."],"The item could not be permanently deleted.":["L'élément n'a pas pu être supprimé définitivement."],"The item could not be restored.":["L'élément n'a pas pu être rétabli."],"The item you picked as parent holds another kind of content. Pick a parent of the same content type as this item.":["L'élément que vous avez choisi comme parent contient un autre genre de contenu. Choisissez un parent du même type de contenu que cet élément."],"The item you picked as parent is in the trash. Restore it, or pick another parent.":["L'élément que vous avez choisi comme parent est dans la corbeille. Rétablissez-le, ou choisissez un autre parent."],"The item you picked as parent no longer exists. Reload the page and pick another parent.":["L'élément que vous avez choisi comme parent n'existe plus. Rechargez la page et choisissez un autre parent."],"The media library could not be loaded.":["La bibliothèque de médias n'a pas pu être chargée."],"The name changes. Nothing stored under this field moves.":["Le nom change. Rien de ce qui est stocké sous ce champ ne bouge."],"The name could not be updated.":["Le nom n'a pas pu être mis à jour."],"The new order has to name every field group exactly once. Reload the page and move the groups again.":["Le nouvel ordre doit nommer chaque groupe de champs exactement une fois. Rechargez la page et déplacez les groupes à nouveau."],"The new order has to name every field of this content type exactly once. Reload the page and move the fields again.":["Le nouvel ordre doit nommer chaque champ de ce type de contenu exactement une fois. Rechargez la page et déplacez les champs à nouveau."],"The number of revisions to keep has to be a whole number, and it cannot be negative. Enter a different number.":["Le nombre de révisions à conserver doit être un nombre entier, et il ne peut pas être négatif. Saisissez un autre nombre."],"The registry could not be reached.":["Le registre n'a pas pu être joint."],"The role could not be changed.":["Le rôle n'a pas pu être modifié."],"The rule on %(source)s names nothing to match. Pick a value, or remove the rule.":["La règle sur %(source)s ne nomme rien à faire correspondre. Choisissez une valeur, ou retirez la règle."],"The rules could not be loaded.":["Les règles n'ont pas pu être chargées."],"The rules on %(field)s lead back to %(field)s itself, so nothing can decide whether to show it. Point one of them at another field.":["Les règles de %(field)s reviennent à %(field)s lui-même, rien ne peut donc décider de l'afficher. Faites pointer l'une d'elles vers un autre champ."],"The same item was picked twice for %(field)s. Pick each item once.":["Le même élément a été choisi deux fois pour %(field)s. Choisissez chaque élément une seule fois."],"The server could not be reached, so nothing was changed.":["Le serveur n'a pas pu être joint, rien n'a donc été changé."],"The server could not be reached, so nothing was saved.":["Le serveur n'a pas pu être joint, rien n'a donc été enregistré."],"The server could not be reached, so nothing was uploaded.":["Le serveur n'a pas pu être joint, rien n'a donc été téléversé."],"The setting %(setting)s cannot hold the value it was given. Check what this setting takes and try again.":["Le réglage %(setting)s ne peut pas contenir la valeur donnée. Vérifiez ce que ce réglage accepte et réessayez."],"The setting %(setting)s falls outside the others. Keep min below max and the default inside the bounds.":["Le réglage %(setting)s sort des autres. Gardez min sous max et la valeur par défaut dans les limites."],"The settings could not be read, so nothing can be changed here yet.":["Les réglages n'ont pas pu être lus, donc rien ne peut encore être modifié ici."],"The site default":["La langue par défaut du site"],"The site may already hold part of this file. Import it again to see what is left.":["Le site contient peut-être déjà une partie de ce fichier. Importez-le à nouveau pour voir ce qu'il reste."],"The source %(source)s does not offer %(operator)s. Pick one of the comparisons it does offer.":["La source %(source)s n'offre pas %(operator)s. Choisissez une des comparaisons qu'elle offre."],"The theme %(name)s carries no %(file)s, so Gophenberg cannot tell what it is. Put that file at the top level of the zip and package the theme again.":["Le thème %(name)s ne contient aucun %(file)s, Gophenberg ne peut donc pas savoir ce que c'est. Placez ce fichier au premier niveau du zip et réempaquetez le thème."],"The theme %(name)s could not be read, so it cannot serve your site. Upload it again, or choose another theme.":["Le thème %(name)s n'a pas pu être lu, il ne peut donc pas servir votre site. Téléversez-le de nouveau, ou choisissez un autre thème."],"The theme %(name)s did not start, so your site is still served by the theme it had. Ask whoever looks after your site to check the server log for what the theme reported.":["Le thème %(name)s n'a pas démarré, votre site est donc toujours servi par le thème qu'il avait. Demandez à la personne qui s'occupe de votre site de regarder ce que le thème a signalé dans le journal du serveur."],"The theme %(name)s does not carry %(path)s as a file, so there is nothing to run. Package the built theme rather than its source.":["Le thème %(name)s ne contient pas %(path)s comme fichier, il n'y a donc rien à exécuter. Empaquetez le thème construit plutôt que sa source."],"The theme %(name)s does not carry %(path)s as a folder, so its styles and scripts are missing. Package the built theme rather than its source.":["Le thème %(name)s ne contient pas %(path)s comme dossier, ses styles et ses scripts manquent donc. Empaquetez le thème construit plutôt que sa source."],"The theme %(name)s holds a link to another file at %(path)s, and a theme may only carry real files. Replace it and package the theme again.":["Le thème %(name)s contient un lien vers un autre fichier à %(path)s, et un thème ne peut contenir que de vrais fichiers. Remplacez-le et réempaquetez le thème."],"The theme %(name)s is serving your site right now, so it cannot be replaced. Deactivate it first, or install this copy under another name.":["Le thème %(name)s sert votre site en ce moment, il ne peut donc pas être remplacé. Désactivez-le d'abord, ou installez cette copie sous un autre nom."],"The theme %(name)s is the one chosen, but it is not installed, so your site is served by the built-in renderer. Upload the theme again, or choose another one.":["Le thème %(name)s est celui choisi, mais il n'est pas installé, votre site est donc servi par le moteur de rendu intégré. Téléversez de nouveau le thème, ou choisissez-en un autre."],"The theme %(name)s was built on theme kit %(declared)s, and this site serves %(served)s. Build the theme again against a kit this site serves, then install it.":["Le thème %(name)s a été compilé avec le kit de thèmes %(declared)s, et ce site sert %(served)s. Compilez le thème à nouveau avec un kit que ce site sert, puis installez-le."],"The trash could not be emptied.":["La corbeille n'a pas pu être vidée."],"The user could not be created.":["L'utilisateur n'a pas pu être créé."],"The values stored under it do not follow.":["Les valeurs enregistrées dessous ne le suivent pas."],"Theme":["Thème"],"Themes":["Thèmes"],"Themes could not be loaded.":["Les thèmes n'ont pas pu être chargés."],"There is no content type called %(value)s. Pick a type your site holds, or register it first.":["Il n'existe aucun type de contenu appelé %(value)s. Choisissez un type que votre site possède, ou déclarez-le d'abord."],"There is no content type called %(value)s. Pick one of the types your site holds and try again.":["Il n'existe aucun type de contenu appelé %(value)s. Choisissez un des types que votre site possède et réessayez."],"There is no kind of field called %(value)s. Pick one of these instead: %(allowed)s.":["Il n'existe aucun genre de champ appelé %(value)s. Choisissez plutôt parmi ceux-ci : %(allowed)s."],"There is no page kind called %(value)s. Pick one of these instead: %(allowed)s.":["Il n'existe aucun genre de page appelé %(value)s. Choisissez plutôt parmi ceux-ci : %(allowed)s."],"There is no status called %(value)s. Pick one of these instead: %(allowed)s.":["Il n'existe aucun état appelé %(value)s. Choisissez plutôt parmi ceux-ci : %(allowed)s."],"There is nothing at this address. Check the link and try again.":["Il n'y a rien à cette adresse. Vérifiez le lien et réessayez."],"There is nothing to roll back to, because your site has not been through a theme change yet. Install a theme first, then a rollback has somewhere to go.":["Il n'y a rien vers quoi revenir, car votre site n'a pas encore changé de thème. Installez d'abord un thème, et le retour aura alors une destination."],"These files could not be read.":["Ces fichiers n'ont pas pu être lus."],"These were left alone, because nobody confirmed losing them.":["Ceux-ci ont été laissés tels quels, car personne n'a confirmé leur perte."],"This content type does not exist any more. Reload the page to see the types your site holds now.":["Ce type de contenu n'existe plus. Rechargez la page pour voir les types que votre site possède maintenant."],"This content type does not nest, so one of its items cannot sit inside another. Mark the type as Nests first.":["Ce type de contenu ne s'imbrique pas, un de ses éléments ne peut donc pas se trouver dans un autre. Marquez d'abord le type comme Imbriqué."],"This content type has no field called %(field)s. Check the name, or declare the field on the type first.":["Ce type de contenu n'a aucun champ appelé %(field)s. Vérifiez le nom, ou déclarez d'abord le champ sur le type."],"This content type is turned off, so its content is not served or edited. Activate it first.":["Ce type de contenu est désactivé, son contenu n'est donc ni servi ni modifié. Activez-le d'abord."],"This content type still holds content, so it cannot be deleted. Move or delete its items first.":["Ce type de contenu contient encore du contenu, il ne peut donc pas être supprimé. Déplacez ou supprimez d'abord ses éléments."],"This definitions file is larger than the %(max)s bytes Gophenberg reads in one request. Split it, or raise the limit your site runs with.":["Ce fichier de définitions dépasse les %(max)s octets que Gophenberg lit en une requête. Divisez-le, ou relevez la limite de votre site."],"This field holds no layouts yet.":["Ce champ ne contient pas encore de dispositions."],"This field would stand inside too many containers. Put it nearer the top and try again.":["Ce champ se trouverait dans trop de conteneurs. Placez-le plus près du niveau supérieur et réessayez."],"This file could not be opened as a zip. Package the theme again and upload the new file.":["Ce fichier n'a pas pu être ouvert comme zip. Réempaquetez le thème et téléversez le nouveau fichier."],"This file is larger than the upload limit. Upload a smaller file.":["Ce fichier dépasse la limite de téléversement. Téléversez un fichier plus petit."],"This file is named %(extension)s but it holds %(detected)s content. Upload it under a name that matches what is inside it.":["Ce fichier est nommé %(extension)s mais il contient du %(detected)s. Téléversez-le sous un nom qui correspond à ce qu'il contient."],"This file matches the site, so there is nothing to change.":["Ce fichier correspond au site, il n'y a donc rien à changer."],"This file says it was written in format %(declared)s, which is not a version Gophenberg can read. Export the definitions again from the site they came from.":["Ce fichier indique le format %(declared)s, qui n'est pas une version que Gophenberg sait lire. Exportez à nouveau les définitions depuis le site d'origine."],"This file was written in format %(declared)s, and this release reads %(served)s. Upgrade the site the file came from, or this one.":["Ce fichier est écrit au format %(declared)s, et cette version lit %(served)s. Mettez à jour le site d'origine du fichier, ou celui-ci."],"This group holds no fields yet.":["Ce groupe ne contient pas encore de champs."],"This install of Gophenberg has no install root, so it updates by hand.":["Cette installation de Gophenberg n'a pas de racine d'installation, elle se met donc à jour à la main."],"This is a custom build of Gophenberg, so it updates by hand.":["Il s'agit d'une version compilée sur mesure de Gophenberg, elle se met donc à jour à la main."],"This item cannot go straight to that status from the one it holds now. Save it as a draft first, then try again.":["Cet élément ne peut pas passer directement à cet état depuis celui qu'il a maintenant. Enregistrez-le d'abord comme brouillon, puis réessayez."],"This item changed while you were describing it. Reload and try again.":["Cet élément a changé pendant que vous le décriviez. Rechargez et réessayez."],"This item does not exist, and it may have been deleted. Go back to the list to see what is there.":["Cet élément n'existe pas, et il a peut-être été supprimé. Revenez à la liste pour voir ce qui s'y trouve."],"This item is already in the trash. Restore it first to work on it again.":["Cet élément est déjà dans la corbeille. Rétablissez-le d'abord pour y retravailler."],"This item is in the trash. Restore it first to work on it again.":["Cet élément est dans la corbeille. Rétablissez-le d'abord pour y retravailler."],"This item is not in the trash, so there is nothing to restore. Open it from the list to work on it as it is.":["Cet élément n'est pas dans la corbeille, il n'y a donc rien à rétablir. Ouvrez-le depuis la liste pour travailler dessus tel quel."],"This item still holds items nested inside it. Move or delete those first.":["Cet élément contient encore des éléments imbriqués. Déplacez-les ou supprimez-les d'abord."],"This kind of field holds no fields inside it. Put the field in a section or a repeater instead.":["Ce type de champ ne contient aucun champ. Placez le champ dans une section ou dans un répéteur."],"This media item is not in the library, and someone may have deleted it. Reload the page to see what is there now.":["Ce média n'est pas dans la bibliothèque, et quelqu'un l'a peut-être supprimé. Rechargez la page pour voir ce qui s'y trouve maintenant."],"This picture could not be read, so nothing was stored. The file may be damaged, so open it on your computer and upload it again.":["Cette image n'a pas pu être lue, rien n'a donc été stocké. Le fichier est peut-être endommagé, ouvrez-le sur votre ordinateur et téléversez-le de nouveau."],"This picture is %(width)s by %(height)s pixels, over the limit of %(max)s pixels in all. Make it smaller and upload it again.":["Cette image fait %(width)s par %(height)s pixels, au-delà de la limite de %(max)s pixels au total. Réduisez-la et téléversez-la de nouveau."],"This post changed elsewhere. Reload before saving again.":["Cet article a changé ailleurs. Rechargez avant d'enregistrer de nouveau."],"This request came from a page on another site, so it was refused. Open the admin on this site and try again.":["Cette requête vient d'une page d'un autre site, elle a donc été refusée. Ouvrez l'administration sur ce site et réessayez."],"This request carried %(attribute)s, which fields do not take. Reload the page so the admin and the server agree again.":["Cette requête portait %(attribute)s, que les champs n'acceptent pas. Rechargez la page pour que l'administration et le serveur concordent à nouveau."],"This request left out %(field)s, which Gophenberg needs. Reload the page and try again.":["Cette requête a omis %(field)s, dont Gophenberg a besoin. Rechargez la page et réessayez."],"This rule reads a source called %(source)s, and nothing on your site declares one. A plugin that added it may have been removed.":["Cette règle lit une source nommée %(source)s, et rien sur votre site n'en déclare. Une extension qui l'ajoutait a pu être retirée."],"This site cannot write to its themes folder, so no theme can be installed. Ask whoever looks after your site to make the folder that %(setting)s points at writable. A read-only mount is the usual cause.":["Ce site ne peut pas écrire dans son dossier de thèmes, aucun thème ne peut donc être installé. Demandez à la personne qui s'occupe de votre site de rendre accessible en écriture le dossier vers lequel pointe %(setting)s. Un montage en lecture seule en est la cause habituelle."],"This site has nowhere to keep themes, so no theme can be installed. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Ce site n'a nulle part où garder les thèmes, aucun thème ne peut donc être installé. Demandez à la personne qui s'occupe de votre site de faire pointer %(setting)s vers un dossier où le serveur peut écrire."],"This site has nowhere to store uploads, so nothing can be uploaded. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Ce site n'a nulle part où stocker les téléversements, rien ne peut donc être téléversé. Demandez à la personne qui s'occupe de votre site de faire pointer %(setting)s vers un dossier où le serveur peut écrire."],"This theme is over the limit of %(max)s bytes, packed or unpacked. Leave out what the theme does not need and package it again.":["Ce thème dépasse la limite de %(max)s octets, compressé ou non. Retirez ce dont le thème n'a pas besoin et réempaquetez-le."],"This type will answer under /%(word)s.":["Ce type répondra sous /%(word)s."],"This zip holds %(entries)s files, more than the %(max)s a theme may carry. Package only the built theme.":["Ce zip contient %(entries)s fichiers, plus que les %(max)s qu'un thème peut contenir. Empaquetez seulement le thème construit."],"Through":["Via"],"Thumbnail":["Miniature"],"Title":["Titre"],"Two field groups in this file share the key %(key)s. Give one of them a different key and import again.":["Deux groupes de champs de ce fichier partagent la clé %(key)s. Donnez une clé différente à l'un d'eux et importez à nouveau."],"Two rule sources are registered under the name %(param)s. A plugin is declaring a source your site already holds.":["Deux sources de règles sont enregistrées sous le nom %(param)s. Une extension déclare une source que votre site possède déjà."],"Type":["Type"],"Type a whole number in both boxes before saving.":["Saisissez un nombre entier dans les deux cases avant d'enregistrer."],"Undo":["Annuler"],"Unsaved changes":["Modifications non enregistrées"],"Update":["Mettre à jour"],"Update failed.":["La mise à jour a échoué."],"Upload theme":["Téléverser un thème"],"Uploaded %s file.":["%s fichier téléversé.","%s fichiers téléversés.","%s fichiers téléversés."],"Users":["Utilisateurs"],"Users could not be loaded.":["Les utilisateurs n'ont pas pu être chargés."],"Value":["Valeur"],"Version":["Version"],"Videos":["Vidéos"],"Visit Site":["Aller sur le site"],"Web address":["Adresse web"],"What each Linked from field reads":["Ce que lit chaque champ Lié depuis"],"What it points at changed, so its stored values go with it.":["Ce qu'il vise a changé, donc ses valeurs enregistrées partent avec lui."],"Where %(group)s appears":["Où apparaît %(group)s"],"Without a rule this field always shows.":["Sans aucune règle ce champ s'affiche toujours."],"Without a rule this group appears nowhere.":["Sans une règle ce groupe n'apparaît nulle part."],"Yes":["Oui"],"Yes or no":["Oui ou non"],"You cannot change your own role.":["Vous ne pouvez pas changer votre propre rôle."],"Your language":["Votre langue"],"Your role does not allow %(capability)s.":["Votre rôle ne permet pas %(capability)s."],"Your server pins the site to the theme %(name)s, so themes cannot be changed here. Ask whoever looks after your site to remove the pin.":["Votre serveur fixe le site au thème %(name)s, les thèmes ne peuvent donc pas être changés ici. Demandez à la personne qui s'occupe de votre site de retirer cette fixation."],"Your site always needs one default content type, so this one cannot be turned off or deleted. Make another type the default first.":["Votre site a toujours besoin d'un type de contenu par défaut, celui-ci ne peut donc être ni désactivé ni supprimé. Rendez d'abord un autre type par défaut."],"a content type":["un type de contenu"],"account\u0004Role":["Rôle"],"account\u0004Status":["État"],"action label\u0004Duplicate":["Dupliquer"],"action label\u0004Duplicate…":["Dupliquer…"],"address":["adresse"],"admin section\u0004Content Types":["Types de contenu"],"admin section\u0004Field Groups":["Groupes de champs"],"admin section\u0004Language":["Langue"],"admin section\u0004Media":["Médias"],"admin section\u0004Settings":["Réglages"],"admin section\u0004Themes":["Thèmes"],"column\u0004Date":["Date"],"content type\u0004\"%s\" permanently deleted.":["Type de contenu « %s » supprimé définitivement."],"content type\u0004\"%s\" successfully created.":["Type de contenu « %s » créé avec succès."],"content type\u0004Status":["État"],"field\u0004Name":["Nom"],"field group\u0004Status":["Statut"],"field type\u0004Choice":["Choix"],"field type\u0004Color":["Couleur"],"field type\u0004Date":["Date"],"field type\u0004Flexible content":["Contenu flexible"],"field type\u0004Gallery":["Galerie"],"field type\u0004Kind":["Genre"],"field type\u0004Layout":["Disposition"],"field type\u0004Link":["Lien"],"field type\u0004Media":["Média"],"field type\u0004Range":["Plage"],"field type\u0004Repeater":["Répéteur"],"field type\u0004Section":["Section"],"field type\u0004Select":["Liste déroulante"],"holds":["contient"],"holds anything":["contient quelque chose"],"is":["est"],"is above":["est supérieur à"],"is below":["est inférieur à"],"is empty":["est vide"],"is not":["n'est pas"],"media family\u0004Kind":["Type"],"panel button label\u0004Settings":["Réglages"],"person\u0004Name":["Nom"],"post\u0004%s (Copy)":["%s (copie)"],"post\u0004Status":["État"],"post status\u0004Draft":["Brouillon"],"post status\u0004Published":["Publié"],"post status\u0004Trash":["Corbeille"],"post type general name\u0004Pages":["Pages"],"post type general name\u0004Posts":["Articles"],"post type singular name\u0004Page":["Page"],"post type singular name\u0004Post":["Article"],"relation target\u0004None":["Aucun"],"role\u0004Administrator":["Administrateur"],"role\u0004Author":["Auteur"],"role\u0004Editor":["Éditeur"],"role\u0004Member":["Membre"],"role\u0004No role":["Sans rôle"],"status filter\u0004All":["Tout"],"taxonomy general name\u0004Categories":["Catégories"],"taxonomy singular name\u0004Category":["Catégorie"],"theme\u0004Status":["État"],"trash\u0004Restore":["Rétablir"],"v%(version)s":["v%(version)s"],"verb\u0004Trash…":["Mettre à la corbeille…"],"verb\u0004View":["Voir"],"{day} {month} {year}":["{day} {month} {year}"]} +{"":{"lang":"fr","plural-forms":"nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;"}," and ":[" et "]," or ":[" ou "],"\"%s\" activated.":["Type de contenu « %s » activé."],"\"%s\" deactivated.":["Type de contenu « %s » désactivé."],"\"%s\" has been restored.":["« %s » a été rétabli."],"\"%s\" moved to the trash.":["« %s » déplacé dans la corbeille."],"\"%s\" permanently deleted.":["Suppression définitive de « %s »."],"\"%s\" successfully created.":["« %s » créé avec succès."],"\"%s\" uploaded.":["« %s » téléversé."],"%(field)s address":["Adresse de %(field)s"],"%(field)s cannot move inside itself. Pick a container that is not one of its own.":["%(field)s ne peut pas être déplacé à l'intérieur de lui-même. Choisissez un conteneur qui n'est pas l'un des siens."],"%(field)s declared.":["%(field)s déclaré."],"%(field)s deleted.":["%(field)s supprimé."],"%(field)s does not match the format its field asks for. Check it and save again.":["%(field)s ne respecte pas le format demandé par son champ. Vérifiez-le et enregistrez de nouveau."],"%(field)s goes no higher than %(limit)s. Lower the value and save again.":["%(field)s ne dépasse pas %(limit)s. Baissez la valeur et enregistrez de nouveau."],"%(field)s goes no lower than %(limit)s. Raise the value and save again.":["%(field)s ne descend pas sous %(limit)s. Augmentez la valeur et enregistrez de nouveau."],"%(field)s holds no value of its own, so it cannot be required. Make it optional.":["%(field)s ne contient aucune valeur propre, il ne peut donc pas être obligatoire. Rendez-le facultatif."],"%(field)s is optional again.":["%(field)s est de nouveau facultatif."],"%(field)s is required now.":["%(field)s est maintenant obligatoire."],"%(field)s moved.":["%(field)s déplacé."],"%(field)s names the same one twice. Remove the repeat and save again.":["%(field)s nomme le même deux fois. Retirez la répétition et enregistrez de nouveau."],"%(field)s needs at least %(limit)s files. Add one and save again.":["%(field)s demande au moins %(limit)s fichiers. Ajoutez-en un et enregistrez."],"%(field)s needs at least %(limit)s rows. Add one and save again.":["%(field)s demande au moins %(limit)s lignes. Ajoutez-en une et enregistrez."],"%(field)s only takes one of its listed choices. Pick one and save again.":["%(field)s accepte uniquement un des choix de sa liste. Choisissez-en un et enregistrez de nouveau."],"%(field)s reads a relation field that is not there. Pick a group and a relation field it holds.":["%(field)s lit un champ de relation qui n'existe pas. Choisissez un groupe et un champ de relation qu'il contient."],"%(field)s reads a relation pointing at %(type)s, so it would list nothing here. Pick a relation pointing at the items this group is shown on.":["%(field)s lit une relation qui pointe vers %(type)s, elle n'afficherait donc rien ici. Choisissez une relation qui pointe vers les éléments où ce groupe s'affiche."],"%(field)s renamed.":["%(field)s renommé."],"%(field)s settled.":["%(field)s réglé."],"%(field)s shows by its rules now.":["%(field)s s'affiche désormais selon ses règles."],"%(field)s stands in the list.":["%(field)s figure dans la liste."],"%(field)s stays out of the list.":["%(field)s reste hors de la liste."],"%(field)s takes at most %(limit)s files. Remove one and save again.":["%(field)s accepte au plus %(limit)s fichiers. Retirez-en un et enregistrez."],"%(field)s takes at most %(limit)s letters. Shorten what you wrote and save again.":["%(field)s accepte au plus %(limit)s lettres. Raccourcissez ce que vous avez écrit et enregistrez de nouveau."],"%(field)s takes at most %(limit)s rows. Remove one and save again.":["%(field)s accepte au plus %(limit)s lignes. Retirez-en une et enregistrez."],"%(field)s title":["Titre de %(field)s"],"%(file)s is larger than one upload may be.":["%(file)s dépasse la taille maximale d'un téléversement."],"%(file)s is not a file this block accepts.":["%(file)s n'est pas un fichier que ce bloc accepte."],"%(file)s: %(reason)s":["%(file)s : %(reason)s"],"%(group)s created.":["%(group)s créé."],"%(group)s now appears where you said.":["%(group)s apparaît maintenant là où vous avez dit."],"%(group)s removed.":["%(group)s supprimé."],"%(group)s updated.":["%(group)s mis à jour."],"%(key)s is served by %(winner)s, so %(loser)s does not show it on %(where)s.":["%(key)s est servi par %(winner)s, donc %(loser)s ne le montre pas sur %(where)s."],"%(name)s %(version)s is serving the public site.":["%(name)s %(version)s sert le site public."],"%(name)s belongs to the site now.":["%(name)s appartient désormais au site."],"%(name)s came from the %(plugin)s plugin, which no longer declares it.":["%(name)s venait de l'extension %(plugin)s, qui ne le déclare plus."],"%(name)s cannot be used as a theme name. Name the zip file after the theme, with no dot at the start and no slashes in it.":["%(name)s ne peut pas servir de nom de thème. Nommez le fichier zip comme le thème, sans point au début et sans barre oblique."],"%(name)s moves to /%(word)s. Every address of both types changes.":["Le type %(name)s passe à /%(word)s. Toutes les adresses des deux types changent."],"%(name)s would move to /%(word)s, which Gophenberg keeps for itself. Change its plural name and try again.":["Le type %(name)s passerait à /%(word)s, que Gophenberg garde pour lui. Modifiez son nom au pluriel et réessayez."],"%(source)s is %(value)s":["%(source)s est %(value)s"],"%(theme)s failed to start, so the built-in renderer is serving.":["%(theme)s n'a pas pu démarrer, le moteur de rendu intégré sert donc le site."],"%(theme)s is now serving the public site.":["%(theme)s sert désormais le site public."],"%(theme)s is serving the public site.":["%(theme)s sert le site public."],"%(theme)s is still starting, so the built-in renderer is serving.":["%(theme)s démarre encore, le moteur de rendu intégré sert donc le site."],"%(theme)s was installed.":["%(theme)s a été installé."],"%(theme)s will not load, so the built-in renderer is serving.":["%(theme)s ne se charge pas, le moteur de rendu intégré sert donc le site."],"%(type)s answers on a new address, and stored links move with it.":["%(type)s répond à une nouvelle adresse, et les liens enregistrés la suivent."],"%(type)s becomes the type the site opens on, and the old one takes an address.":["%(type)s devient le type sur lequel le site ouvre, et l'ancien prend une adresse."],"%(type)s keeps nesting while %(items)s of its items sit inside another. Move those to the top level first, the ones in the trash included.":["%(type)s continue de s'imbriquer tant que %(items)s de ses éléments se trouvent dans un autre. Déplacez-les d'abord au niveau supérieur, ceux de la corbeille compris."],"%(type)s keeps nesting, because some of its items sit inside another.":["%(type)s continue de s'imbriquer, car certains de ses éléments se trouvent dans un autre."],"%(type)s will answer at the root.":["Le type %(type)s répondra à la racine."],"%s content type activated.":["%s type de contenu activé.","%s types de contenu activés.","%s types de contenu activés."],"%s content type could not be activated.":["%s type de contenu n'a pas pu être activé.","%s types de contenu n'ont pas pu être activés.","%s types de contenu n'ont pas pu être activés."],"%s content type could not be deactivated.":["%s type de contenu n'a pas pu être désactivé.","%s types de contenu n'ont pas pu être désactivés.","%s types de contenu n'ont pas pu être désactivés."],"%s content type could not be permanently deleted.":["%s type de contenu n'a pas pu être supprimé définitivement.","%s types de contenu n'ont pas pu être supprimés définitivement.","%s types de contenu n'ont pas pu être supprimés définitivement."],"%s content type deactivated.":["%s type de contenu désactivé.","%s types de contenu désactivés.","%s types de contenu désactivés."],"%s content type permanently deleted.":["%s type de contenu supprimé définitivement.","%s types de contenu supprimés définitivement.","%s types de contenu supprimés définitivement."],"%s item could not be moved to the trash.":["%s élément n'a pas pu être déplacé dans la corbeille.","%s éléments n'ont pas pu être déplacés dans la corbeille.","%s éléments n'ont pas pu être déplacés dans la corbeille."],"%s item could not be permanently deleted.":["%s élément n'a pas pu être supprimé définitivement.","%s éléments n'ont pas pu être supprimés définitivement.","%s éléments n'ont pas pu être supprimés définitivement."],"%s item could not be restored.":["%s élément n'a pas pu être rétabli.","%s éléments n'ont pas pu être rétablis.","%s éléments n'ont pas pu être rétablis."],"%s item moved to the trash.":["%s élément déplacé dans la corbeille.","%s éléments déplacés dans la corbeille.","%s éléments déplacés dans la corbeille."],"%s item permanently deleted.":["%s élément supprimé définitivement.","%s éléments supprimés définitivement.","%s éléments supprimés définitivement."],"%s item stays in the trash.":["%s élément reste dans la corbeille.","%s éléments restent dans la corbeille.","%s éléments restent dans la corbeille."],"%s page has been restored.":["%s page a été rétablie.","%s pages ont été rétablies.","%s pages ont été rétablies."],"%s post has been restored.":["%s article a été rétabli.","%s articles ont été rétablis.","%s articles ont été rétablis."],"(no title)":["(pas de titre)"],"A %(kind)s field does not take a setting called %(setting)s. Remove it and try again.":["Un champ %(kind)s n'accepte pas de réglage nommé %(setting)s. Retirez-le et réessayez."],"A Linked from field stands on its own, not inside a section or a repeater. Declare %(field)s beside the container.":["Un champ Lié depuis se place seul, ni dans une section ni dans un répéteur. Déclarez %(field)s à côté du conteneur."],"A content type needs a singular and a plural name, like Guide and Guides. Fill both in and try again.":["Un type de contenu a besoin d'un nom au singulier et d'un nom au pluriel, comme Guide et Guides. Remplissez les deux et réessayez."],"A descriptive summary of the content type.":["Un résumé descriptif du type de contenu."],"A field group key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the group in the file so its key comes out in that shape.":["La clé d'un groupe de champs doit commencer par une lettre minuscule puis ne contenir que des lettres minuscules, des chiffres et des tirets. Renommez le groupe dans le fichier pour que sa clé prenne cette forme."],"A field key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Rename the field so its key comes out in that shape.":["La clé d'un champ doit commencer par une lettre minuscule et ne contenir ensuite que des lettres minuscules, des chiffres et des tirets. Renommez le champ pour que sa clé prenne cette forme."],"A field needs a name. Type one and try again.":["Un champ a besoin d'un nom. Saisissez-en un et réessayez."],"A field with that name already exists on the items this group reaches. Pick another name.":["Un champ portant ce nom existe déjà sur les éléments que ce groupe atteint. Choisissez un autre nom."],"A flexible content field holds layouts, and %(field)s is not one. Add a layout first, then declare the field inside that layout.":["Un champ de contenu flexible contient des dispositions, et %(field)s n'en est pas une. Ajoutez d'abord une disposition, puis déclarez le champ dans cette disposition."],"A higher quality stores larger files, and it applies to pictures uploaded from now on.":["Une qualité plus élevée stocke des fichiers plus grands, et elle s'applique aux images téléversées à partir de maintenant."],"A layout stands inside a flexible content field only. Put %(field)s inside one of those instead.":["Une disposition ne se place que dans un champ de contenu flexible. Placez %(field)s dans l'un d'eux."],"A layout stands inside a flexible content field, never on its own. Declare a flexible content field first, then add %(field)s inside it.":["Une disposition se place dans un champ de contenu flexible, jamais seule. Déclarez d'abord un champ de contenu flexible, puis ajoutez %(field)s dedans."],"A listing carries between 1 and %(max)s items, so %(value)s is not a size Gophenberg takes. Pick a number inside that range.":["Une liste affiche entre 1 et %(max)s éléments, donc %(value)s n'est pas une taille que Gophenberg accepte. Choisissez un nombre dans cet intervalle."],"A relation field has to say which content type it points at. Pick that type and try again.":["Un champ de relation doit indiquer le type de contenu qu'il vise. Choisissez ce type et réessayez."],"A reload shows the admin in the language you chose.":["Un rechargement affiche l'administration dans la langue que vous avez choisie."],"A route word has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change it and try again.":["Un mot de route doit commencer par une lettre minuscule et ne contenir ensuite que des lettres minuscules, des chiffres et des tirets. Modifiez-le et réessayez."],"A row of %(field)s has to name the layout it takes. Pick a layout for the row, or remove the row.":["Une ligne de %(field)s doit nommer la disposition qu'elle prend. Choisissez une disposition pour la ligne, ou retirez la ligne."],"A row of %(field)s takes one layout and no more. Reload the page and build the row again.":["Une ligne de %(field)s prend une seule disposition. Rechargez la page et reconstruisez la ligne."],"A rule matching any %(source)s cannot be turned into an exclusion. Leave it as is, or name the ones to leave out instead.":["Une règle valable pour n'importe quel %(source)s ne peut pas devenir une exclusion. Laissez-la telle quelle, ou nommez ceux à écarter."],"A theme naming its own page size uses that instead.":["Un thème qui indique sa propre taille de page utilise celle-ci à la place."],"A type key has to start with a lowercase letter and then hold only lowercase letters, numbers and hyphens. Change the key and try again.":["La clé d'un type doit commencer par une lettre minuscule et ne contenir ensuite que des lettres minuscules, des chiffres et des tirets. Modifiez la clé et réessayez."],"Actions":["Actions"],"Activate":["Activer"],"Activate %(theme)s":["Activer %(theme)s"],"Active":["Actif"],"Add":["Ajouter"],"Add %(layout)s":["Ajouter %(layout)s"],"Add %(name)s":["Ajouter %(name)s"],"Add %(type)s":["Ajouter %(type)s"],"Add Category":["Ajouter une catégorie"],"Add Content Type":["Ajouter un type de contenu"],"Add Field Group":["Ajouter un groupe de champs"],"Add Media File":["Ajouter un fichier média"],"Add Page":["Ajouter une page"],"Add Post":["Ajouter un article"],"Add User":["Ajouter un utilisateur"],"Add block":["Ajouter un bloc"],"Add choice":["Ajouter un choix"],"Add condition":["Ajouter une condition"],"Add field":["Ajouter un champ"],"Add field to %(field)s":["Ajouter un champ à %(field)s"],"Add layout":["Ajouter une disposition"],"Add layout to %(field)s":["Ajouter une disposition à %(field)s"],"Add one with %(button)s.":["Ajoutez un élément avec %(button)s."],"Add row":["Ajouter une ligne"],"Add rule set":["Ajouter un ensemble de règles"],"Add title":["Ajouter un titre"],"Add to %(field)s":["Ajouter à %(field)s"],"Address":["Adresse"],"Address updated.":["Adresse mise à jour."],"Adopt":["Adopter"],"Allow custom":["Autoriser les valeurs libres"],"Allow empty":["Autoriser le vide"],"Alt text":["Texte alternatif"],"An item cannot point at itself. Pick a different item for %(field)s.":["Un élément ne peut pas se viser lui-même. Choisissez un autre élément pour %(field)s."],"An item cannot sit inside itself, or inside one of the items nested under it. Pick a parent outside this branch.":["Un élément ne peut pas se trouver dans lui-même, ni dans un élément imbriqué sous lui. Choisissez un parent hors de cette branche."],"Another account wrote this, and only its author, an editor or an administrator may change it. You can still read it.":["Un autre compte a écrit ceci, et seuls son auteur, un éditeur ou un administrateur peuvent le modifier. Vous pouvez toujours le lire."],"Another account wrote this, so you are reading it rather than editing it.":["Un autre compte a écrit ceci, alors vous le lisez au lieu de le modifier."],"Another content type already answers under this route word. Pick a different word.":["Un autre type de contenu répond déjà sous ce mot de route. Choisissez un autre mot."],"Another content type already lives at the root of your site, and only one can. Give this type a route word, or hand the root over with Make default.":["Un autre type de contenu occupe déjà la racine de votre site, et un seul le peut. Donnez un mot de route à ce type, ou cédez la racine avec Rendre par défaut."],"Another content type already uses this key. Give this type a different name.":["Un autre type de contenu utilise déjà cette clé. Donnez un autre nom à ce type."],"Another item of this content type already uses that address. Pick a different slug and save again.":["Un autre élément de ce type de contenu utilise déjà cette adresse. Choisissez un autre slug et enregistrez de nouveau."],"Another update is currently in progress.":["Une autre mise à jour est actuellement en cours."],"Appears on":["Apparaît sur"],"Apply":["Appliquer"],"April":["avril"],"Are you sure you want to move \"%s\" to the trash?":["Voulez-vous vraiment déplacer « %s » dans la corbeille ?"],"Audio":["Audio"],"August":["août"],"Author":["Auteur/autrice"],"Back to %(type)s":["Retour aux %(type)s"],"Back to the latest posts":["Retour aux derniers articles"],"Block":["Bloc"],"Broken":["Cassé"],"Bundles of fields, shown where their rules place them.":["Ensembles de champs, affichés là où leurs règles les placent."],"Button group":["Groupe de boutons"],"Cancel":["Annuler"],"Caption":["Légende"],"Change %(name)s":["Modifier %(name)s"],"Change address":["Changer l'adresse"],"Change address…":["Changer l'adresse…"],"Change role":["Changer le rôle"],"Change the role of %(name)s":["Changer le rôle de %(name)s"],"Checkbox group":["Groupe de cases à cocher"],"Choices":["Choix"],"Choose %(field)s":["Choisir %(field)s"],"Choose how the site serves its content.":["Choisissez comment le site sert son contenu."],"Choose the language the admin reads in.":["Choisissez la langue dans laquelle se lit l'administration."],"Choose the role of %(name)s.":["Choisissez le rôle de %(name)s."],"Choose the version to update to.":["Choisissez la version vers laquelle mettre à jour."],"Clear %(field)s":["Vider %(field)s"],"Close":["Fermer"],"Close Settings":["Fermer les réglages"],"Closed":["Fermé"],"Condition":["Condition"],"Content":["Contenu"],"Content Types":["Types de contenu"],"Content nests at most %(max)s levels deep, and this item would sit deeper. Pick a parent closer to the top.":["Le contenu s'imbrique sur au plus %(max)s niveaux, et cet élément irait plus profond. Choisissez un parent plus proche du sommet."],"Content type":["Type de contenu"],"Content type updated.":["Type de contenu mis à jour."],"Could not load that post.":["Impossible de charger cet article."],"Could not save image. %s":["Impossible d’enregistrer le média. %s"],"Could not save media. %s":["Impossible d’enregistrer le média. %s"],"Could not save that post.":["Impossible d'enregistrer cet article."],"Create":["Créer"],"Create a field group":["Créer un groupe de champs"],"Create user":["Créer l'utilisateur"],"Created":["Date de création"],"Date added":["Date d’ajout"],"Date modified":["Date de modification"],"Deactivate":["Désactiver"],"Deactivate %(theme)s":["Désactiver %(theme)s"],"December":["décembre"],"Default":["Par défaut"],"Default content type updated.":["Type de contenu par défaut mis à jour."],"Definitions file":["Fichier de définitions"],"Delete":["Supprimer"],"Delete \"%(title)s\" for good? This cannot be undone.":["Supprimer « %(title)s » définitivement ? Cette action est irréversible."],"Delete %(field)s":["Supprimer %(field)s"],"Delete %(group)s":["Supprimer %(group)s"],"Delete %s content type for good? This cannot be undone.":["Supprimer %s type de contenu définitivement ? Cette action est irréversible.","Supprimer %s types de contenu définitivement ? Cette action est irréversible.","Supprimer %s types de contenu définitivement ? Cette action est irréversible."],"Delete %s item for good? This cannot be undone.":["Supprimer %s élément définitivement ? Cette action est irréversible.","Supprimer %s éléments définitivement ? Cette action est irréversible.","Supprimer %s éléments définitivement ? Cette action est irréversible."],"Delete permanently":["Supprimer définitivement"],"Delete the field":["Supprimer le champ"],"Delete the group":["Supprimer le groupe"],"Delete…":["Supprimer…"],"Describe":["Décrire"],"Describe the purpose of the image.":["Décrivez la fonction de l’image."],"Describe…":["Décrire…"],"Description":["Description"],"Description updated.":["Description mise à jour."],"Desktop":["Ordinateur"],"Destination":["Destination"],"Dimensions":["Dimensions"],"Disable":["Désactiver"],"Disable %(name)s":["Désactiver %(name)s"],"Disabled":["Désactivé"],"Dismiss":["Ignorer"],"Document":["Document"],"Documents":["Documents"],"Done. The site now holds what the file describes.":["Terminé. Le site contient maintenant ce que décrit le fichier."],"Draft saved.":["Brouillon enregistré."],"Drop files to upload":["Déposez vos fichiers pour les téléverser"],"Edit media":["Modifier le média"],"Edit…":["Modifier…"],"Email":["E-mail"],"Empty Trash":["Vider la corbeille"],"Empty trash":["Vider la corbeille"],"Enable":["Activer"],"Enable %(name)s":["Activer %(name)s"],"Every address of this type moves. Links kept elsewhere to the old addresses stop working.":["Toutes les adresses de ce type changent. Les liens conservés ailleurs vers les anciennes adresses cessent de fonctionner."],"Every content type":["Tous les types de contenu"],"Every field group needs a title. Give it a name and save again.":["Chaque groupe de champs a besoin d'un titre. Donnez-lui un nom et enregistrez à nouveau."],"Every item needs an author who exists. Pick one from the list and save again.":["Chaque élément a besoin d'un auteur existant. Choisissez-en un dans la liste et enregistrez de nouveau."],"Every kind of content this site holds.":["Toutes les sortes de contenu que ce site contient."],"Every value stored under this field goes with it, in every item the group reaches and in the revisions behind them.":["Toutes les valeurs enregistrées dans ce champ partent avec lui, dans chaque élément que le groupe atteint et dans les révisions qui les suivent."],"Excerpt":["Extrait"],"Export definitions":["Exporter les définitions"],"February":["février"],"Fewest files":["Minimum de fichiers"],"Fewest rows":["Minimum de lignes"],"Field":["Champ"],"Field Groups":["Groupes de champs"],"Field group":["Groupe de champs"],"Fields":["Champs"],"Fields of %(group)s":["Champs de %(group)s"],"File name":["Nom de fichier"],"File size":["Taille du fichier"],"File type":["Type de fichier"],"Filter by status":["Filtrer par état"],"Follow the browser":["Suivre le navigateur"],"From %(plugin)s":["De %(plugin)s"],"Full Size":["Taille originale"],"Gophenberg cannot start this update: %(reason)s":["Gophenberg ne peut pas lancer cette mise à jour : %(reason)s"],"Gophenberg checked for updates moments ago. Try again later.":["Gophenberg a cherché des mises à jour il y a un instant. Réessayez plus tard."],"Gophenberg could not read what your browser sent. Reload the page and try again, and tell whoever looks after your site if it keeps happening.":["Gophenberg n'a pas pu lire ce que votre navigateur a envoyé. Rechargez la page et réessayez, et prévenez la personne qui s'occupe de votre site si cela continue."],"Gophenberg could not read which item you picked as parent. Reload the page and pick it again.":["Gophenberg n'a pas pu lire quel élément vous avez choisi comme parent. Rechargez la page et choisissez-le de nouveau."],"Gophenberg does not accept %(extension)s files. Upload a picture, a video, an audio file, a PDF or a zip instead.":["Gophenberg n'accepte pas les fichiers %(extension)s. Téléversez plutôt une image, une vidéo, un fichier audio, un PDF ou un zip."],"Gophenberg does not answer in %(value)s. Pick one of the languages on the list.":["Gophenberg ne répond pas en %(value)s. Choisissez une des langues de la liste."],"Gophenberg has no autosave of yours for this item. Open the item to see the version that is stored.":["Gophenberg n'a aucun enregistrement automatique de vous pour cet élément. Ouvrez l'élément pour voir la version stockée."],"Gophenberg holds no kind of definition called %(subject)s, so nothing was taken over. Reload the page so the admin and the server agree again.":["Gophenberg ne connaît aucune sorte de définition nommée %(subject)s, rien n'a donc été repris. Rechargez la page pour que l'admin et le serveur se rejoignent à nouveau."],"Gophenberg keeps /%(path)s for itself or for another content type, so your content cannot answer there. Give the item a different slug.":["Gophenberg garde l'adresse /%(path)s pour lui ou pour un autre type de contenu, votre contenu ne peut donc pas y répondre. Donnez un autre slug à l'élément."],"Gophenberg keeps this word for itself, so no content type can answer under it. Pick a different route word.":["Gophenberg garde ce mot pour lui, aucun type de contenu ne peut donc répondre sous ce mot. Choisissez un autre mot de route."],"Group":["Groupe"],"Highest":["Maximum"],"Holds":["Contient"],"Images":["Images"],"Import definitions":["Importer les définitions"],"In list":["Dans la liste"],"Inactive":["Inactif"],"Installed":["Installé"],"Instructions":["Instructions"],"It moves here from another group and keeps its stored values.":["Il arrive ici depuis un autre groupe et garde ses valeurs enregistrées."],"It moves to another group and keeps its stored values.":["Il passe dans un autre groupe et garde ses valeurs enregistrées."],"It moves to another group, so its stored values do not follow.":["Il passe dans un autre groupe, donc ses valeurs enregistrées ne le suivent pas."],"It reads a relation inside a container, and only an import can point it elsewhere.":["Il lit une relation dans un conteneur, et seule une importation peut le faire pointer ailleurs."],"It starts on %(type)s, and the rules may be edited afterwards.":["Il commence sur %(type)s, et les règles peuvent être modifiées ensuite."],"Items could not be loaded.":["Les éléments n'ont pas pu être chargés."],"Its items sit inside one another, so it keeps nesting.":["Ses éléments se trouvent les uns dans les autres, donc il continue de s'imbriquer."],"Its kind changed, so its stored values go with it.":["Son type a changé, donc ses valeurs enregistrées partent avec lui."],"January":["janvier"],"July":["juillet"],"June":["juin"],"Keep %(field)s out of the list":["Garder %(field)s hors de la liste"],"Keep it":["Le conserver"],"Keep it here":["Le laisser ici"],"Key":["Clé"],"Label":["Libellé"],"Language":["Langue"],"Large":["Grande"],"Leave empty if decorative.":["Laissez vide si l’image est décorative"],"Let items nest":["Autoriser l'imbrication"],"Linked from":["Lié depuis"],"List view":["Vue en liste"],"Loading field groups.":["Chargement des groupes de champs."],"Loading the post.":["Chargement de l'article."],"Loading the screen.":["Chargement de l'écran."],"Loading themes.":["Chargement des thèmes."],"Loading users.":["Chargement des utilisateurs."],"Loading…":["Chargement…"],"Longest":["Longueur maximale"],"Lowest":["Minimum"],"Make %(field)s optional":["Rendre %(field)s facultatif"],"Make default":["Rendre par défaut"],"Make default…":["Rendre par défaut…"],"Make optional":["Rendre facultatif"],"Manage the categories on this site.":["Gérez les catégories de ce site."],"Manage the images and files this site holds.":["Gérez les images et les fichiers que ce site contient."],"Manage the items of this content type.":["Gérez les éléments de ce type de contenu."],"Manage the pages on this site.":["Gérez les pages de ce site."],"Manage the posts on this site.":["Gérez les articles de ce site."],"Many targets":["Plusieurs cibles"],"Many values":["Plusieurs valeurs"],"March":["mars"],"May":["mai"],"Media %(id)s":["Média %(id)s"],"Media Library":["Médiathèque"],"Media file updated.":["Fichier média mis à jour."],"Medium":["Moyenne"],"Mobile":["Mobile"],"Modified: %s":["Modifié : %s"],"Most files":["Maximum de fichiers"],"Most rows":["Maximum de lignes"],"Move":["Déplacer"],"Move \"%(title)s\" to the trash?":["Déplacer « %(title)s » dans la corbeille ?"],"Move %(field)s down":["Descendre %(field)s"],"Move %(field)s elsewhere":["Déplacer %(field)s ailleurs"],"Move %(field)s up":["Monter %(field)s"],"Move %(group)s down":["Descendre %(group)s"],"Move %(group)s up":["Monter %(group)s"],"Move %(name)s down":["Descendre %(name)s"],"Move %(name)s up":["Monter %(name)s"],"Move %s item to the trash?":["Déplacer %s élément dans la corbeille ?","Déplacer %s éléments dans la corbeille ?","Déplacer %s éléments dans la corbeille ?"],"Move every address":["Déplacer toutes les adresses"],"Move row down":["Descendre la ligne"],"Move row up":["Monter la ligne"],"Move the field":["Déplacer le champ"],"Move to trash":["Déplacer dans la corbeille"],"Name":["Nom"],"Name updated.":["Nom mis à jour."],"Nests":["Imbriqué"],"Newer posts":["Articles plus récents"],"No":["Non"],"No content types found.":["Aucun type de contenu trouvé."],"No description":["Aucune description"],"No file arrived with this upload. Choose a file and try again.":["Aucun fichier n'est arrivé avec ce téléversement. Choisissez un fichier et réessayez."],"No items found.":["Aucun élément trouvé."],"No items yet.":["Aucun élément pour l'instant."],"No media chosen":["Aucun média choisi"],"No media has been uploaded yet.":["Aucun média n'a encore été téléversé."],"No media items found.":["Aucun média n’a été trouvé."],"No media items yet.":["Aucun fichier média pour l’instant."],"No page lives at this address.":["Aucune page n'existe à cette adresse."],"No parent":["Pas de parent"],"No theme arrived with this upload. Choose a zip file and try again.":["Aucun thème n'est arrivé avec ce téléversement. Choisissez un fichier zip et réessayez."],"No themes are installed.":["Aucun thème n'est installé."],"No title":["Aucun titre"],"None":["Aucun"],"Not %(type)s":["Sauf %(type)s"],"Not found":["Introuvable"],"Not serving":["Hors service"],"Nothing declares that role.":["Rien ne déclare ce rôle."],"Nothing points here yet.":["Rien ne pointe encore ici."],"Nothing published here yet.":["Rien n'est encore publié ici."],"Nothing published yet.":["Rien n'est encore publié."],"November":["novembre"],"Nowhere":["Nulle part"],"Number":["Nombre"],"October":["octobre"],"Older posts":["Articles plus anciens"],"One frame of this animation holds more than %(max)s pixels, which is more than Gophenberg opens. Upload a smaller animation.":["Une image de cette animation dépasse %(max)s pixels, plus que ce que Gophenberg ouvre. Téléversez une animation plus petite."],"One of the filters on this list is not one Gophenberg understands. Clear the filters and try again.":["Un des filtres de cette liste n'est pas un filtre que Gophenberg comprend. Videz les filtres et réessayez."],"One of the items picked for %(field)s is not the kind of content that field points at. Reload the page and pick from the list the field offers.":["Un des éléments choisis pour %(field)s n'est pas le genre de contenu que vise ce champ. Rechargez la page et choisissez dans la liste que le champ propose."],"One of the items picked for %(field)s was not named in a way Gophenberg understands. Reload the page and pick the items again.":["Un des éléments choisis pour %(field)s n'était pas nommé d'une façon que Gophenberg comprend. Rechargez la page et choisissez de nouveau les éléments."],"One of the items picked no longer exists. Reload the page and pick again.":["Un des éléments choisis n'existe plus. Rechargez la page et choisissez de nouveau."],"One target":["Une cible"],"Only a relation field points at another content type or holds many items. Pick the Relation kind, or leave the target and the many option empty.":["Seul un champ de relation vise un autre type de contenu ou contient plusieurs éléments. Choisissez le genre Relation, ou laissez vides la cible et l'option plusieurs."],"Only one file may be placed here.":["Un seul fichier peut être placé ici."],"Only the content type at the root of your site may go without a route word. Give this type one and try again.":["Seul le type de contenu à la racine de votre site peut se passer de mot de route. Donnez-en un à ce type et réessayez."],"Open %(field)s in a new tab":["Ouvrir %(field)s dans un nouvel onglet"],"Order stored.":["Ordre enregistré."],"Other":["Autre"],"Out of list":["Hors de la liste"],"Parent":["Parent"],"Password":["Mot de passe"],"Pending":["En attente"],"Pending Review":["En attente de relecture"],"Permanently delete…":["Supprimer définitivement…"],"Picture quality":["Qualité d'image"],"Picture quality runs from 1 to %(max)s, so %(value)s is not a quality Gophenberg stores at. Pick a number inside that range.":["La qualité d'image va de 1 à %(max)s, donc %(value)s n'est pas une qualité avec laquelle Gophenberg enregistre. Choisissez un nombre dans cet intervalle."],"Placeholder":["Texte indicatif"],"Plural name":["Nom au pluriel"],"Points at":["Vise"],"Post published.":["Article publié."],"Posts per page":["Articles par page"],"Preview":["Prévisualiser"],"Private":["Privé"],"Publish":["Publier"],"Published: %s":["Publié : %s"],"Radio group":["Groupe de boutons radio"],"Reads from":["Lit depuis"],"Redo":["Rétablir"],"Relation":["Relation"],"Remove":["Retirer"],"Remove %(name)s":["Retirer %(name)s"],"Remove %(title)s":["Retirer %(title)s"],"Remove row":["Retirer la ligne"],"Rename":["Renommer"],"Rename %(field)s":["Renommer %(field)s"],"Rename…":["Renommer…"],"Require":["Rendre obligatoire"],"Require %(field)s":["Rendre %(field)s obligatoire"],"Required":["Obligatoire"],"Restore":["Rétablir"],"Role changed.":["Rôle modifié."],"Roll back to %(theme)s":["Revenir à %(theme)s"],"Roll back to the built-in renderer":["Revenir au moteur de rendu intégré"],"Route word":["Mot de route"],"Rule %(number)s of set %(set)s":["Règle %(number)s de l'ensemble %(set)s"],"Rule set %(number)s":["Ensemble de règles %(number)s"],"Rules":["Règles"],"Rules showing %(field)s":["Règles affichant %(field)s"],"Save":["Enregistrer"],"Save draft":["Enregistrer le brouillon"],"Save rules":["Enregistrer les règles"],"Save settings":["Enregistrer les réglages"],"Saved":["Enregistré"],"Saving":["Enregistrement"],"Scheduled":["Planifié"],"Scheduled: %s":["Planifié : %s"],"Search %(type)s…":["Rechercher des %(type)s…"],"Search audio":["Rechercher un audio"],"Search images":["Rechercher des images"],"Search media":["Rechercher des médias"],"Search videos":["Rechercher des vidéos"],"Select":["Sélectionner"],"September":["septembre"],"Serving":["En service"],"Settings":["Réglages"],"Settings of %(field)s":["Réglages de %(field)s"],"Shadowed":["Masqué"],"Show %(field)s in the list":["Afficher %(field)s dans la liste"],"Showing %(held)s of %(total)s.":["Affichage de %(held)s sur %(total)s."],"Singular name":["Nom au singulier"],"Slug":["Slug"],"Somebody must be able to manage users.":["Quelqu'un doit pouvoir gérer les utilisateurs."],"Someone else changed this media item while you were editing it. Reload the page and make your change again.":["Quelqu'un d'autre a modifié ce média pendant que vous le modifiiez. Rechargez la page et refaites votre modification."],"Someone else saved changes to this item while you were editing, so yours were not saved. Copy your text somewhere safe, then reload the item and put your changes back in.":["Quelqu'un d'autre a enregistré des modifications sur cet élément pendant que vous éditiez, les vôtres n'ont donc pas été enregistrées. Copiez votre texte en lieu sûr, puis rechargez l'élément et remettez vos modifications."],"Something went wrong inside Gophenberg. Try again in a moment, and tell whoever looks after your site if it keeps happening.":["Quelque chose a mal tourné dans Gophenberg. Réessayez dans un instant, et prévenez la personne qui s'occupe de votre site si cela continue."],"Something went wrong.":["Quelque chose a mal tourné."],"Something went wrong. Try again.":["Quelque chose a mal tourné. Réessayez."],"Sorry, you are not allowed to access this page.":["Désolé, vous n’avez pas l’autorisation d’accéder à cette page."],"Source":["Source"],"Steps of":["Pas de"],"Stop nesting":["Arrêter l'imbrication"],"Tablet":["Tablette"],"Text":["Texte"],"Text area":["Zone de texte"],"That email is already in use.":["Cet e-mail est déjà utilisé."],"That field group no longer exists. Reload the page to see the groups your site holds now.":["Ce groupe de champs n'existe plus. Rechargez la page pour voir les groupes que votre site contient maintenant."],"That field is not on this content type any more. Reload the page to see the fields it has now.":["Ce champ n'est plus sur ce type de contenu. Rechargez la page pour voir les champs qu'il possède maintenant."],"That revision does not exist, and older ones drop off once a type holds more than it keeps. Open the item to see which revisions are left.":["Cette révision n'existe pas, et les plus anciennes disparaissent quand un type en détient plus qu'il n'en conserve. Ouvrez l'élément pour voir les révisions restantes."],"That role holds more than your own.":["Ce rôle va au-delà du vôtre."],"That web address does not name a media item Gophenberg can look up. Open the media library and pick the item from there.":["Cette adresse web ne désigne aucun média que Gophenberg puisse trouver. Ouvrez la bibliothèque de médias et choisissez l'élément depuis là."],"That web address does not name a revision Gophenberg can look up. Open the item and pick a revision from its history.":["Cette adresse web ne désigne aucune révision que Gophenberg puisse trouver. Ouvrez l'élément et choisissez une révision dans son historique."],"That web address does not name an item Gophenberg can look up. Go back to the list and open the item from there.":["Cette adresse web ne désigne aucun élément que Gophenberg puisse trouver. Revenez à la liste et ouvrez l'élément depuis là."],"The %(count)s item in the trash is deleted for good. This cannot be undone.":["%(count)s élément de la corbeille est supprimé définitivement. Cette action est irréversible.","Les %(count)s éléments de la corbeille sont supprimés définitivement. Cette action est irréversible.","Les %(count)s éléments de la corbeille sont supprimés définitivement. Cette action est irréversible."],"The %(file)s calls this theme %(declared)s, but it is being installed as %(installed)s. Rename the zip file to match, or change the name inside %(file)s.":["Le fichier %(file)s appelle ce thème %(declared)s, mais il s'installe sous %(installed)s. Renommez le fichier zip pour qu'ils correspondent, ou changez le nom dans %(file)s."],"The %(file)s inside %(name)s is not valid JSON, so it could not be read. Fix that file and package the theme again.":["Le fichier %(file)s dans %(name)s n'est pas du JSON valide, il n'a donc pas pu être lu. Corrigez ce fichier et réempaquetez le thème."],"The %(file)s inside %(name)s names its theme kit as %(declared)s, which is not a version number. Build the theme again, and the build will fill that in.":["Le %(file)s dans %(name)s indique son kit de thèmes comme %(declared)s, ce qui n'est pas un numéro de version. Compilez le thème à nouveau et la compilation le renseignera."],"The %(origin)s plugin declared this, so it can only be changed in that plugin. Turning it off is still yours to do.":["L'extension %(origin)s l'a déclaré, donc il ne peut être modifié que dans cette extension. Le désactiver reste entre vos mains."],"The %(plugin)s plugin also declares %(name)s, and the site keeps the key.":["L'extension %(plugin)s déclare aussi %(name)s, et le site garde la clé."],"The %(plugin)s plugin declared these fields, so they change only in its code.":["L'extension %(plugin)s a déclaré ces champs, donc ils ne changent que dans son code."],"The %(plugin)s plugin declares %(name)s flat, and the site keeps it nesting while its items sit inside another.":["L'extension %(plugin)s déclare %(name)s sans imbrication, et le site continue de l'imbriquer tant que ses éléments se trouvent dans un autre."],"The browser kept an unsaved version of this post.":["Le navigateur a gardé une version non enregistrée de cet article."],"The built-in renderer is now serving the public site.":["Le moteur de rendu intégré sert désormais le site public."],"The built-in renderer is serving the public site.":["Le moteur de rendu intégré sert le site public."],"The content type this field points at is not named in a shape Gophenberg accepts. Give it a key that starts with a lowercase letter and then holds only lowercase letters, numbers and hyphens.":["Le type de contenu que vise ce champ n'est pas nommé sous une forme que Gophenberg accepte. Donnez-lui une clé qui commence par une lettre minuscule et ne contient ensuite que des lettres minuscules, des chiffres et des tirets."],"The content types could not be loaded, so where these groups appear and which fields they shadow are not shown.":["Les types de contenu n'ont pas pu être chargés, donc où ces groupes apparaissent et quels champs ils masquent ne sont pas affichés."],"The content types could not be loaded.":["Les types de contenu n'ont pas pu être chargés."],"The content types could not be refreshed, so where these groups appear and what they shadow may be out of date.":["Les types de contenu n'ont pas pu être actualisés, donc où ces groupes apparaissent et ce qu'ils masquent peut être obsolète."],"The draft could not be created.":["Le brouillon n'a pas pu être créé."],"The entry %(entry)s in this zip would be written outside the theme folder, so nothing was installed. Package the theme again.":["L'entrée %(entry)s de ce zip s'écrirait hors du dossier du thème, rien n'a donc été installé. Réempaquetez le thème."],"The field %(field)s cannot go, because %(by)s reads it. Change %(by)s first.":["Le champ %(field)s ne peut pas être retiré, car %(by)s le lit. Modifiez d'abord %(by)s."],"The field %(field)s cannot hold the value it was given. Check what kind of field it is and enter a value that matches.":["Le champ %(field)s ne peut pas contenir la valeur qui lui a été donnée. Vérifiez de quel genre de champ il s'agit et saisissez une valeur qui convient."],"The field %(field)s has to hold a value before this item can be published. Fill it in, or leave the item as a draft.":["Le champ %(field)s doit contenir une valeur avant que cet élément puisse être publié. Remplissez-le, ou laissez l'élément en brouillon."],"The field %(field)s in %(group)s still points at this content type. Delete that field first, then delete this type.":["Le champ %(field)s dans %(group)s vise encore ce type de contenu. Supprimez d'abord ce champ, puis supprimez ce type."],"The field %(field)s is hidden by its own rules right now, so it cannot hold a value. Show it again before filling it in.":["Le champ %(field)s est masqué par ses propres règles pour le moment, il ne peut donc contenir aucune valeur. Affichez-le de nouveau avant de le remplir."],"The field %(field)s points at one item only. Remove the extra choices and keep one.":["Le champ %(field)s ne vise qu'un seul élément. Retirez les choix en trop et gardez-en un."],"The field %(field)s points at other items, so it does not take a plain value. Pick the items it points at instead.":["Le champ %(field)s vise d'autres éléments, il n'accepte donc pas une valeur simple. Choisissez plutôt les éléments qu'il vise."],"The field %(field)s takes a list of items, and what arrived was not a list. Reload the page and pick the items again.":["Le champ %(field)s attend une liste d'éléments, et ce qui est arrivé n'était pas une liste. Rechargez la page et choisissez de nouveau les éléments."],"The field %(source)s cannot hold the value %(value)s, so no rule can compare it. Write a value of the kind %(source)s stores.":["Le champ %(source)s ne peut pas contenir la valeur %(value)s, aucune règle ne peut donc la comparer. Écrivez une valeur du type que %(source)s enregistre."],"The field groups could not be loaded.":["Les groupes de champs n'ont pas pu être chargés."],"The field groups could not be reached.":["Les groupes de champs n'ont pas pu être joints."],"The field keeps its values on the content both groups reach.":["Le champ garde ses valeurs sur le contenu que les deux groupes atteignent."],"The fields this group holds go with it, and so do the values every item stored in them.":["Les champs que ce groupe contient partent avec lui, ainsi que les valeurs que chaque élément y a enregistrées."],"The groups were reordered.":["Les groupes ont été réordonnés."],"The item could not be duplicated.":["L'élément n'a pas pu être dupliqué."],"The item could not be moved to the trash.":["L'élément n'a pas pu être déplacé dans la corbeille."],"The item could not be permanently deleted.":["L'élément n'a pas pu être supprimé définitivement."],"The item could not be restored.":["L'élément n'a pas pu être rétabli."],"The item you picked as parent holds another kind of content. Pick a parent of the same content type as this item.":["L'élément que vous avez choisi comme parent contient un autre genre de contenu. Choisissez un parent du même type de contenu que cet élément."],"The item you picked as parent is in the trash. Restore it, or pick another parent.":["L'élément que vous avez choisi comme parent est dans la corbeille. Rétablissez-le, ou choisissez un autre parent."],"The item you picked as parent no longer exists. Reload the page and pick another parent.":["L'élément que vous avez choisi comme parent n'existe plus. Rechargez la page et choisissez un autre parent."],"The media library could not be loaded.":["La bibliothèque de médias n'a pas pu être chargée."],"The name changes. Nothing stored under this field moves.":["Le nom change. Rien de ce qui est stocké sous ce champ ne bouge."],"The name could not be updated.":["Le nom n'a pas pu être mis à jour."],"The new order has to name every field group exactly once. Reload the page and move the groups again.":["Le nouvel ordre doit nommer chaque groupe de champs exactement une fois. Rechargez la page et déplacez les groupes à nouveau."],"The new order has to name every field of this content type exactly once. Reload the page and move the fields again.":["Le nouvel ordre doit nommer chaque champ de ce type de contenu exactement une fois. Rechargez la page et déplacez les champs à nouveau."],"The number of revisions to keep has to be a whole number, and it cannot be negative. Enter a different number.":["Le nombre de révisions à conserver doit être un nombre entier, et il ne peut pas être négatif. Saisissez un autre nombre."],"The registry could not be reached.":["Le registre n'a pas pu être joint."],"The role could not be changed.":["Le rôle n'a pas pu être modifié."],"The rule on %(source)s names nothing to match. Pick a value, or remove the rule.":["La règle sur %(source)s ne nomme rien à faire correspondre. Choisissez une valeur, ou retirez la règle."],"The rules could not be loaded.":["Les règles n'ont pas pu être chargées."],"The rules on %(field)s lead back to %(field)s itself, so nothing can decide whether to show it. Point one of them at another field.":["Les règles de %(field)s reviennent à %(field)s lui-même, rien ne peut donc décider de l'afficher. Faites pointer l'une d'elles vers un autre champ."],"The same item was picked twice for %(field)s. Pick each item once.":["Le même élément a été choisi deux fois pour %(field)s. Choisissez chaque élément une seule fois."],"The server could not be reached, so nothing was changed.":["Le serveur n'a pas pu être joint, rien n'a donc été changé."],"The server could not be reached, so nothing was saved.":["Le serveur n'a pas pu être joint, rien n'a donc été enregistré."],"The server could not be reached, so nothing was uploaded.":["Le serveur n'a pas pu être joint, rien n'a donc été téléversé."],"The setting %(setting)s cannot hold the value it was given. Check what this setting takes and try again.":["Le réglage %(setting)s ne peut pas contenir la valeur donnée. Vérifiez ce que ce réglage accepte et réessayez."],"The setting %(setting)s falls outside the others. Keep min below max and the default inside the bounds.":["Le réglage %(setting)s sort des autres. Gardez min sous max et la valeur par défaut dans les limites."],"The settings could not be read, so nothing can be changed here yet.":["Les réglages n'ont pas pu être lus, donc rien ne peut encore être modifié ici."],"The site default":["La langue par défaut du site"],"The site may already hold part of this file. Import it again to see what is left.":["Le site contient peut-être déjà une partie de ce fichier. Importez-le à nouveau pour voir ce qu'il reste."],"The source %(source)s does not offer %(operator)s. Pick one of the comparisons it does offer.":["La source %(source)s n'offre pas %(operator)s. Choisissez une des comparaisons qu'elle offre."],"The theme %(name)s carries no %(file)s, so Gophenberg cannot tell what it is. Put that file at the top level of the zip and package the theme again.":["Le thème %(name)s ne contient aucun %(file)s, Gophenberg ne peut donc pas savoir ce que c'est. Placez ce fichier au premier niveau du zip et réempaquetez le thème."],"The theme %(name)s could not be read, so it cannot serve your site. Upload it again, or choose another theme.":["Le thème %(name)s n'a pas pu être lu, il ne peut donc pas servir votre site. Téléversez-le de nouveau, ou choisissez un autre thème."],"The theme %(name)s did not start, so your site is still served by the theme it had. Ask whoever looks after your site to check the server log for what the theme reported.":["Le thème %(name)s n'a pas démarré, votre site est donc toujours servi par le thème qu'il avait. Demandez à la personne qui s'occupe de votre site de regarder ce que le thème a signalé dans le journal du serveur."],"The theme %(name)s does not carry %(path)s as a file, so there is nothing to run. Package the built theme rather than its source.":["Le thème %(name)s ne contient pas %(path)s comme fichier, il n'y a donc rien à exécuter. Empaquetez le thème construit plutôt que sa source."],"The theme %(name)s does not carry %(path)s as a folder, so its styles and scripts are missing. Package the built theme rather than its source.":["Le thème %(name)s ne contient pas %(path)s comme dossier, ses styles et ses scripts manquent donc. Empaquetez le thème construit plutôt que sa source."],"The theme %(name)s holds a link to another file at %(path)s, and a theme may only carry real files. Replace it and package the theme again.":["Le thème %(name)s contient un lien vers un autre fichier à %(path)s, et un thème ne peut contenir que de vrais fichiers. Remplacez-le et réempaquetez le thème."],"The theme %(name)s is serving your site right now, so it cannot be replaced. Deactivate it first, or install this copy under another name.":["Le thème %(name)s sert votre site en ce moment, il ne peut donc pas être remplacé. Désactivez-le d'abord, ou installez cette copie sous un autre nom."],"The theme %(name)s is the one chosen, but it is not installed, so your site is served by the built-in renderer. Upload the theme again, or choose another one.":["Le thème %(name)s est celui choisi, mais il n'est pas installé, votre site est donc servi par le moteur de rendu intégré. Téléversez de nouveau le thème, ou choisissez-en un autre."],"The theme %(name)s was built on theme kit %(declared)s, and this site serves %(served)s. Build the theme again against a kit this site serves, then install it.":["Le thème %(name)s a été compilé avec le kit de thèmes %(declared)s, et ce site sert %(served)s. Compilez le thème à nouveau avec un kit que ce site sert, puis installez-le."],"The trash could not be emptied.":["La corbeille n'a pas pu être vidée."],"The user could not be created.":["L'utilisateur n'a pas pu être créé."],"The values stored under it do not follow.":["Les valeurs enregistrées dessous ne le suivent pas."],"Theme":["Thème"],"Themes":["Thèmes"],"Themes could not be loaded.":["Les thèmes n'ont pas pu être chargés."],"There is no content type called %(value)s. Pick a type your site holds, or register it first.":["Il n'existe aucun type de contenu appelé %(value)s. Choisissez un type que votre site possède, ou déclarez-le d'abord."],"There is no content type called %(value)s. Pick one of the types your site holds and try again.":["Il n'existe aucun type de contenu appelé %(value)s. Choisissez un des types que votre site possède et réessayez."],"There is no kind of field called %(value)s. Pick one of these instead: %(allowed)s.":["Il n'existe aucun genre de champ appelé %(value)s. Choisissez plutôt parmi ceux-ci : %(allowed)s."],"There is no page kind called %(value)s. Pick one of these instead: %(allowed)s.":["Il n'existe aucun genre de page appelé %(value)s. Choisissez plutôt parmi ceux-ci : %(allowed)s."],"There is no status called %(value)s. Pick one of these instead: %(allowed)s.":["Il n'existe aucun état appelé %(value)s. Choisissez plutôt parmi ceux-ci : %(allowed)s."],"There is nothing at this address. Check the link and try again.":["Il n'y a rien à cette adresse. Vérifiez le lien et réessayez."],"There is nothing to roll back to, because your site has not been through a theme change yet. Install a theme first, then a rollback has somewhere to go.":["Il n'y a rien vers quoi revenir, car votre site n'a pas encore changé de thème. Installez d'abord un thème, et le retour aura alors une destination."],"These files could not be read.":["Ces fichiers n'ont pas pu être lus."],"These were left alone, because nobody confirmed losing them.":["Ceux-ci ont été laissés tels quels, car personne n'a confirmé leur perte."],"This content type does not exist any more. Reload the page to see the types your site holds now.":["Ce type de contenu n'existe plus. Rechargez la page pour voir les types que votre site possède maintenant."],"This content type does not nest, so one of its items cannot sit inside another. Mark the type as Nests first.":["Ce type de contenu ne s'imbrique pas, un de ses éléments ne peut donc pas se trouver dans un autre. Marquez d'abord le type comme Imbriqué."],"This content type has no field called %(field)s. Check the name, or declare the field on the type first.":["Ce type de contenu n'a aucun champ appelé %(field)s. Vérifiez le nom, ou déclarez d'abord le champ sur le type."],"This content type is turned off, so its content is not served or edited. Activate it first.":["Ce type de contenu est désactivé, son contenu n'est donc ni servi ni modifié. Activez-le d'abord."],"This content type still holds content, so it cannot be deleted. Move or delete its items first.":["Ce type de contenu contient encore du contenu, il ne peut donc pas être supprimé. Déplacez ou supprimez d'abord ses éléments."],"This definitions file is larger than the %(max)s bytes Gophenberg reads in one request. Split it, or raise the limit your site runs with.":["Ce fichier de définitions dépasse les %(max)s octets que Gophenberg lit en une requête. Divisez-le, ou relevez la limite de votre site."],"This field holds no layouts yet.":["Ce champ ne contient pas encore de dispositions."],"This field would stand inside too many containers. Put it nearer the top and try again.":["Ce champ se trouverait dans trop de conteneurs. Placez-le plus près du niveau supérieur et réessayez."],"This file could not be opened as a zip. Package the theme again and upload the new file.":["Ce fichier n'a pas pu être ouvert comme zip. Réempaquetez le thème et téléversez le nouveau fichier."],"This file is larger than the upload limit. Upload a smaller file.":["Ce fichier dépasse la limite de téléversement. Téléversez un fichier plus petit."],"This file is named %(extension)s but it holds %(detected)s content. Upload it under a name that matches what is inside it.":["Ce fichier est nommé %(extension)s mais il contient du %(detected)s. Téléversez-le sous un nom qui correspond à ce qu'il contient."],"This file matches the site, so there is nothing to change.":["Ce fichier correspond au site, il n'y a donc rien à changer."],"This file says it was written in format %(declared)s, which is not a version Gophenberg can read. Export the definitions again from the site they came from.":["Ce fichier indique le format %(declared)s, qui n'est pas une version que Gophenberg sait lire. Exportez à nouveau les définitions depuis le site d'origine."],"This file was written in format %(declared)s, and this release reads %(served)s. Upgrade the site the file came from, or this one.":["Ce fichier est écrit au format %(declared)s, et cette version lit %(served)s. Mettez à jour le site d'origine du fichier, ou celui-ci."],"This group holds no fields yet.":["Ce groupe ne contient pas encore de champs."],"This install of Gophenberg has no install root, so it updates by hand.":["Cette installation de Gophenberg n'a pas de racine d'installation, elle se met donc à jour à la main."],"This is a custom build of Gophenberg, so it updates by hand.":["Il s'agit d'une version compilée sur mesure de Gophenberg, elle se met donc à jour à la main."],"This item cannot go straight to that status from the one it holds now. Save it as a draft first, then try again.":["Cet élément ne peut pas passer directement à cet état depuis celui qu'il a maintenant. Enregistrez-le d'abord comme brouillon, puis réessayez."],"This item does not exist, and it may have been deleted. Go back to the list to see what is there.":["Cet élément n'existe pas, et il a peut-être été supprimé. Revenez à la liste pour voir ce qui s'y trouve."],"This item is already in the trash. Restore it first to work on it again.":["Cet élément est déjà dans la corbeille. Rétablissez-le d'abord pour y retravailler."],"This item is in the trash. Restore it first to work on it again.":["Cet élément est dans la corbeille. Rétablissez-le d'abord pour y retravailler."],"This item is not in the trash, so there is nothing to restore. Open it from the list to work on it as it is.":["Cet élément n'est pas dans la corbeille, il n'y a donc rien à rétablir. Ouvrez-le depuis la liste pour travailler dessus tel quel."],"This item still holds items nested inside it. Move or delete those first.":["Cet élément contient encore des éléments imbriqués. Déplacez-les ou supprimez-les d'abord."],"This kind of field holds no fields inside it. Put the field in a section or a repeater instead.":["Ce type de champ ne contient aucun champ. Placez le champ dans une section ou dans un répéteur."],"This media item is not in the library, and someone may have deleted it. Reload the page to see what is there now.":["Ce média n'est pas dans la bibliothèque, et quelqu'un l'a peut-être supprimé. Rechargez la page pour voir ce qui s'y trouve maintenant."],"This picture could not be read, so nothing was stored. The file may be damaged, so open it on your computer and upload it again.":["Cette image n'a pas pu être lue, rien n'a donc été stocké. Le fichier est peut-être endommagé, ouvrez-le sur votre ordinateur et téléversez-le de nouveau."],"This picture is %(width)s by %(height)s pixels, over the limit of %(max)s pixels in all. Make it smaller and upload it again.":["Cette image fait %(width)s par %(height)s pixels, au-delà de la limite de %(max)s pixels au total. Réduisez-la et téléversez-la de nouveau."],"This post changed elsewhere. Reload before saving again.":["Cet article a changé ailleurs. Rechargez avant d'enregistrer de nouveau."],"This request came from a page on another site, so it was refused. Open the admin on this site and try again.":["Cette requête vient d'une page d'un autre site, elle a donc été refusée. Ouvrez l'administration sur ce site et réessayez."],"This request carried %(attribute)s, which fields do not take. Reload the page so the admin and the server agree again.":["Cette requête portait %(attribute)s, que les champs n'acceptent pas. Rechargez la page pour que l'administration et le serveur concordent à nouveau."],"This request left out %(field)s, which Gophenberg needs. Reload the page and try again.":["Cette requête a omis %(field)s, dont Gophenberg a besoin. Rechargez la page et réessayez."],"This rule reads a source called %(source)s, and nothing on your site declares one. A plugin that added it may have been removed.":["Cette règle lit une source nommée %(source)s, et rien sur votre site n'en déclare. Une extension qui l'ajoutait a pu être retirée."],"This site cannot write to its themes folder, so no theme can be installed. Ask whoever looks after your site to make the folder that %(setting)s points at writable. A read-only mount is the usual cause.":["Ce site ne peut pas écrire dans son dossier de thèmes, aucun thème ne peut donc être installé. Demandez à la personne qui s'occupe de votre site de rendre accessible en écriture le dossier vers lequel pointe %(setting)s. Un montage en lecture seule en est la cause habituelle."],"This site has nowhere to keep themes, so no theme can be installed. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Ce site n'a nulle part où garder les thèmes, aucun thème ne peut donc être installé. Demandez à la personne qui s'occupe de votre site de faire pointer %(setting)s vers un dossier où le serveur peut écrire."],"This site has nowhere to store uploads, so nothing can be uploaded. Ask whoever looks after your site to point %(setting)s at a folder the server can write to.":["Ce site n'a nulle part où stocker les téléversements, rien ne peut donc être téléversé. Demandez à la personne qui s'occupe de votre site de faire pointer %(setting)s vers un dossier où le serveur peut écrire."],"This theme is over the limit of %(max)s bytes, packed or unpacked. Leave out what the theme does not need and package it again.":["Ce thème dépasse la limite de %(max)s octets, compressé ou non. Retirez ce dont le thème n'a pas besoin et réempaquetez-le."],"This type will answer under /%(word)s.":["Ce type répondra sous /%(word)s."],"This zip holds %(entries)s files, more than the %(max)s a theme may carry. Package only the built theme.":["Ce zip contient %(entries)s fichiers, plus que les %(max)s qu'un thème peut contenir. Empaquetez seulement le thème construit."],"Through":["Via"],"Thumbnail":["Miniature"],"Title":["Titre"],"Two field groups in this file share the key %(key)s. Give one of them a different key and import again.":["Deux groupes de champs de ce fichier partagent la clé %(key)s. Donnez une clé différente à l'un d'eux et importez à nouveau."],"Two rule sources are registered under the name %(param)s. A plugin is declaring a source your site already holds.":["Deux sources de règles sont enregistrées sous le nom %(param)s. Une extension déclare une source que votre site possède déjà."],"Type":["Type"],"Type a whole number in both boxes before saving.":["Saisissez un nombre entier dans les deux cases avant d'enregistrer."],"Undo":["Annuler"],"Unsaved changes":["Modifications non enregistrées"],"Update":["Mettre à jour"],"Update failed.":["La mise à jour a échoué."],"Upload theme":["Téléverser un thème"],"Uploaded %s file.":["%s fichier téléversé.","%s fichiers téléversés.","%s fichiers téléversés."],"Users":["Utilisateurs"],"Users could not be loaded.":["Les utilisateurs n'ont pas pu être chargés."],"Value":["Valeur"],"Version":["Version"],"Videos":["Vidéos"],"Visit Site":["Aller sur le site"],"Web address":["Adresse web"],"What each Linked from field reads":["Ce que lit chaque champ Lié depuis"],"What it points at changed, so its stored values go with it.":["Ce qu'il vise a changé, donc ses valeurs enregistrées partent avec lui."],"Where %(group)s appears":["Où apparaît %(group)s"],"Without a rule this field always shows.":["Sans aucune règle ce champ s'affiche toujours."],"Without a rule this group appears nowhere.":["Sans une règle ce groupe n'apparaît nulle part."],"Yes":["Oui"],"Yes or no":["Oui ou non"],"You cannot change your own role.":["Vous ne pouvez pas changer votre propre rôle."],"Your language":["Votre langue"],"Your role does not allow %(capability)s.":["Votre rôle ne permet pas %(capability)s."],"Your server pins the site to the theme %(name)s, so themes cannot be changed here. Ask whoever looks after your site to remove the pin.":["Votre serveur fixe le site au thème %(name)s, les thèmes ne peuvent donc pas être changés ici. Demandez à la personne qui s'occupe de votre site de retirer cette fixation."],"Your site always needs one default content type, so this one cannot be turned off or deleted. Make another type the default first.":["Votre site a toujours besoin d'un type de contenu par défaut, celui-ci ne peut donc être ni désactivé ni supprimé. Rendez d'abord un autre type par défaut."],"a content type":["un type de contenu"],"account\u0004Role":["Rôle"],"account\u0004Status":["État"],"action label\u0004Duplicate":["Dupliquer"],"action label\u0004Duplicate…":["Dupliquer…"],"address":["adresse"],"admin section\u0004Content Types":["Types de contenu"],"admin section\u0004Field Groups":["Groupes de champs"],"admin section\u0004Language":["Langue"],"admin section\u0004Media":["Médias"],"admin section\u0004Settings":["Réglages"],"admin section\u0004Themes":["Thèmes"],"column\u0004Date":["Date"],"content type\u0004\"%s\" permanently deleted.":["Type de contenu « %s » supprimé définitivement."],"content type\u0004\"%s\" successfully created.":["Type de contenu « %s » créé avec succès."],"content type\u0004Status":["État"],"field\u0004Name":["Nom"],"field group\u0004Status":["Statut"],"field type\u0004Choice":["Choix"],"field type\u0004Color":["Couleur"],"field type\u0004Date":["Date"],"field type\u0004Flexible content":["Contenu flexible"],"field type\u0004Gallery":["Galerie"],"field type\u0004Kind":["Genre"],"field type\u0004Layout":["Disposition"],"field type\u0004Link":["Lien"],"field type\u0004Media":["Média"],"field type\u0004Range":["Plage"],"field type\u0004Repeater":["Répéteur"],"field type\u0004Section":["Section"],"field type\u0004Select":["Liste déroulante"],"file size\u0004%1$s %2$s":["%1$s %2$s"],"file type group\u0004Archives":["Archives"],"file type group\u0004Audio":["Sons"],"file type group\u0004Video":["Vidéos"],"holds":["contient"],"holds anything":["contient quelque chose"],"https://www.w3.org/WAI/tutorials/images/decision-tree/":["https://www.w3.org/WAI/tutorials/images/decision-tree/"],"image dimensions\u0004%1$s × %2$s":["%1$s par %2$s"],"is":["est"],"is above":["est supérieur à"],"is below":["est inférieur à"],"is empty":["est vide"],"is not":["n'est pas"],"panel button label\u0004Settings":["Réglages"],"person\u0004Name":["Nom"],"post\u0004%s (Copy)":["%s (copie)"],"post\u0004Status":["État"],"post status\u0004Draft":["Brouillon"],"post status\u0004Published":["Publié"],"post status\u0004Trash":["Corbeille"],"post type general name\u0004Pages":["Pages"],"post type general name\u0004Posts":["Articles"],"post type singular name\u0004Page":["Page"],"post type singular name\u0004Post":["Article"],"relation target\u0004None":["Aucun"],"role\u0004Administrator":["Administrateur"],"role\u0004Author":["Auteur"],"role\u0004Editor":["Éditeur"],"role\u0004Member":["Membre"],"role\u0004No role":["Sans rôle"],"status filter\u0004All":["Tout"],"taxonomy general name\u0004Categories":["Catégories"],"taxonomy singular name\u0004Category":["Catégorie"],"theme\u0004Status":["État"],"trash\u0004Restore":["Rétablir"],"unit symbol\u0004B":["o"],"unit symbol\u0004EB":["Eo"],"unit symbol\u0004GB":["Go"],"unit symbol\u0004KB":["Ko"],"unit symbol\u0004MB":["Mo"],"unit symbol\u0004PB":["Po"],"unit symbol\u0004TB":["To"],"unit symbol\u0004YB":["Yo"],"unit symbol\u0004ZB":["Zo"],"v%(version)s":["v%(version)s"],"verb\u0004Trash…":["Mettre à la corbeille…"],"verb\u0004View":["Voir"],"{day} {month} {year}":["{day} {month} {year}"]} diff --git a/languages/es-ES.po b/languages/es-ES.po index 0d0f4e8b..14f6bc2f 100644 --- a/languages/es-ES.po +++ b/languages/es-ES.po @@ -130,12 +130,6 @@ msgstr "Contenido" msgid "Content Types" msgstr "Tipos de contenido" -msgid "Could not delete that item." -msgid_plural "Could not delete every item." -msgstr[0] "No se ha podido borrar ese elemento." -msgstr[1] "No se han podido borrar todos los elementos." -msgstr[2] "No se han podido borrar todos los elementos." - msgid "Could not load that post." msgstr "No se ha podido cargar esa entrada." @@ -145,8 +139,9 @@ msgstr "No se ha podido guardar esa entrada." msgid "Create user" msgstr "Crear usuario" +#, fuzzy msgid "Date added" -msgstr "Fecha de subida" +msgstr "Fecha de inclusión" msgid "Deactivate" msgstr "Desactivar" @@ -160,9 +155,6 @@ msgstr "Por defecto" msgid "Delete" msgstr "Borrar" -msgid "Delete Permanently" -msgstr "Borrar permanentemente" - msgid "Delete the field" msgstr "Borrar el campo" @@ -229,9 +221,6 @@ msgstr "Tamaño del archivo" msgid "File type" msgstr "Tipo de archivo" -msgid "Files" -msgstr "Archivos" - msgid "Filter by status" msgstr "Filtrar por estado" @@ -686,9 +675,6 @@ msgstr "Este archivo se llama %(extension)s pero lleva contenido %(detected)s. S msgid "This item cannot go straight to that status from the one it holds now. Save it as a draft first, then try again." msgstr "Este elemento no puede pasar directamente a ese estado desde el que tiene ahora. Guárdalo antes como borrador e inténtalo de nuevo." -msgid "This item changed while you were describing it. Reload and try again." -msgstr "Este elemento ha cambiado mientras lo describías. Recarga e inténtalo de nuevo." - msgid "This item does not exist, and it may have been deleted. Go back to the list to see what is there." msgstr "Este elemento no existe, y puede que se haya borrado. Vuelve a la lista para ver qué hay." @@ -821,9 +807,6 @@ msgstr "Desactivar %(theme)s" msgid "Delete %(field)s" msgstr "Borrar %(field)s" -msgid "Delete %(title)s for good? This cannot be undone." -msgstr "¿Borrar %(title)s para siempre? Esto no se puede deshacer." - msgid "Disable %(name)s" msgstr "Desactivar a %(name)s" @@ -1839,12 +1822,6 @@ msgstr[0] "¿Borrar permanentemente %s elemento? Esto no se puede deshacer." msgstr[1] "¿Borrar permanentemente %s de elementos? Esto no se puede deshacer." msgstr[2] "¿Borrar permanentemente %s elementos? Esto no se puede deshacer." -msgid "Delete this %(count)s item for good? This cannot be undone." -msgid_plural "Delete these %(count)s items for good? This cannot be undone." -msgstr[0] "¿Borrar este %(count)s elemento para siempre? Esto no se puede deshacer." -msgstr[1] "¿Borrar estos %(count)s elementos para siempre? Esto no se puede deshacer." -msgstr[2] "¿Borrar estos %(count)s elementos para siempre? Esto no se puede deshacer." - #, fuzzy msgid "Empty trash" msgstr "Vaciar papelera" @@ -1898,13 +1875,9 @@ msgstr "Un fotograma de esta animación tiene más de %(max)s píxeles, más de msgid "Pending Review" msgstr "Pendiente de revisión" -#, fuzzy -msgid "Permanently delete" -msgstr "Borrar permanentemente" - #, fuzzy msgid "Permanently delete…" -msgstr "Borrar permanentemente…" +msgstr "Eliminar permanentemente…" #, fuzzy msgid "Picture quality runs from 1 to %(max)s, so %(value)s is not a quality Gophenberg stores at. Pick a number inside that range." @@ -2257,10 +2230,6 @@ msgctxt "field type" msgid "Link" msgstr "Enlace" -msgctxt "media family" -msgid "Kind" -msgstr "Clase" - msgctxt "person" msgid "Name" msgstr "Nombre" @@ -2498,3 +2467,117 @@ msgstr "No se han encontrado tipos de contenido." #, fuzzy msgid "No description" msgstr "Sin descripción" + +#, fuzzy +msgid "Could not save image. %s" +msgstr "No se ha podido guardar la imagen. %s" + +#, fuzzy +msgid "Could not save media. %s" +msgstr "No se ha podido guardar el archivo multimedia. %s" + +#, fuzzy +msgid "Date modified" +msgstr "Fecha de modificación" + +#, fuzzy +msgid "Delete permanently" +msgstr "Borrar permanentemente" + +#, fuzzy +msgid "Describe the purpose of the image." +msgstr "Describe el propósito de la imagen." + +#, fuzzy +msgid "Documents" +msgstr "Documentos" + +#, fuzzy +msgid "Edit media" +msgstr "Editar los medios" + +#, fuzzy +msgid "Edit…" +msgstr "Editar…" + +#, fuzzy +msgid "Leave empty if decorative." +msgstr "Déjalo vacío si es decorativo." + +#, fuzzy +msgid "Media file updated." +msgstr "Archivo de medios actualizado." + +#, fuzzy +msgid "https://www.w3.org/WAI/tutorials/images/decision-tree/" +msgstr "https://www.w3.org/WAI/tutorials/images/decision-tree/" + +#, fuzzy +msgctxt "file size" +msgid "%1$s %2$s" +msgstr "%1$s %2$s" + +#, fuzzy +msgctxt "file type group" +msgid "Archives" +msgstr "Archivos" + +#, fuzzy +msgctxt "file type group" +msgid "Audio" +msgstr "Audio" + +#, fuzzy +msgctxt "file type group" +msgid "Video" +msgstr "Vídeo" + +#, fuzzy +msgctxt "image dimensions" +msgid "%1$s × %2$s" +msgstr "%1$s × %2$s" + +#, fuzzy +msgctxt "unit symbol" +msgid "B" +msgstr "B" + +#, fuzzy +msgctxt "unit symbol" +msgid "EB" +msgstr "EB" + +#, fuzzy +msgctxt "unit symbol" +msgid "GB" +msgstr "GB" + +#, fuzzy +msgctxt "unit symbol" +msgid "KB" +msgstr "KB" + +#, fuzzy +msgctxt "unit symbol" +msgid "MB" +msgstr "MB" + +#, fuzzy +msgctxt "unit symbol" +msgid "PB" +msgstr "PB" + +#, fuzzy +msgctxt "unit symbol" +msgid "TB" +msgstr "TB" + +#, fuzzy +msgctxt "unit symbol" +msgid "YB" +msgstr "YB" + +#, fuzzy +msgctxt "unit symbol" +msgid "ZB" +msgstr "ZB" diff --git a/languages/fr-FR.po b/languages/fr-FR.po index 1e1704d6..55547f68 100644 --- a/languages/fr-FR.po +++ b/languages/fr-FR.po @@ -118,7 +118,7 @@ msgstr "%(source)s est %(value)s" #, fuzzy msgid "(no title)" -msgstr "(sans titre)" +msgstr "(pas de titre)" #, fuzzy msgid "" @@ -288,7 +288,7 @@ msgstr "août" #, fuzzy msgid "Author" -msgstr "Auteur" +msgstr "Auteur/autrice" #, fuzzy msgid "Back to the latest posts" @@ -362,12 +362,6 @@ msgstr "Le contenu s'imbrique sur au plus %(max)s niveaux, et cet élément irai msgid "Content type" msgstr "Type de contenu" -msgid "Could not delete that item." -msgid_plural "Could not delete every item." -msgstr[0] "Impossible de supprimer cet élément." -msgstr[1] "Impossible de supprimer tous les éléments." -msgstr[2] "Impossible de supprimer tous les éléments." - #, fuzzy msgid "Could not load that post." msgstr "Impossible de charger cet article." @@ -390,7 +384,7 @@ msgstr "Créer l'utilisateur" #, fuzzy msgid "Date added" -msgstr "Date d'ajout" +msgstr "Date d’ajout" #, fuzzy msgid "Deactivate" @@ -412,10 +406,6 @@ msgstr "Supprimer" msgid "Delete %(group)s" msgstr "Supprimer %(group)s" -#, fuzzy -msgid "Delete Permanently" -msgstr "Supprimer définitivement" - #, fuzzy msgid "Delete the field" msgstr "Supprimer le champ" @@ -518,7 +508,7 @@ msgstr "Champs de %(group)s" #, fuzzy msgid "File name" -msgstr "Nom du fichier" +msgstr "Nom de fichier" #, fuzzy msgid "File size" @@ -528,10 +518,6 @@ msgstr "Taille du fichier" msgid "File type" msgstr "Type de fichier" -#, fuzzy -msgid "Files" -msgstr "Fichiers" - #, fuzzy msgid "Filter by status" msgstr "Filtrer par état" @@ -715,7 +701,7 @@ msgstr "mai" #, fuzzy msgid "Media Library" -msgstr "Bibliothèque de médias" +msgstr "Médiathèque" #, fuzzy msgid "Medium" @@ -795,7 +781,7 @@ msgstr "Aucun thème n'est installé." #, fuzzy msgid "No title" -msgstr "Sans titre" +msgstr "Aucun titre" #, fuzzy msgid "None" @@ -1425,10 +1411,6 @@ msgstr "Ce fichier est nommé %(extension)s mais il contient du %(detected)s. T msgid "This item cannot go straight to that status from the one it holds now. Save it as a draft first, then try again." msgstr "Cet élément ne peut pas passer directement à cet état depuis celui qu'il a maintenant. Enregistrez-le d'abord comme brouillon, puis réessayez." -#, fuzzy -msgid "This item changed while you were describing it. Reload and try again." -msgstr "Cet élément a changé pendant que vous le décriviez. Rechargez et réessayez." - #, fuzzy msgid "This item does not exist, and it may have been deleted. Go back to the list to see what is there." msgstr "Cet élément n'existe pas, et il a peut-être été supprimé. Revenez à la liste pour voir ce qui s'y trouve." @@ -1665,17 +1647,6 @@ msgstr "Désactiver %(theme)s" msgid "Delete %(field)s" msgstr "Supprimer %(field)s" -#, fuzzy -msgid "Delete %(title)s for good? This cannot be undone." -msgstr "Supprimer %(title)s définitivement ? Cette action est irréversible." - -#, fuzzy -msgid "Delete this %(count)s item for good? This cannot be undone." -msgid_plural "Delete these %(count)s items for good? This cannot be undone." -msgstr[0] "Supprimer définitivement ce %(count)s élément ? Cette action est irréversible." -msgstr[1] "Supprimer définitivement ces %(count)s éléments ? Cette action est irréversible." -msgstr[2] "Supprimer définitivement ces %(count)s éléments ? Cette action est irréversible." - #, fuzzy msgid "Disable %(name)s" msgstr "Désactiver %(name)s" @@ -1832,11 +1803,6 @@ msgctxt "field type" msgid "Link" msgstr "Lien" -#, fuzzy -msgctxt "media family" -msgid "Kind" -msgstr "Type" - #, fuzzy msgctxt "panel button label" msgid "Settings" @@ -2444,10 +2410,6 @@ msgstr "L'élément n'a pas pu être rétabli." msgid "The item could not be permanently deleted." msgstr "L'élément n'a pas pu être supprimé définitivement." -#, fuzzy -msgid "Permanently delete" -msgstr "Supprimer définitivement" - #, fuzzy msgid "Move %s item to the trash?" msgid_plural "Move %s items to the trash?" @@ -2515,7 +2477,7 @@ msgstr[2] "%s éléments n'ont pas pu être rétablis." #, fuzzy msgid "\"%s\" permanently deleted." -msgstr "« %s » supprimé définitivement." +msgstr "Suppression définitive de « %s »." #, fuzzy msgid "%s item permanently deleted." @@ -2740,7 +2702,7 @@ msgstr "%(file)s : %(reason)s" #, fuzzy msgid "No media items found." -msgstr "Aucun fichier média trouvé." +msgstr "Aucun média n’a été trouvé." #, fuzzy msgid "No media items yet." @@ -2910,3 +2872,117 @@ msgstr "Aucun type de contenu trouvé." #, fuzzy msgid "No description" msgstr "Aucune description" + +#, fuzzy +msgid "Could not save image. %s" +msgstr "Impossible d’enregistrer le média. %s" + +#, fuzzy +msgid "Could not save media. %s" +msgstr "Impossible d’enregistrer le média. %s" + +#, fuzzy +msgid "Date modified" +msgstr "Date de modification" + +#, fuzzy +msgid "Delete permanently" +msgstr "Supprimer définitivement" + +#, fuzzy +msgid "Describe the purpose of the image." +msgstr "Décrivez la fonction de l’image." + +#, fuzzy +msgid "Documents" +msgstr "Documents" + +#, fuzzy +msgid "Edit media" +msgstr "Modifier le média" + +#, fuzzy +msgid "Edit…" +msgstr "Modifier…" + +#, fuzzy +msgid "Leave empty if decorative." +msgstr "Laissez vide si l’image est décorative" + +#, fuzzy +msgid "Media file updated." +msgstr "Fichier média mis à jour." + +#, fuzzy +msgid "https://www.w3.org/WAI/tutorials/images/decision-tree/" +msgstr "https://www.w3.org/WAI/tutorials/images/decision-tree/" + +#, fuzzy +msgctxt "file size" +msgid "%1$s %2$s" +msgstr "%1$s %2$s" + +#, fuzzy +msgctxt "file type group" +msgid "Archives" +msgstr "Archives" + +#, fuzzy +msgctxt "file type group" +msgid "Audio" +msgstr "Sons" + +#, fuzzy +msgctxt "file type group" +msgid "Video" +msgstr "Vidéos" + +#, fuzzy +msgctxt "image dimensions" +msgid "%1$s × %2$s" +msgstr "%1$s par %2$s" + +#, fuzzy +msgctxt "unit symbol" +msgid "B" +msgstr "o" + +#, fuzzy +msgctxt "unit symbol" +msgid "EB" +msgstr "Eo" + +#, fuzzy +msgctxt "unit symbol" +msgid "GB" +msgstr "Go" + +#, fuzzy +msgctxt "unit symbol" +msgid "KB" +msgstr "Ko" + +#, fuzzy +msgctxt "unit symbol" +msgid "MB" +msgstr "Mo" + +#, fuzzy +msgctxt "unit symbol" +msgid "PB" +msgstr "Po" + +#, fuzzy +msgctxt "unit symbol" +msgid "TB" +msgstr "To" + +#, fuzzy +msgctxt "unit symbol" +msgid "YB" +msgstr "Yo" + +#, fuzzy +msgctxt "unit symbol" +msgid "ZB" +msgstr "Zo" diff --git a/languages/gophenberg.pot b/languages/gophenberg.pot index dad878c4..8354cbba 100644 --- a/languages/gophenberg.pot +++ b/languages/gophenberg.pot @@ -563,14 +563,15 @@ msgstr "" msgid "Content type updated." msgstr "" -msgid "Could not delete that item." -msgid_plural "Could not delete every item." -msgstr[0] "" -msgstr[1] "" - msgid "Could not load that post." msgstr "" +msgid "Could not save image. %s" +msgstr "" + +msgid "Could not save media. %s" +msgstr "" + msgid "Could not save that post." msgstr "" @@ -589,6 +590,9 @@ msgstr "" msgid "Date added" msgstr "" +msgid "Date modified" +msgstr "" + msgid "Deactivate" msgstr "" @@ -619,9 +623,6 @@ msgstr "" msgid "Delete %(group)s" msgstr "" -msgid "Delete %(title)s for good? This cannot be undone." -msgstr "" - msgid "Delete %s content type for good? This cannot be undone." msgid_plural "Delete %s content types for good? This cannot be undone." msgstr[0] "" @@ -632,7 +633,7 @@ msgid_plural "Delete %s items for good? This cannot be undone." msgstr[0] "" msgstr[1] "" -msgid "Delete Permanently" +msgid "Delete permanently" msgstr "" msgid "Delete the field" @@ -641,17 +642,15 @@ msgstr "" msgid "Delete the group" msgstr "" -msgid "Delete this %(count)s item for good? This cannot be undone." -msgid_plural "Delete these %(count)s items for good? This cannot be undone." -msgstr[0] "" -msgstr[1] "" - msgid "Delete…" msgstr "" msgid "Describe" msgstr "" +msgid "Describe the purpose of the image." +msgstr "" + msgid "Describe…" msgstr "" @@ -685,6 +684,9 @@ msgstr "" msgid "Document" msgstr "" +msgid "Documents" +msgstr "" + msgid "Done. The site now holds what the file describes." msgstr "" @@ -694,6 +696,12 @@ msgstr "" msgid "Drop files to upload" msgstr "" +msgid "Edit media" +msgstr "" + +msgid "Edit…" +msgstr "" + msgid "Email" msgstr "" @@ -766,9 +774,6 @@ msgstr "" msgid "File type" msgstr "" -msgid "Files" -msgstr "" - msgid "Filter by status" msgstr "" @@ -892,6 +897,9 @@ msgstr "" msgid "Large" msgstr "" +msgid "Leave empty if decorative." +msgstr "" + msgid "Let items nest" msgstr "" @@ -970,6 +978,9 @@ msgstr "" msgid "Media Library" msgstr "" +msgid "Media file updated." +msgstr "" + msgid "Medium" msgstr "" @@ -1179,9 +1190,6 @@ msgstr "" msgid "Pending Review" msgstr "" -msgid "Permanently delete" -msgstr "" - msgid "Permanently delete…" msgstr "" @@ -1724,9 +1732,6 @@ msgstr "" msgid "This item cannot go straight to that status from the one it holds now. Save it as a draft first, then try again." msgstr "" -msgid "This item changed while you were describing it. Reload and try again." -msgstr "" - msgid "This item does not exist, and it may have been deleted. Go back to the list to see what is there." msgstr "" @@ -1897,6 +1902,9 @@ msgstr "" msgid "holds anything" msgstr "" +msgid "https://www.w3.org/WAI/tutorials/images/decision-tree/" +msgstr "" + msgid "is" msgstr "" @@ -2034,8 +2042,24 @@ msgctxt "field type" msgid "Select" msgstr "" -msgctxt "media family" -msgid "Kind" +msgctxt "file size" +msgid "%1$s %2$s" +msgstr "" + +msgctxt "file type group" +msgid "Archives" +msgstr "" + +msgctxt "file type group" +msgid "Audio" +msgstr "" + +msgctxt "file type group" +msgid "Video" +msgstr "" + +msgctxt "image dimensions" +msgid "%1$s × %2$s" msgstr "" msgctxt "panel button label" @@ -2126,6 +2150,42 @@ msgctxt "trash" msgid "Restore" msgstr "" +msgctxt "unit symbol" +msgid "B" +msgstr "" + +msgctxt "unit symbol" +msgid "EB" +msgstr "" + +msgctxt "unit symbol" +msgid "GB" +msgstr "" + +msgctxt "unit symbol" +msgid "KB" +msgstr "" + +msgctxt "unit symbol" +msgid "MB" +msgstr "" + +msgctxt "unit symbol" +msgid "PB" +msgstr "" + +msgctxt "unit symbol" +msgid "TB" +msgstr "" + +msgctxt "unit symbol" +msgid "YB" +msgstr "" + +msgctxt "unit symbol" +msgid "ZB" +msgstr "" + msgctxt "verb" msgid "Trash…" msgstr "" From 98a3eb281168c625020c7bcade27f917b0b38a32 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 10:52:10 +0100 Subject: [PATCH 09/13] docs(guides): describe the media library list, its dialogs and its filters --- docs/src/content/docs/guides/media.md | 150 ++++++++++++++++++++++---- 1 file changed, 128 insertions(+), 22 deletions(-) diff --git a/docs/src/content/docs/guides/media.md b/docs/src/content/docs/guides/media.md index 164e5102..ddec95d2 100644 --- a/docs/src/content/docs/guides/media.md +++ b/docs/src/content/docs/guides/media.md @@ -1,6 +1,6 @@ --- title: The media library -description: Uploading pictures and files, describing them, and placing them in a post. +description: Uploading pictures and files, finding and editing them, and placing them in a post. --- The Media screen holds every file you have uploaded. Pictures, @@ -54,27 +54,118 @@ An animated GIF is the exception. It is stored exactly as uploaded and gains no smaller copies, because making them would throw the animation away. -## Finding and describing - -The library opens as a grid of thumbnails. The **Layout** control -switches it to a table, which adds the file name, type and date. -Search matches titles and file names, and the filter narrows the -list to images or to plain files. When nothing matches, the library -says `No media items found.` An empty library says `No media items -yet.` and how to add one. - -Every item carries four descriptions, all editable through -**Describe**: - -- **Title** names the item in the library. +## Reading the library + +The library opens as a grid of cards, the newest file first. On a +screen narrower than 640 pixels, such as a phone, it shows a list +with one file per row instead. There is no layout switch. + +Each card shows the picture, the title in bold and the author. An +untitled file shows `(no title)`. A small round avatar sits before +the author's name, drawn the way the site's +[avatar settings](/self-hosting/configuration/#avatars) say, as in +the [posts list](/guides/posts/). + +A file that is no picture shows the icon of its kind, video, audio +or a plain file, above its file name. A picture that fails to load +shows the picture icon the same way. A long file name is cut short +with `…` on the card. + +Date added, Date modified, Alt text, Caption, Description, File +name, File size, Dimensions and File type are hidden at first. Turn +them on under **View options**. Both dates show the whole date and +time the way WordPress writes them in the language you read the +admin in, such as `October 8, 2026 5:54 pm` or +`8 de octubre de 2026 17:54`. A file name longer than 15 characters +is cut short with `…` and shows whole when you rest the pointer on +it. Alt text shows on pictures alone. An empty alt text, caption, +description or file type shows `-`. + +## Finding a file + +The library shows 20 files per page by default. **View options** +offers the other page sizes the site allows, and the arrows under +the library move between pages. It also sorts by Title, Date added, +Date modified or Author. + +The search box reads `Search media…`. It waits for you to stop +typing, then matches titles, file names, captions and descriptions. + +**Add filter** narrows the library: + +- **Author** keeps the files of the people you pick, or with **Is + none of** leaves them out. +- **Date added** and **Date modified** keep the files added or + changed before or after a moment you pick. +- **Type** keeps one type of file: Images, Audio, Video, Documents + for PDF files or Archives for ZIP files. Every type is listed, even + one the library holds none of. + +Filters add up, so a file has to match every one of them. The page, +the page size, the sort, the search and the filters all live in the +address of the screen. Reload it or share the link and the library +opens the same way. + +When nothing matches, the library says `No media items found.` An +empty library says `No media items yet.` and how to add one. + +## The actions on a file + +The actions menu of a file offers: + +- **View** opens the file itself in a new tab. +- **Edit…** opens the file's details, as the next part explains. +- **Rename…** changes the title alone, in a dialog sent with + **Save**. +- **Permanently delete…** removes the file, after a question. + +View is the main action and shows on every file. On a phone it also +shows as a button beside the menu of each row. The other three show +only on the files you may change: an author's own uploads, and every +file for an editor or an administrator. Another person's file shows +its title as plain text to an author, and on a phone a tap on its +row opens the file in a new tab, as View does. + +## Editing a file + +Click the title or the picture of a file you may change to open +**Edit media**, or pick **Edit…** from its actions menu. On a phone, +tap the row. The dialog holds four fields you write in: + +- **Title** names the file in the library. - **Alt text** describes a picture to someone who cannot see it. - Leave it empty when the picture is decorative. + Leave it empty when the picture is decorative. Only pictures have + it, and its link explains how to write one. - **Caption** is the line shown under a picture in a post. - **Description** is a longer note for your own use. -If someone else changes an item while you are describing it, your -save is refused rather than quietly overwriting theirs. Reload and -make the change again. +Under them sit the details you cannot change: Date added, Author, +File name, File type, File size and Dimensions. The file name shows +whole here. + +Cancel and **Save** stay at the bottom of the dialog while the fields +above them scroll, as in WordPress's own media editor. While the +fields do not fit, the part they scroll in is a stop of its own for +the Tab key, named `Scrollable section` for a screen reader, so the +arrow keys, Page Down and End scroll it. Once it has scrolled, a line +shows under the dialog's header, as in WordPress's dialogs. The dialog +closes the moment you press **Save**, and a short message at the +bottom of the screen says `Media file updated.` once the server +stored the change. The focus then goes back to what opened the +dialog, the title, the picture, the actions menu or the phone row, +wherever the file now sits in the library. It goes to the file's +actions menu when that part no longer shows, and to the library +itself when the file no longer shows there. Only the fields +you changed are sent, so a change someone else made to another field +stays. When the save fails, the message says why, starting with +`Could not save image.` for a picture and `Could not save media.` +for any other file. The dialog then opens on what you typed the next +time you open it for that file. + +**Rename…** closes at once too, says `Name updated.`, and keeps a +refused name the same way. Saves to one file go one after the +other, so when you rename a file and then edit it, the last one you +sent wins. ## Placing media in a post @@ -96,7 +187,22 @@ library from the Document panel, and the same warning applies. ## Deleting -**Delete Permanently** removes an item and every copy of it from -disk. There is no media trash, so the confirmation is the only -step between you and a deleted file. You can select several items -and delete them together. +**Permanently delete…** removes a file and every copy of it from +disk. There is no media trash, so the question is the only step +between you and a deleted file. It asks in a dialog with no header +that names the file, such as +`Delete "Sunset" for good? This cannot be undone.` Cancel has the +focus when the dialog opens. Press **Delete permanently** and the +button shows the work running until the server answers, then the +dialog closes. + +Tick several cards and the **Permanently delete…** button under the +library deletes them all at once, behind one question that counts +them. A phone list has no tick boxes. + +A short message at the bottom of the screen names the file, such as +`"Sunset" permanently deleted.`, or counts the files when you deleted +several. A long title is cut short with `…`, in the message and in +the question. When the server refuses, the message gives the reason +instead, and when some of several files fail it counts the ones that +failed. From 1fb31595b5bd1b2027ced2ad20bba9ab3a2ade12 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 10:52:11 +0100 Subject: [PATCH 10/13] docs(self-hosting): name the media dates and avatars among the exceptions --- .../src/content/docs/self-hosting/configuration.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/self-hosting/configuration.md b/docs/src/content/docs/self-hosting/configuration.md index 506848bb..327a93f4 100644 --- a/docs/src/content/docs/self-hosting/configuration.md +++ b/docs/src/content/docs/self-hosting/configuration.md @@ -358,18 +358,20 @@ change them. They shape every list the same way: - **The format.** `GOPHENBERG_FORMAT_LOCALE` sets the locale for dates, times and numbers, whatever language a person reads the admin in. The tag `es-ES` writes 04/10/2026, 14:05 and 1.234,56. - The date column of a content list is the one exception. Like - WordPress, it writes the date the way WordPress does in the - reader's language, with a short month in English and Spanish and - a whole one in French. + The date column of a content list and the two dates of the media + library are the exceptions. Like WordPress, they write the date the + way WordPress does in the reader's language. The content list + writes a short month in English and Spanish and a whole one in + French. The media library writes the whole date and time, such as + `8 de octubre de 2026 17:54` in Spanish. ## Avatars An avatar is the small round picture beside a person's name. The three avatar settings copy the Avatars part of WordPress's Discussion settings. The server reads them when it starts. The author column of -the admin's content lists follows them, and so does every plugin that -shows who wrote something. +the admin's content and media lists follows them, and so does every +plugin that shows who wrote something. - **`initials`**, the default, draws the first letter of the name in a coloured circle. The site draws it, so nothing is sent to another From 8f8eedbcc47996b3b5ce2443f37c725744a8f482 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 10:52:11 +0100 Subject: [PATCH 11/13] docs(contributing): translate size units under the unit symbol context --- .../content/docs/contributing/add-a-translatable-string.md | 6 ++++-- docs/src/content/docs/contributing/translate-gophenberg.md | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/contributing/add-a-translatable-string.md b/docs/src/content/docs/contributing/add-a-translatable-string.md index cbff2d6e..51dd57f9 100644 --- a/docs/src/content/docs/contributing/add-a-translatable-string.md +++ b/docs/src/content/docs/contributing/add-a-translatable-string.md @@ -57,8 +57,10 @@ Class names, test ids, route paths, API field names, query keys, and any status or type value sent to the server. Those are identifiers, not words, and translating one breaks the software. -Also leave alone the product name Gophenberg, the size units B, KB, MB, -GB and TB, and anything that arrives from the server or from an author. +Also leave alone the product name Gophenberg, and anything that arrives +from the server or from an author. A size unit is no identifier. Wrap +it under the context WordPress gives it, +`_x('KB', 'unit symbol', 'gophenberg')`, so French can write Ko. A content type's plural label and its description read as their author typed them, in whatever language they typed them, and no catalogue reaches them. diff --git a/docs/src/content/docs/contributing/translate-gophenberg.md b/docs/src/content/docs/contributing/translate-gophenberg.md index 8ca4026d..ba8a2c63 100644 --- a/docs/src/content/docs/contributing/translate-gophenberg.md +++ b/docs/src/content/docs/contributing/translate-gophenberg.md @@ -46,7 +46,10 @@ that note describes. Your language may well need two different words where English used one, and that is the reason the context exists. **Some words stay in English.** The name Gophenberg is never translated. -Neither are the size units B, KB, MB, GB and TB. + +**Size units follow your language.** The units B, KB, MB and the larger +ones carry the context "unit symbol". Write the symbol your language +uses, as WordPress does, such as Ko and Mo in French. **Write the way the software speaks.** Gophenberg addresses the reader directly and plainly. Keep sentences short, and prefer the everyday word From f74a2df018ceb4509ce75e6dc641624845427185 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 10:52:11 +0100 Subject: [PATCH 12/13] docs(extending): list the file type icons the sdk passes on --- .../src/content/docs/extending/the-plugin-sdk.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/extending/the-plugin-sdk.md b/docs/src/content/docs/extending/the-plugin-sdk.md index b13db584..8654af68 100644 --- a/docs/src/content/docs/extending/the-plugin-sdk.md +++ b/docs/src/content/docs/extending/the-plugin-sdk.md @@ -548,15 +548,19 @@ for the account at `/api/auth/session`, so serve that address with The frontend SDK, `@gophenberg/frontend-sdk`, passes on a few icons from `@wordpress/icons`, each named after the job it does in the -admin: `authorIcon`, `backIcon`, `backupIcon`, `brushIcon`, -`categoryIcon`, `closeIcon`, `customPostTypeIcon`, `downIcon`, -`languageIcon`, `listIcon`, `listViewIcon`, `mediaIcon`, `pageIcon`, -`postIcon`, `redoIcon`, `saveIcon`, `settingsIcon`, `sidebarIcon`, -`sidebarRtlIcon`, `trashIcon`, `undoIcon`, `upIcon` and -`uploadIcon`. `authorIcon` is the one an +admin: `audioIcon`, `authorIcon`, `backIcon`, `backupIcon`, +`brushIcon`, `categoryIcon`, `closeIcon`, `customPostTypeIcon`, +`downIcon`, `fileIcon`, `imageIcon`, `languageIcon`, `listIcon`, +`listViewIcon`, `mediaIcon`, `pageIcon`, `postIcon`, `redoIcon`, +`saveIcon`, `settingsIcon`, `sidebarIcon`, `sidebarRtlIcon`, +`trashIcon`, `undoIcon`, `upIcon`, `uploadIcon` and `videoIcon`. +`authorIcon` is the one an author cell shows when the site shows no avatars, `postIcon` the one an empty content list shows, `mediaIcon` the one an empty media library shows, and `uploadIcon` the one an upload button carries. +`imageIcon`, `videoIcon`, `audioIcon` and `fileIcon` are the ones a +media card draws for a file that shows no picture, picked by its +file type as WordPress picks them. `sidebarIcon` is the one the editor's Settings button carries, and `sidebarRtlIcon` its mirror for a language written right to left. `saveIcon` is the one Save draft shows on a phone, and `closeIcon` From f36a3cccdf7014609d354f9f4166854c60910a49 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 11:23:15 +0100 Subject: [PATCH 13/13] fix(media): name the Edit fields pane when a shorter window makes it overflow --- frontend/src/media/FieldsPane.tsx | 6 +++-- frontend/src/test/media-actions.test.tsx | 28 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/frontend/src/media/FieldsPane.tsx b/frontend/src/media/FieldsPane.tsx index 2c87bf9d..1b728019 100644 --- a/frontend/src/media/FieldsPane.tsx +++ b/frontend/src/media/FieldsPane.tsx @@ -23,8 +23,9 @@ function paneClass(scrollable: boolean, scrolled: boolean): string { /** * Renders the pane the fields of Edit scroll in, as the components Modal treats its own content: named Scrollable - * section while the fields overflow it, checked each time they resize, and marked once it scrolled. It turns a tab stop - * only once the focus the Modal puts on its first field landed inside, so the Modal never picks the pane for that. + * section while the fields overflow it, checked each time they or the pane resize, and marked once it scrolled. It + * turns a tab stop only once the focus the Modal puts on its first field landed inside, so the Modal never picks the + * pane for that. * @param props - The fields. * @returns The pane. */ @@ -39,6 +40,7 @@ export function FieldsPane({ children }: { children: ReactNode }) { const measure = () => setScrollable(scroller.scrollHeight > scroller.clientHeight) const watcher = new ResizeObserver(measure) watcher.observe(content.current as HTMLDivElement) + watcher.observe(scroller) measure() return () => watcher.disconnect() }, []) diff --git a/frontend/src/test/media-actions.test.tsx b/frontend/src/test/media-actions.test.tsx index 45cd87c2..8976edf4 100644 --- a/frontend/src/test/media-actions.test.tsx +++ b/frontend/src/test/media-actions.test.tsx @@ -507,7 +507,11 @@ test('draws the line under the Edit header once the fields pane scrolled, gone a } }) -test('names the Edit fields pane once its fields grow past it, watched as the Modal watches content', async () => { +/** + * Replaces ResizeObserver with one that records what it watches and what it stops, until the test ends. + * @returns The callback each watched element reports to, and the callbacks disconnected. + */ +function watchResizes() { const watched = new Map() const stopped: ResizeObserverCallback[] = [] vi.stubGlobal( @@ -529,6 +533,28 @@ test('names the Edit fields pane once its fields grow past it, watched as the Mo onTestFinished(() => { vi.unstubAllGlobals() }) + return { watched, stopped } +} + +test('names the Edit fields pane once a shorter window makes it overflow, its fields the same size', async () => { + const { watched } = watchResizes() + stubLayout() + renderAt('/media') + const dialog = await openCardDialog('Harbor at dawn', 'Edit…') + const pane = fieldsPane(dialog) + await waitFor(() => expect(within(dialog).getByRole('textbox', { name: 'Title' })).toHaveFocus()) + expect(pane).not.toHaveAttribute('aria-label') + expect(watched.has(pane)).toBe(true) + + overflowFields() + act(() => (watched.get(pane) as ResizeObserverCallback)([], {} as ResizeObserver)) + + expect(pane).toHaveAttribute('aria-label', 'Scrollable section') + expect(pane).toHaveAttribute('tabindex', '0') +}) + +test('names the Edit fields pane once its fields grow past it, watched as the Modal watches content', async () => { + const { watched, stopped } = watchResizes() stubLayout() renderAt('/media') const dialog = await openCardDialog('Harbor at dawn', 'Edit…')