diff --git a/docs/architecture/images.md b/docs/architecture/images.md index 215933f49..07f6fdb39 100644 --- a/docs/architecture/images.md +++ b/docs/architecture/images.md @@ -936,6 +936,32 @@ siblings are `VutuvWeb.AgentDocs.PressKitDoc`, and the profile document carries the same entries under `press_kit`; both list the **released** pictures only, since a document that names a file must name one that can be fetched. +**A page has the same section, kept by its team (issue #2087).** The card, the +section page, the documents, the schema.org block and the sitemap entry are the +same code with an organization as the owner — `PressKitComponents.press_card/1` +(lifted out of the profile template when the page needed the second copy), +`VutuvWeb.PressKitController.organization/2`, `PressKitDoc.build/2`. Three things +are the page's own. **The URL is built from the slug**, `/organizations/:slug/press` +(`PressKit.page_path/1`), not from a claimed root handle: that handle dispatches +the bare `/:slug` alone, so `Identity.path/1 <> "/press"` answered an address that +404ed. The **editor** is `/organizations/:slug/press/edit` — the same +`VutuvWeb.PressKitLive`, embedded by `OrganizationController.press/2` instead of +routed, so it resolves the viewer with `InitAssigns.assign_embedded/2` and +re-asks `manageable_by?/2` on the socket (`OrganizationLive.ManageGate`) rather +than trusting the signed `organization_id`; it is on the manage tab bar and in +the page's own manage strip, for an owner or a publisher. And a page may take +**its own logo** onto the logo shelf with one press (`PressKit.adopt_page_logo/3`) +where that logo is a **vector** — `File.cp!` through the ordinary `create/4`, so +the page's original stays where it is and the copy meets the same cap, whitelist, +rights stamp and scan; a raster logo is not offered, since the file the page +uploaded is already the screen-sized copy and would be a print promise it cannot +keep. + +The sentences whose German addresses the reader as the owner ("Ihr Logo", "über +Sie schreibt") get **their own msgids** for the page's editor rather than sharing +the member's: `gettext.extract --merge` fuzzy-filled all three with exactly that +member-voiced German, which is the trap `docs/architecture/i18n.md` records. + ### The takedown hold (issue #2012) A copyright freeze **moves** a picture, it never deletes one, and the tree it diff --git a/docs/architecture/organizations.md b/docs/architecture/organizations.md index ca1027779..ac22f8067 100644 --- a/docs/architecture/organizations.md +++ b/docs/architecture/organizations.md @@ -650,6 +650,19 @@ column `user_id` empty for this kind, because it cascades. The whole move, including what the release that wires the takedown has to answer for a page that cannot be struck, is in [images.md](images.md). +Beside the logo a page keeps a **press kit** (#2087): press photos and logo +variants a journalist downloads, on a card below the open positions and on +`/organizations/:slug/press`. Its editor is `/organizations/:slug/press/edit`, +on the manage tab bar, and it is the one manage page a **publisher** reaches +without holding an administrative role — the material a page hands the press is +part of speaking for it (`Vutuv.PressKit.manageable_by?/2`, owners and +publishers, deliberately not `can_manage?/2`, which also counts whoever claimed +the page). The rows belong to the page: `organization_id` owns them and the +colleague who uploaded one only rides in `uploader_user_id`, so a press photo +outlives their account exactly as the logo does. Where the page's own logo is a +vector it is one press away from being the first variant. The whole kind is in +[images.md](images.md). + ## The homepage screenshot The page shows a picture of the website it names — a "Website" card at the top diff --git a/lib/vutuv/press_kit.ex b/lib/vutuv/press_kit.ex index 1e10d2a4c..e144e24cc 100644 --- a/lib/vutuv/press_kit.ex +++ b/lib/vutuv/press_kit.ex @@ -66,6 +66,7 @@ defmodule Vutuv.PressKit do alias Vutuv.Moderation.ImageScans alias Vutuv.Moderation.Pixelation alias Vutuv.Ordering + alias Vutuv.OrganizationImageStore alias Vutuv.Organizations alias Vutuv.Organizations.Organization alias Vutuv.PressKitStore @@ -117,6 +118,21 @@ defmodule Vutuv.PressKit do @doc "The owner's logo variants, in the order they are shown." def logos(owner), do: shelf_rows(owner, true) + @doc """ + Both shelves of one owner's kit **whatever their moderation state** — + `%{photos:, logos:}`, each in the owner's own order. What the editor lists, + which is the one surface that shows a picture the AI gate is still holding as + itself rather than as a stand-in. + + One query for both, for `public_shelves/2`'s reason: the caps are ten and five, + so the whole kit is at most fifteen rows and the split is cheaper in memory + than a second round trip — and the editor reloads them after every save, + delete, move, reorder and upload. + """ + def shelves(owner) do + owner |> owned() |> Ordering.by_position() |> Repo.all() |> split() + end + @doc "How many pictures the owner has on that shelf." def count(owner, logo?) when is_boolean(logo?), do: owner |> shelf(logo?) |> Repo.aggregate(:count) @@ -254,13 +270,22 @@ defmodule Vutuv.PressKit do """ def manageable_by?(%User{id: id}, %User{id: id}), do: true - def manageable_by?(%Organization{} = organization, %User{} = viewer) do - powers = Organizations.role_powers(organization, viewer) - powers.owner? or powers.publisher? - end + def manageable_by?(%Organization{} = organization, %User{} = viewer), + do: organization |> Organizations.role_powers(viewer) |> manageable_by_powers?() def manageable_by?(_owner, _viewer), do: false + @doc """ + The same rule, read off a `Vutuv.Organizations.role_powers/2` answer somebody + already holds — for the page itself, which consolidated five permission reads + into that one and must not grow a sixth to ask this. + + Public so the rule keeps one home: `manageable_by?/2` is this function plus + the read. + """ + def manageable_by_powers?(%{owner?: owner?, publisher?: publisher?}), + do: owner? or publisher? + @doc """ Whether `viewer` may fetch this picture's bytes — the one statement of that rule, which the proxy and every surface #2086 and #2087 build ask. @@ -430,17 +455,36 @@ defmodule Vutuv.PressKit do defp address(%Image{token: token}, file), do: "/system/press_kit/#{token}/#{file}" @doc """ - Where this owner's press kit is published: `/ada.king/press` for a member, the - page's canonical path plus `/press` for a page — `Vutuv.Identity.path/1` plus - one segment, so the next owner kind is remembered in the one place that - already knows about owner kinds. + Where this owner's press kit is published: `/ada.king/press` for a member, + `/organizations/acme/press` for a page. For everything holding an owner rather than a route: the doc's canonical URL, - the schema.org block, and #2087's page twin. The profile template keeps its - verified `~p` sigil, which is a compile-time check this cannot give it. + the schema.org block, the card's footer link and the sitemap entry. + + **A page is addressed by its slug here, not by its root handle**, which is + where this deviates from `Vutuv.Identity.path/1`. A page that claimed a root + handle is canonical at `/acme`, but that handle dispatches the **bare** + `/:slug` page alone (`VutuvWeb.Plug.UserResolveSlug`, `dispatch_organization: + true` on that one route) — every sub-page of a page lives under + `/organizations/:slug/`, exactly as its post permalinks do, and #2086 shipped + `Identity.path/1 <> "/press"` here, which answered `/acme/press` for such a + page and 404ed. """ + def page_path(%Organization{slug: slug}), do: "/organizations/#{slug}/press" def page_path(owner), do: Identity.path(owner) <> "/press" + @doc """ + Where this kit is **edited**: `/settings/press` for a member (#2085), the + page's own editor for a page (#2087). + + The second path this kind owns, and it needs one owner for the same reason the + first does — four surfaces link to it (the section page's "Manage" bridge, the + card's add tile, the page's manage menu and the editor's own trail), and a + member's editor carries no slug at all while a page's does. + """ + def editor_path(%Organization{slug: slug}), do: "/organizations/#{slug}/press/edit" + def editor_path(%User{}), do: "/settings/press" + @doc """ The one sentence under which every press picture is offered. @@ -519,6 +563,57 @@ defmodule Vutuv.PressKit do end end + @doc """ + The page's own logo file, when it is one the logo shelf could take as a + variant: the absolute path of the stored original, or `nil` (#2087). + + **A vector only, deliberately.** A press logo is offered for print, and what a + page uploaded as a raster is already the screen-sized copy + `Vutuv.OrganizationImageStore` derived from it — handing a journalist a 512 px + PNG under the word "logo" is worse than handing them nothing, and it is what + the page's own tile already shows. A vector is the one case where the file the + page uploaded **is** the printable one, which is why #2082 asked for the + one-click offer on exactly that case. + + It asks the disk rather than the row's `content_type`, because the disk is + what `adopt_page_logo/3` will copy: an installation restored from a snapshot + carries no `originals/` tree, and a button offering a file that is not there + would fail on the press instead of never appearing. + """ + def page_logo_source(%Organization{logo: token}) when is_binary(token) do + path = OrganizationImageStore.original_path(token) + + if path && Path.extname(path) == ".svg", do: path + end + + def page_logo_source(_owner), do: nil + + @doc """ + Copies that logo onto the page's logo shelf as one more variant — the + one-click adoption, which is `create/4` with the page's own file standing in + for the upload. + + Through `create/4` and nothing else, so it inherits every rule a picked file + meets: the authorization, the shelf cap, the whitelist, the rights stamp and + the AI scan. `attrs` are the add form's, the rights tick included — the page + holds this file, but releasing it for editorial use with a credit is still a + decision somebody makes rather than one adoption may imply. + """ + def adopt_page_logo(%Organization{} = organization, %User{} = viewer, attrs) do + case page_logo_source(organization) do + nil -> + {:error, :invalid_file} + + path -> + create(organization, viewer, {path, Path.basename(path)}, Map.put(attrs, "logo", true)) + end + end + + # A member's kit has no page logo to adopt, and the editor offers the button + # on a page alone — but the event arrives from a client, so the clause that + # cannot happen answers rather than raising. + def adopt_page_logo(_owner, _viewer, _attrs), do: {:error, :forbidden} + @doc """ Edits one stored picture's label, credit and caption — the only three columns a form may write once the file is on disk (`Image.press_kit_update_changeset/2` @@ -592,6 +687,29 @@ defmodule Vutuv.PressKit do end end + @doc """ + What the editor's credit field is **offered** ready-filled with: the last + picture on that shelf where there is one (`last_credit/1`), and the owner's + own name where there is not. + + The fallback is the whole point. A press picture with no credit is one a + journalist may not print, and until now a first-time member met an empty field + — the shelf they were filling had nothing to copy a line from. The name is + right far more often than blank is: most members are photographed by somebody + they can name, and the ones who took the picture themselves are named + correctly by it. + + The **owner's** name, never the uploader's: a page's press kit belongs to the + page, and the colleague who uploads for it is not who the credit names. + + A default, not a value. It is rendered into an ordinary editable input and + nothing writes it at save time, so a member who clears the field stores an + empty credit — the field says what will be stored, which is the only way a + prefill can be honest. + """ + def credit_default(owner, images) when is_list(images), + do: last_credit(images) || Identity.display_name(owner) + @doc """ Releases a press picture the AI gate cleared, and answers `:stale` when the row is no longer the one that was waiting (deleted meanwhile, or already settled by diff --git a/lib/vutuv/sitemap.ex b/lib/vutuv/sitemap.ex index dedf84c23..c50f5ee39 100644 --- a/lib/vutuv/sitemap.ex +++ b/lib/vutuv/sitemap.ex @@ -76,7 +76,8 @@ defmodule Vutuv.Sitemap do organizations: chunks(Repo.aggregate(indexable_organizations(), :count)), organization_posts: chunks(Repo.aggregate(indexable_organization_posts(), :count)), jobs: chunks(Repo.aggregate(indexable_jobs(), :count)), - press: chunks(Repo.aggregate(indexable_press_users(), :count)) + press: chunks(Repo.aggregate(indexable_press_users(), :count)), + organization_press: chunks(Repo.aggregate(indexable_press_organizations(), :count)) } end @@ -111,6 +112,27 @@ defmodule Vutuv.Sitemap do end) end + @doc """ + `{path, lastmod_date}` entries of one organization-press chunk (1-based): the + press page of every crawlable page that actually offers a picture (#2087). + + Its own type rather than a widened `press_entries/1`, for the reason + `organization_post_entries/1` is its own: that one is scoped to + `indexable_users/0` and reads a member's handle for the URL, and a page has + neither — its URL is built from the slug and its indexability is its `seo?` + flag. `lastmod` is the page's own, matching its directory entry. + """ + def organization_press_entries(chunk) do + indexable_press_organizations() + |> order_by([o], o.id) + |> window(chunk) + |> select([o], {o.slug, o.updated_at}) + |> Repo.all() + |> Enum.map(fn {slug, updated_at} -> + {"/organizations/#{slug}/press", NaiveDateTime.to_date(updated_at)} + end) + end + @doc "`{path, lastmod_date}` entries of one posts chunk (1-based)." def post_entries(chunk) do indexable_posts() @@ -219,6 +241,21 @@ defmodule Vutuv.Sitemap do where(indexable_users(), [u], u.id in subquery(owner_ids)) end + # The page twin (#2087), and the other half of that nullable pair: a picture + # whose `organization_id` is set names a page's press section. Same `IN` over + # owner ids for the same reason — one row per page, however many pictures it + # offers — and the same explicit `not is_nil`, which here keeps a *member's* + # rows out. + defp indexable_press_organizations do + owner_ids = + from(i in PressKit.public_query(), + where: not is_nil(i.organization_id), + select: i.organization_id + ) + + where(indexable_organizations(), [o], o.id in subquery(owner_ids)) + end + # scope_visible(nil) already drops restricted posts, frozen posts and # moderation-hidden authors; the join adds the member-level conditions. # diff --git a/lib/vutuv_web/agent_docs/markdown.ex b/lib/vutuv_web/agent_docs/markdown.ex index e6869995a..0cebd61f3 100644 --- a/lib/vutuv_web/agent_docs/markdown.ex +++ b/lib/vutuv_web/agent_docs/markdown.ex @@ -283,7 +283,11 @@ defmodule VutuvWeb.AgentDocs.Markdown do |> Enum.filter(&is_binary/1) |> Enum.join("\n"), organization_people(doc), - organization_open_positions(doc) + organization_open_positions(doc), + # The page's press kit (#2087), written by the same line builder the + # profile document uses, so a press picture reads the same whoever offers + # it. + section(gettext("Press"), Enum.map(doc[:press_kit] || [], &press_picture_line/1)) ] |> join_blocks() end diff --git a/lib/vutuv_web/agent_docs/organization_doc.ex b/lib/vutuv_web/agent_docs/organization_doc.ex index 6c5788d77..07b6d99b2 100644 --- a/lib/vutuv_web/agent_docs/organization_doc.ex +++ b/lib/vutuv_web/agent_docs/organization_doc.ex @@ -17,8 +17,10 @@ defmodule VutuvWeb.AgentDocs.OrganizationDoc do alias Vutuv.Organizations.Organization alias Vutuv.Posts alias Vutuv.Posts.Post + alias Vutuv.PressKit alias VutuvWeb.AgentDocs alias VutuvWeb.AgentDocs.JobPostingDoc + alias VutuvWeb.AgentDocs.PressKitDoc alias VutuvWeb.Fediverse.Docs alias VutuvWeb.UserHelpers @@ -83,6 +85,11 @@ defmodule VutuvWeb.AgentDocs.OrganizationDoc do # asked with a nil viewer, so a post still held by moderation is absent # here even though the page's own publishers see it in the HTML. posts: Enum.map(posts, &post_entry/1), + # The page's press kit (issue #2087), the twin of the profile document's + # own `press_kit` list: the card is on the HTML page, so the document + # names the same pictures. The **released** ones only, like every press + # document — one that names a file must name one that can be fetched. + press_kit: press_kit_entries(organization), # The page's Fediverse address, the same fact its card shows (nil unless # it federates). It belongs in the doc for the reason the whole system # exists: an agent reading the `.md` should be able to say where to follow @@ -91,6 +98,14 @@ defmodule VutuvWeb.AgentDocs.OrganizationDoc do }) end + # A read rather than a preload: press pictures are rows on the shared `images` + # table with no association off `organizations` (see `Vutuv.PressKit`), and the + # two shelves are rejoined here because the card shows them as one set. + defp press_kit_entries(organization) do + shelves = PressKit.published_shelves(organization) + PressKitDoc.entries(shelves.photos ++ shelves.logos) + end + # No `moved_to` arm, unlike `ProfileDoc`: a page cannot move its account # elsewhere, so the entry is the address or nothing. defp fediverse_entry(organization) do diff --git a/lib/vutuv_web/agent_docs/text.ex b/lib/vutuv_web/agent_docs/text.ex index 34186b8a1..ecd5acc79 100644 --- a/lib/vutuv_web/agent_docs/text.ex +++ b/lib/vutuv_web/agent_docs/text.ex @@ -254,6 +254,7 @@ defmodule VutuvWeb.AgentDocs.Text do |> Enum.filter(&is_binary/1), organization_people(doc), organization_open_positions(doc), + section(gettext("Press"), Enum.map(doc[:press_kit] || [], &press_picture_lines/1)), footer(doc) ] |> join_blocks() diff --git a/lib/vutuv_web/components/organization_components.ex b/lib/vutuv_web/components/organization_components.ex index df85bcb9c..e91b56aad 100644 --- a/lib/vutuv_web/components/organization_components.ex +++ b/lib/vutuv_web/components/organization_components.ex @@ -20,6 +20,7 @@ defmodule VutuvWeb.OrganizationComponents do alias Vutuv.Countries alias Vutuv.Organizations alias Vutuv.Organizations.Organization + alias Vutuv.PressKit attr(:domain, :string, required: true) @@ -252,6 +253,14 @@ defmodule VutuvWeb.OrganizationComponents do <.manage_tab active={@active == :exclusions} navigate={"/organizations/#{@organization.slug}/exclusions"}> {gettext("Job exclusions")} + <%!-- The page's press kit (issue #2087). Owners and publishers, which is + `Vutuv.PressKit`'s own rule read off the powers this header already has: + the material a page hands the press out with is part of speaking for it, + and it is the one manage page a publisher reaches without holding an + administrative role. --%> + <.manage_tab :if={PressKit.manageable_by_powers?(@powers)} active={@active == :press} navigate={PressKit.editor_path(@organization)}> + {gettext("Press")} + <%!-- What happened to the page (issue #1336). Open to the whole team, not only its publishers: this is news ABOUT the page rather than speaking FOR it. --%> diff --git a/lib/vutuv_web/components/press_kit_components.ex b/lib/vutuv_web/components/press_kit_components.ex index c5c7b8e67..acc98b418 100644 --- a/lib/vutuv_web/components/press_kit_components.ex +++ b/lib/vutuv_web/components/press_kit_components.ex @@ -1,13 +1,14 @@ defmodule VutuvWeb.PressKitComponents do @moduledoc """ - What a member's or a page's press kit looks like on a **public** surface - (issue #2086): the card below Links on the profile, and the section page at - `/:slug/press` that hands the files out. + What a member's or a page's press kit looks like on a **public** surface: the + card below Links on the profile (#2086) and below the open positions on an + organization page (#2087), and the section page that hands the files out. - Its own module rather than more of `VutuvWeb.UI` because #2087 draws exactly - the same two shelves on an organization page, and the one thing that differs - there is the owner it is handed — so everything here takes pictures and a - viewer and nothing else. + Its own module rather than more of `VutuvWeb.UI` because both owner kinds draw + exactly the same two shelves, and the one thing that differs is the owner they + are handed — so everything here takes pictures and a viewer and nothing else. + The whole card is `press_card/1`, which #2087 lifted out of + `templates/user/show.html.heex` when the page needed the second copy. ## What the card shows and what opens @@ -15,10 +16,13 @@ defmodule VutuvWeb.PressKitComponents do `VutuvWeb.PostComponents.mosaic_layout/2` answers the geometry — hero first, a frame chosen from the hero's own shape, at most five tiles and a `+N` on the last — so the arrangements themselves are stated once for both. The grid's - *markup* is still written twice, and knowingly: folding it into a shared - component with a cell slot is the right move once #2087 makes a third caller - and there is a second press surface to prove the shape against, and until then - it would be a refactor of the app's hottest markup for one new page. + *markup* is still written twice, and knowingly. #2086 expected #2087 to be the + third caller that pays for folding it into a shared component with a cell + slot; it is not one. A page's card is this module's own `press_photos/1` at a + second **call site**, not a third copy of the markup, so the count of copies + is still two and the refactor of the app's hottest markup still has one + surface to prove itself on. Leave it until a genuinely different grid needs + the same geometry. A tile **describes** its photo and the magnifier in the corner **opens** it — the split `assets/js/lightbox.js` reads, and the reason a tile can sit inside @@ -65,6 +69,57 @@ defmodule VutuvWeb.PressKitComponents do alias VutuvWeb.Markdown alias VutuvWeb.PostComponents + @doc """ + The whole **Press card**: the photo mosaic, the logo row, the rights line and + the footer link to the section page — on a member's profile below Links, and + on a page below its open positions. + + `manage_href` is the editor (`Vutuv.PressKit.editor_path/1`) when this viewer + may write the kit and `nil` when they may not, which is the only thing the two + surfaces disagree about: it decides whether an empty kit shows the card at all + and what the dashed add tile links to. Every other difference lives in the + owner, and `page_path/1` answers it. + """ + attr(:id, :string, required: true) + attr(:owner, :any, required: true, doc: "the member or the page whose kit this is") + attr(:press, :map, required: true, doc: "`Vutuv.PressKit.public_shelves/2`'s answer") + attr(:viewer, :any, default: nil) + attr(:manage_href, :any, default: nil) + + def press_card(assigns) do + # `Map.put/3`, not `assign/3`: a card's owner cannot change without a fresh + # mount, so the key needs no change mark and the attributes reading it stay + # separately tracked. + assigns = Map.put(assigns, :href, PressKit.page_path(assigns.owner)) + + ~H""" + <.card :if={press_any?(@press) or @manage_href} id={@id}> + <.section_header title={gettext("Press")} /> + <.empty_add :if={@manage_href && not press_any?(@press)} href={@manage_href}> + {gettext("Add press photos")} + +
+ {gettext( + "What a journalist writing about this page may download: photos in print quality and its logo as a file. Everything here is public and free for editorial use as long as the credit is shown." + )} +
+ +@@ -328,34 +489,14 @@ defmodule VutuvWeb.PressKitLive do
- <.error_banner :if={@error} id="press-error">{@error} - - <.shelf - user={@user} - logo?={false} - images={@photos} - upload={@uploads.photo} - armed?={@armed[false]} - credit={@credits[false]} - open={@open} - /> - - <.shelf - user={@user} - logo?={true} - images={@logos} - upload={@uploads.logo} - armed?={@armed[true]} - credit={@credits[true]} - open={@open} - logo_ground={@logo_ground} - /> + {render_slot(@inner_block)}- {if @logo?, - do: - gettext( - "Your logo as a file, one tile per variant. A vector (SVG) is what a printer asks for; a PNG is what everybody else can open." - ), - else: - gettext( - "The first photo is the one shown first. Drag a tile, or use the arrows, to change the order." - )} + {if @logo?, do: logo_shelf_hint(@owner), else: photo_shelf_hint()}
<%!-- The light/dark switch, on the logo shelf alone: a white wordmark on @@ -440,7 +577,8 @@ defmodule VutuvWeb.PressKitLive do > <.tile :for={{image, index} <- Enum.with_index(@images)} - user={@user} + owner={@owner} + viewer={@viewer} image={image} index={index} last?={index == @count - 1} @@ -455,6 +593,7 @@ defmodule VutuvWeb.PressKitLive do upload={@upload} armed?={@armed?} credit={@credit} + adopt_logo?={@adopt_logo?} />{gettext("Remove")} @@ -639,7 +779,7 @@ defmodule VutuvWeb.PressKitLive do <.markdown_editor id={"press-caption-#{@image.id}"} name="picture[caption]" - user={@user} + user={@viewer} value={@image.caption || ""} label={gettext("Caption")} placeholder={gettext("Who took it, where, and what may be cropped.")} @@ -665,6 +805,7 @@ defmodule VutuvWeb.PressKitLive do attr(:upload, :any, required: true) attr(:armed?, :boolean, required: true) attr(:credit, :string, required: true) + attr(:adopt_logo?, :boolean, default: false) defp add_form(assigns) do assigns = Map.put(assigns, :shelf, PressKit.shelf_name(assigns.logo?)) @@ -746,6 +887,28 @@ defmodule VutuvWeb.PressKitLive do <.live_file_input upload={@upload} disabled={not @armed?} class="sr-only" /> + <%!-- The page's own logo, taken onto the shelf with one press (#2087), + offered only where that file is a vector — a raster logo on this page is + already the screen-sized copy, and offering it for print would be a + promise the file cannot keep. It is armed by the very tick beside it + rather than skipping the gate: the page holds the file, but releasing it + for editorial use is still somebody's decision, and one rule beats a + second one that only this button knows about. --%> +
+ {gettext("Copies the vector file this page's logo was uploaded as onto this shelf.")} +
+