feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)
This commit is contained in:
1 parent
3d81d2c717
commit
6b8990c55e
239 files changed
+9194
-1354
No files matched your search
@@ -152,6 +152,11 @@ func parseAuthorId(r *http.Request) (uint32, error) {
|
||||
return uint32(id), err
|
||||
}
|
||||
|
||||
type ownAuthorResponse struct {
|
||||
domain.Author
|
||||
SlugAdjusted bool `json:"slug_adjusted"`
|
||||
}
|
||||
|
||||
func presentAuthor(a domain.Author) domain.Author {
|
||||
if a.SocialLinks == nil {
|
||||
a.SocialLinks = []domain.SocialLink{}
|
||||
@@ -325,7 +330,7 @@ func (h *Handler) CreateMine(w http.ResponseWriter, r *http.Request) {
|
||||
writeAuthorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, presentAuthor(created))
|
||||
writeJSON(w, http.StatusCreated, ownAuthorResponse{Author: presentAuthor(created.Author), SlugAdjusted: created.SlugAdjusted})
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+1
-1
@@ -176,7 +176,7 @@ article is allowed and never blocks publishing.
|
||||
| --- | --- | --- |
|
||||
| `GET /v1/author/` | verified session or API key | The authors newest first. Query `q` (name or slug, accent and case insensitive), `page` (default 1), `size` (5, 25, 50 or 100, default 25). Body `{ items, total, page, size }`; a bad query is 400 with the body-mismatch message. |
|
||||
| `POST /v1/author/` | owner or admin, session only | Create. 201 with the author. `user_id` links an account that has no author. |
|
||||
| `POST /v1/author/me` | any verified session | Create the caller's own author from the display name (or the username). A taken slug gets `-2`, `-3`... silently. |
|
||||
| `POST /v1/author/me` | any verified session | Create the caller's own author from the display name (or the username). A taken slug gets `-2`, `-3`... silently. 201 with the author plus `slug_adjusted`: `true` when the stored slug differs from the plain slug of the name (a collision suffix was added), `false` otherwise. No other author response carries this field. |
|
||||
| `PATCH /v1/author/{id}` | owner or admin on any author; any verified session on the author linked to it; session only | Edit `name`, `slug`, `bio`, `avatar_id`, `website`, `social_links`, `user_id`. Absent fields are untouched, `avatar_id`, `website` and `user_id` accept `null`, `social_links` is replaced as a whole. `user_id` links or unlinks the account and is owner or admin only. |
|
||||
| `DELETE /v1/author/{id}?reassign_to={id\|none}` | owner or admin, session only | 204. An author credited on articles needs `reassign_to`: an author id (that author takes the deleted one's position and is never duplicated on an article) or `none` (the credit is removed). The move and the delete are one transaction. `reassign_to` is ignored when the author has no article. The linked account is untouched. |
|
||||
|
||||
|
||||
@@ -218,6 +218,7 @@ Feature: Authors backend, public bylines that articles credit in order
|
||||
Then the status is 201
|
||||
And the author name is "<name>" and the slug is "<slug>"
|
||||
And the author has user_id of the caller and is_linked true
|
||||
And slug_adjusted is false
|
||||
|
||||
Examples:
|
||||
| display | username | name | slug |
|
||||
@@ -239,6 +240,7 @@ Feature: Authors backend, public bylines that articles credit in order
|
||||
When "Marta Alves" sends POST /v1/author/me
|
||||
Then the status is 201
|
||||
And the slug is "marta-alves-3"
|
||||
And slug_adjusted is true
|
||||
And no error is returned
|
||||
|
||||
Scenario: The suffix still fits in the slug limit
|
||||
@@ -246,6 +248,12 @@ Feature: Authors backend, public bylines that articles credit in order
|
||||
When the caller sends POST /v1/author/me
|
||||
Then the status is 201
|
||||
And the slug is at most 100 characters and ends with "-2"
|
||||
And slug_adjusted is true
|
||||
|
||||
Scenario: Only the own-author creation carries slug_adjusted
|
||||
When the owner sends POST /v1/author/ with {"name": "Camille Roche"}
|
||||
Then the 201 body has no slug_adjusted field
|
||||
And GET /v1/author/ items and GET /v1/auth/me author have no slug_adjusted field
|
||||
|
||||
Scenario: A caller who already has an author is refused
|
||||
Given the caller is linked to an author
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
Feature: Sites and authors in the Editor, Articles and Profile screens
|
||||
|
||||
As a RenewCMS user who can edit articles
|
||||
I want to choose an article's site and bylines in the editor, see them in the articles list, and create my own author profile
|
||||
So that every article belongs to the right frontend and credits the right people
|
||||
|
||||
Background:
|
||||
Given the screens are EditorView (/article/:id/edit), ArticlesView (/article) and ProfileView (/profile),
|
||||
inside DashboardShell, per docs/design/routes.md
|
||||
And the visual spec of record is docs/design/screens/editor.md (group "Editor: site and authors"),
|
||||
articles.md (group "Articles: sites and authors") and profile.md (group "Profile: author profile"),
|
||||
with the error copy in docs/design/states.md. Exact copy is read from those files and is not repeated here
|
||||
And this feature supersedes the lines of features/sites_authors_screens.feature that said
|
||||
POST /v1/author/me is not exposed in the UI and that Editor, Articles and Profile are not modified
|
||||
And POST /v1/author/me answers 201 with the author and slug_adjusted, true when the stored slug differs from the plain slug of the name
|
||||
And the SPA calls, with relative URLs through useApi only:
|
||||
GET /v1/site/, GET /v1/author/ (page, size in 5, 25, 50 or 100, and q), POST /v1/author/, POST /v1/author/me,
|
||||
GET /v1/auth/me, GET /v1/article/{id}, PATCH /v1/article/{id} with site_id and author_ids,
|
||||
and GET /v1/article/?site={key}
|
||||
And an article carries site_id and authors [{id, name, slug, ...}] in list order, and the first author is the primary byline
|
||||
And GET /v1/auth/me carries "author" (the linked author, or null) and the SPA keeps it in the session store
|
||||
And the sites fixture is Renew (id 1, renew.example, default), Journal (id 2, journal.renew.example), Projects (id 3) and Archive (id 4)
|
||||
And the authors fixture is Inès Duarte (id 2), Jonas Pereira, Camille Roche and the Marta Alves of the design
|
||||
And an author is drawn as AuthorAvatar (initials fallback, square), several as AuthorStack
|
||||
And the domain defines only the roles "owner" and "admin" (internal/domain/user, RoleOwner and RoleAdmin)
|
||||
And "author rights" means owner and admin: they may create authors and edit any author
|
||||
And "verified" below names a verified account without the owner or admin role, which has no author rights
|
||||
And "several sites" means GET /v1/site/ returned more than one site
|
||||
And every visible string goes through t(...) with the English text as the key and a French entry in web/src/i18n/messages.ts
|
||||
And a scenario tagged @not-in-design uses copy or behaviour that docs/design does not define: it takes the
|
||||
proposal of this feature, has a French entry, and is listed in the .qa.md to be fed back to the design
|
||||
And the screens use Nuxt UI components and design tokens only, with no raw colors
|
||||
And the board frames of docs/design/source/AppFrame.dc.html and DirectoryFrame.dc.html are the layout tiebreaker
|
||||
|
||||
Rule: Editor: the "Site and authors" section (MetaSection)
|
||||
|
||||
Scenario: Collapsed by default with one site and an author
|
||||
Given the install has one site and article 12 has the author "Inès Duarte"
|
||||
And nothing is remembered for this browser
|
||||
When the user opens /article/12/edit on desktop
|
||||
Then the details sidebar shows a collapsed "Site and authors" section with the one-line summary "Inès Duarte"
|
||||
And no Site select is rendered
|
||||
And the section sits in the same place in every editor state that has the details sidebar
|
||||
|
||||
Scenario: Loading and not found
|
||||
When GET /v1/article/12 is pending
|
||||
Then the section header is shown with a skeleton summary
|
||||
When GET /v1/article/12 answers 404
|
||||
Then the "Not found" state shows no section
|
||||
|
||||
Scenario Outline: The section opens by itself when it needs attention
|
||||
Given nothing is remembered for this browser
|
||||
And the install has <sites> and the article has <authors>
|
||||
When the user opens the editor on desktop
|
||||
Then the section is <state>
|
||||
|
||||
Examples:
|
||||
| sites | authors | state |
|
||||
| one site | an author | collapsed |
|
||||
| several sites | an author | open |
|
||||
| one site | no authors | open |
|
||||
| several sites | no authors | open |
|
||||
|
||||
Scenario: The choice is remembered per browser and wins over auto-open
|
||||
Given the section is collapsed by default
|
||||
When the user opens it and later opens another article in the same browser
|
||||
Then the section is open there too
|
||||
When the user collapses it and reloads
|
||||
Then it is collapsed even if the install has several sites or the article has no authors
|
||||
And the choice is kept in localStorage and nothing is sent to the API
|
||||
|
||||
Scenario: Mobile: inside the Details sheet, closed by default
|
||||
Given the viewport is 390 px wide
|
||||
When the user opens the editor
|
||||
Then the section is not visible until the Details sheet is opened
|
||||
And inside the sheet it is closed unless a choice is remembered
|
||||
When the user opens it, closes the sheet and opens it again
|
||||
Then the section keeps the choice made in the sheet
|
||||
And the sheet and the desktop sidebar share the same storage key
|
||||
|
||||
@not-in-design
|
||||
Scenario Outline: Collapsed summary line variants
|
||||
Given the section is collapsed
|
||||
And the install has <sites> and the article has <authors>
|
||||
Then the summary line reads <summary>
|
||||
|
||||
Examples:
|
||||
| sites | authors | summary |
|
||||
| one site | Inès Duarte | "Inès Duarte" |
|
||||
| one site | Inès Duarte, Jonas Pereira, Camille Roche | "Inès Duarte +2" |
|
||||
| one site | no authors | "No authors" |
|
||||
| several sites | Inès Duarte on Journal | "Journal · Inès Duarte" |
|
||||
| several sites | no authors on Journal | "Journal · No authors" |
|
||||
|
||||
Rule: Site select
|
||||
|
||||
Scenario: One site: no Site select
|
||||
Given the install has one site
|
||||
When the section is open
|
||||
Then there is no Site select and the "Authors" list is the first control
|
||||
|
||||
Scenario: Several sites: Site select above the authors
|
||||
Given the install has several sites and article 12 is on "Journal"
|
||||
When the section is open
|
||||
Then a "Site" select above the authors shows "Journal" with "journal.renew.example"
|
||||
And the helper text reads "Canonical host: journal.renew.example"
|
||||
And the select is available to every role that can edit the article
|
||||
|
||||
Scenario: Site select options
|
||||
When the user opens the Site select
|
||||
Then each option shows the site name and its host, and the default site is first
|
||||
|
||||
Scenario: Changing the site is an edit
|
||||
Given the editor shows "Saved" and the article is on "Renew"
|
||||
When the user chooses "Journal"
|
||||
Then the status becomes "Unsaved changes" and Save is enabled
|
||||
And no request is sent yet
|
||||
When the user activates Save
|
||||
Then exactly one PATCH /v1/article/12 is sent containing site_id 2
|
||||
And on 200 the status is "Saved" and the helper text shows the new host
|
||||
|
||||
Rule: Author list
|
||||
|
||||
Scenario: Authors are an ordered list; the first is the primary byline
|
||||
Given article 12 has four authors
|
||||
When the section is open
|
||||
Then the first row shows the label "Primary" (@not-in-design)
|
||||
And each row has a drag handle and a remove button
|
||||
And the hint "Drag to reorder. The first author is the primary byline." is shown
|
||||
And a long name is truncated with an ellipsis and carries the full name in title and aria-label
|
||||
|
||||
Scenario: Reorder with the keyboard
|
||||
Given an author list of three with "Jonas Pereira" second
|
||||
When the user focuses the handle of "Jonas Pereira" and presses Space or Enter
|
||||
Then a live region announces the grabbed-row message of the design (position 2 of 3 and the key instructions)
|
||||
When the user presses ArrowUp
|
||||
Then the row moves to position 1 and the new position is announced
|
||||
When the user presses Space
|
||||
Then the row is dropped there and the status becomes "Unsaved changes"
|
||||
When the user grabs a row, moves it and presses Escape
|
||||
Then the row returns to its original position and the status does not change
|
||||
|
||||
@not-in-design
|
||||
Scenario: Dropped and cancelled announcements
|
||||
Given a row was grabbed
|
||||
When the user drops it with Space
|
||||
Then the live region announces the dropped message with the final position
|
||||
When the user cancels with Escape
|
||||
Then the live region announces the cancelled message and the original position
|
||||
|
||||
Scenario: Reorder with the pointer
|
||||
When the user drags a row
|
||||
Then the row lifts and a line shows the drop position
|
||||
When the user drops it
|
||||
Then the list takes the new order and the status becomes "Unsaved changes"
|
||||
|
||||
Scenario: Remove an author
|
||||
Given the list has "Inès Duarte" and "Jonas Pereira"
|
||||
When the user activates the remove button of "Inès Duarte"
|
||||
Then "Jonas Pereira" becomes "Primary" and the status becomes "Unsaved changes"
|
||||
When the last author is removed
|
||||
Then the list shows "No authors. This article will have no byline."
|
||||
|
||||
Scenario: Saving authors
|
||||
Given the author list was reordered to [Jonas Pereira, Inès Duarte]
|
||||
When the user activates Save
|
||||
Then exactly one PATCH /v1/article/12 is sent whose author_ids follow the list order
|
||||
And a list emptied by the user is sent as author_ids []
|
||||
And fields that did not change are not sent
|
||||
|
||||
Rule: Add author
|
||||
|
||||
Scenario: The combobox
|
||||
When the user activates "Add an author"
|
||||
Then a combobox opens with a "Search authors" field and GET /v1/author/ is requested with size 100
|
||||
And authors already on the article are checked and dimmed
|
||||
When the user activates a checked author
|
||||
Then nothing happens and the author is not added twice
|
||||
When the user picks "Camille Roche"
|
||||
Then she is appended to the list and the status becomes "Unsaved changes"
|
||||
|
||||
Scenario: Searching beyond the first 100 authors
|
||||
Given the install has more than 100 authors
|
||||
When the user types "Zoe" in "Search authors"
|
||||
Then GET /v1/author/ is requested with q "Zoe" and the list shows the matches
|
||||
|
||||
Scenario Outline: "Create author…" only for roles with author rights
|
||||
Given the session role is "<role>"
|
||||
When the combobox is open
|
||||
Then the "Create author…" row is <visible>
|
||||
|
||||
Examples:
|
||||
| role | visible |
|
||||
| owner | shown |
|
||||
| admin | shown |
|
||||
| verified | absent |
|
||||
|
||||
Scenario Outline: No match
|
||||
Given the session role is "<role>"
|
||||
When the user types "Zoe Lambert" and nobody matches
|
||||
Then the message "No author matches "Zoe Lambert"." is shown
|
||||
And <create>
|
||||
|
||||
Examples:
|
||||
| role | create |
|
||||
| owner | the row Create author "Zoe Lambert"… is shown |
|
||||
| verified | no create row is shown |
|
||||
|
||||
Scenario Outline: Authors could not be loaded
|
||||
When GET /v1/author/ fails with <failure> as the combobox opens
|
||||
Then the combobox shows "Couldn't load authors", "Check your connection and try again." and "Try again"
|
||||
And the article's authors in the list are untouched
|
||||
When the user activates "Try again"
|
||||
Then GET /v1/author/ is requested again
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| status 500 |
|
||||
| a network error |
|
||||
|
||||
Rule: Create author dialog (owner and admin)
|
||||
|
||||
Scenario: Open the dialog from the combobox
|
||||
Given the session role is "owner"
|
||||
When the user activates Create author "Zoe Lambert"…
|
||||
Then the dialog "Create author" opens over the editor, with Name prefilled "Zoe Lambert", Slug "zoe-lambert" (Auto, same SlugField as Authors), "Cancel" and "Create author"
|
||||
And the unsaved text of the article is untouched
|
||||
|
||||
Scenario: Create succeeds
|
||||
Given the Create author dialog is filled with name "Zoe Lambert" and slug "zoe-lambert"
|
||||
When the user activates "Create author"
|
||||
Then exactly one POST /v1/author/ is sent with name and slug
|
||||
And the dialog closes, "Zoe Lambert" is appended to the author list and the status becomes "Unsaved changes"
|
||||
And the article's authors are not sent to the API until Save
|
||||
|
||||
Scenario: "This is me" only when the user has no author profile
|
||||
Given the session role is "owner" and GET /v1/auth/me has author null
|
||||
When the Create author dialog is open
|
||||
Then the checkbox "This is me" shows "Links the author to marta@renew.example."
|
||||
And it is unchecked until the user checks it
|
||||
When it is checked and the user creates the author
|
||||
Then POST /v1/author/ is sent with user_id of the current user
|
||||
And the session store now holds that author, so the hint and the Profile card show the profile
|
||||
|
||||
Scenario: No "This is me" when the user already has an author
|
||||
Given GET /v1/auth/me carries an author
|
||||
When the Create author dialog is open
|
||||
Then there is no "This is me" checkbox and no user_id is ever sent
|
||||
|
||||
Scenario: Slug taken
|
||||
When POST /v1/author/ answers 409 author_slug_taken
|
||||
Then the dialog shows "The slug "zoe-lambert" is already used by another author." and the action "Use zoe-lambert-2"
|
||||
And activating the action sets the slug and keeps the name
|
||||
|
||||
Scenario Outline: Create author failed
|
||||
When POST /v1/author/ fails with <failure>
|
||||
Then the dialog stays open with "Couldn't create the author" and "The article is unchanged. Try again in a moment."
|
||||
And the values are kept and the article is unchanged
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| status 500 |
|
||||
| a network error |
|
||||
|
||||
Scenario: Role changed while the dialog was open
|
||||
When POST /v1/author/ answers 403
|
||||
Then the error toast of states.md "Only an owner or admin can change authors" shows and the dialog closes
|
||||
|
||||
Rule: "Create my author profile" hint (every role)
|
||||
|
||||
Scenario Outline: The hint appears
|
||||
Given the session role is "<role>", GET /v1/auth/me has author null, the article has no authors and the hint was never dismissed
|
||||
When the section is open
|
||||
Then the dismissible hint "You don't have an author profile yet" with "Create my author profile" is shown
|
||||
And the list shows "No authors. This article will have no byline."
|
||||
|
||||
Examples:
|
||||
| role |
|
||||
| owner |
|
||||
| admin |
|
||||
| verified |
|
||||
|
||||
Scenario Outline: The hint stays away
|
||||
Given <condition>
|
||||
Then the hint is not shown
|
||||
|
||||
Examples:
|
||||
| condition |
|
||||
| the user already has an author profile |
|
||||
| the article has an author |
|
||||
| the hint was dismissed by this user |
|
||||
|
||||
Scenario: Create my author profile
|
||||
Given the hint is shown
|
||||
When the user activates "Create my author profile"
|
||||
Then exactly one POST /v1/author/me is sent with no body and no dialog opens
|
||||
And on 201 the new author is added to the list as the primary author
|
||||
And the status becomes "Unsaved changes" and the hint disappears
|
||||
And the session store holds the author, so /profile shows the "Created" card
|
||||
|
||||
@not-in-design
|
||||
Scenario: The hint-success toast
|
||||
When POST /v1/author/me answers 201
|
||||
Then a success toast shows "Author profile created" and "Marta Alves is now your byline." (the Profile copy)
|
||||
|
||||
Scenario Outline: Create my author profile fails
|
||||
When POST /v1/author/me fails with <failure>
|
||||
Then the hint stays, an error toast shows "Couldn't create your author profile" and "Try again in a moment."
|
||||
And the article is unchanged
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| status 500 |
|
||||
| a network error |
|
||||
|
||||
Scenario: The profile already exists (409)
|
||||
When POST /v1/author/me answers 409 user_already_linked
|
||||
Then GET /v1/auth/me is requested again, the hint disappears and the existing author is offered in the combobox
|
||||
|
||||
Scenario: Dismissing the hint
|
||||
When the user dismisses the hint
|
||||
Then it disappears and does not come back for this user in this browser, on later visits to any article
|
||||
And the dismissal is kept in localStorage keyed by user id
|
||||
And dismissing sends no request
|
||||
|
||||
Rule: Editor failures and keyboard
|
||||
|
||||
Scenario: The sites could not be loaded
|
||||
Given GET /v1/site/ fails
|
||||
When the user opens the editor
|
||||
Then the editor still loads, no Site select is rendered and no notice is shown
|
||||
|
||||
Scenario Outline: Save is refused for a site or author that no longer exists
|
||||
Given the user changed the <what>
|
||||
When PATCH /v1/article/12 answers 404 <code>
|
||||
Then the existing "Save failed" state is shown with the changes kept
|
||||
And an info toast "<toast>" shows and the lists are reloaded
|
||||
|
||||
Examples:
|
||||
| what | code | toast |
|
||||
| site | site_not_found | That site no longer exists |
|
||||
| author | author_not_found | That author no longer exists |
|
||||
|
||||
Scenario: Existing save behaviour is kept
|
||||
Given the user changed only the site or the authors
|
||||
Then "Save and publish", the leave-confirmation dialog, Ctrl/Cmd S and the draft kept in sessionStorage after a session expiry treat the change like any other edit
|
||||
And the restored draft shows "Unsaved changes" with the chosen site and author order
|
||||
|
||||
Rule: Articles list: Authors column, Site column and Site filter
|
||||
|
||||
Scenario Outline: Authors column on every frame
|
||||
Given an article with <authors>
|
||||
When the list is shown
|
||||
Then the Authors cell shows <cell>
|
||||
|
||||
Examples:
|
||||
| authors | cell |
|
||||
| no authors | "—" |
|
||||
| one author | one avatar |
|
||||
| three | three avatars, no "+N" |
|
||||
| five | three avatars and "+2" |
|
||||
|
||||
Scenario: Mobile Authors cell, and no site on the cards
|
||||
Given the viewport is 390 px wide
|
||||
Then the card shows the avatars with the first name of the first author, as drawn in the board
|
||||
And the card shows no site, as in the design
|
||||
|
||||
Scenario: One site: no Site column and no filter
|
||||
Given the install has one site
|
||||
Then the list has an Authors column, no Site column and no Site filter
|
||||
|
||||
Scenario: Several sites: Site column and Site filter
|
||||
Given the install has several sites
|
||||
Then the list has a Site column showing the site name as read-only text for every role
|
||||
And the toolbar has a Site select with "All sites" and every site name
|
||||
|
||||
Scenario: Choosing a site
|
||||
Given the list is on page 3
|
||||
When the user chooses "Journal" in the Site filter
|
||||
Then the URL becomes /article?site=journal with the page omitted
|
||||
And GET /v1/article/?site=journal is requested with the current q, status and sort
|
||||
And the tab counts still show the full list
|
||||
|
||||
Scenario Outline: Other changes keep the site
|
||||
Given the URL holds ?site=journal
|
||||
When the user <action>
|
||||
Then ?site=journal stays in the URL
|
||||
|
||||
Examples:
|
||||
| action |
|
||||
| searches for "plaster" |
|
||||
| changes the tab |
|
||||
| changes the sort |
|
||||
| goes to page 2 |
|
||||
| changes the page size |
|
||||
|
||||
Scenario: Choosing "All sites"
|
||||
Given the URL holds ?site=journal
|
||||
When the user chooses "All sites"
|
||||
Then site leaves the URL, the page resets to 1 and the full list is requested
|
||||
|
||||
Scenario: Opening a URL with a site
|
||||
When the user opens /article?site=journal&page=2
|
||||
Then the Site filter shows "Journal" and GET /v1/article/?site=journal&page=2 is requested
|
||||
|
||||
Scenario Outline: A site key that cannot be used is dropped silently
|
||||
Given the URL holds <query>
|
||||
Then the key is removed from the URL with no notice and the unfiltered list is requested
|
||||
|
||||
Examples:
|
||||
| query |
|
||||
| ?site=nope |
|
||||
| ?site=renew on a one-site install |
|
||||
|
||||
Scenario: No articles on a site
|
||||
Given ?site=archive and the response is empty with no search and the tab "All"
|
||||
Then the state shows "No articles on "Archive"", "Move articles here from the editor, or choose another site." and "Show all sites"
|
||||
When the user activates "Show all sites"
|
||||
Then site leaves the URL and the full list is requested
|
||||
|
||||
Scenario: Zero results on a site with a search or another tab
|
||||
Given ?site=archive and a search or a tab other than "All" is active
|
||||
Then the existing "No results" or "No drafts" state is shown
|
||||
|
||||
Scenario: Existing states keep working with a site
|
||||
Then Loading, Empty, row actions, delete and pagination states behave as in docs/design/screens/articles.md with the site filter applied
|
||||
|
||||
Scenario: The sites could not be loaded
|
||||
Given GET /v1/site/ fails
|
||||
Then the list renders as a single-site install, with no Site column and no Site filter
|
||||
|
||||
Rule: Profile: the Author profile card
|
||||
|
||||
Scenario: Position and roles
|
||||
Then in every Profile frame the "Author profile" card sits between "Account" and "Password"
|
||||
And it is shown to every role
|
||||
|
||||
Scenario: Loading
|
||||
When GET /v1/auth/me is pending
|
||||
Then the card shows a skeleton
|
||||
|
||||
Scenario: No author profile
|
||||
Given GET /v1/auth/me has author null
|
||||
Then the card shows the copy of state 1, a "Create my author profile" button and the line "Uses your display name, Marta Alves. You can change it afterwards."
|
||||
|
||||
@not-in-design
|
||||
Scenario: No author profile and no display name
|
||||
Given GET /v1/auth/me has author null and no display name
|
||||
Then the line reads "Uses your username, marta. You can change it afterwards."
|
||||
|
||||
Scenario: Create succeeds
|
||||
When the user activates "Create my author profile"
|
||||
Then exactly one POST /v1/author/me is sent
|
||||
And on 201 the card shows "Author profile created", "Marta Alves is now your byline." and "Edit author" with "Linked to your account. New articles start with this author."
|
||||
And the session store holds the author
|
||||
|
||||
Scenario: Slug adjusted
|
||||
Given the 201 answer has slug_adjusted true and the slug "marta-alves-2"
|
||||
Then no error is shown and the card also shows "Your slug is marta-alves-2" with "marta-alves was already used by another author. You can change it in Edit author."
|
||||
|
||||
Scenario Outline: The notice follows slug_adjusted, never the slug itself
|
||||
Given the author's name is "<name>"
|
||||
And the 201 answer has the slug "<returned>" and slug_adjusted <flag>
|
||||
Then the card <notice> the "Your slug is" notice
|
||||
|
||||
Examples:
|
||||
| name | returned | flag | notice |
|
||||
| Marta Alves | marta-alves | false | does not show |
|
||||
| Marta Alves | marta-alves-2 | true | shows |
|
||||
| Inès Duarte | ines-duarte-2 | true | shows |
|
||||
| Jean-Luc O'Neil | jean-luc-o-neil | false | does not show |
|
||||
| Zoë Müller | zoe-muller | false | does not show |
|
||||
| Agent 007 | agent-007 | false | does not show |
|
||||
| Agent 007 | agent-007-2 | true | shows |
|
||||
|
||||
Scenario Outline: Create fails
|
||||
When POST /v1/author/me fails with <failure>
|
||||
Then the card shows "Couldn't create your author profile" and "Try again in a moment." and the button stays available
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| status 500 |
|
||||
| a network error |
|
||||
|
||||
Scenario: Profile already exists (409)
|
||||
When POST /v1/author/me answers 409 user_already_linked
|
||||
Then GET /v1/auth/me is requested again and the card shows the existing author
|
||||
|
||||
Scenario: Existing author on load
|
||||
Given GET /v1/auth/me carries an author
|
||||
Then the card shows the author's name, "Edit author" and "Linked to your account. New articles start with this author." without the "Created" banner
|
||||
|
||||
Scenario: Edit own author without author rights
|
||||
Given the session role has no author rights and has a linked author
|
||||
When the user activates "Edit author"
|
||||
Then the author form opens titled "Edit Marta Alves" with Linked user "marta@renew.example" locked, "This is your author profile. Only an owner or admin can change the link." and "Save changes"
|
||||
And there is no "Unlink" and no delete action on the form or on the screen
|
||||
When the user saves a change
|
||||
Then exactly one PATCH /v1/author/{id} is sent with only the changed fields
|
||||
|
||||
@not-in-design
|
||||
Scenario: Edit author as owner or admin
|
||||
Given the session role is "owner" or "admin" with a linked author
|
||||
When the user activates "Edit author" on /profile
|
||||
Then the existing AuthorFormPanel opens in place, on /profile, with "Unlink" allowed
|
||||
|
||||
Scenario: Language and session
|
||||
Given the language is "fr"
|
||||
Then every new string on the three screens is the French message
|
||||
And a 401 on any new call lands on /login?redirect=<path>&reason=expired and a 403 from an unverified account lands on /register/pending
|
||||
|
||||
Rule: Catalogue and copy left over from the Sites and Authors screens
|
||||
|
||||
Scenario: Authors routes in the API catalogue
|
||||
Then web/src/data/apiCatalogue.ts has an "Authors" group and an "Authors · owner and admin" group covering every /v1/author route, including POST /v1/author/me as a session-only route
|
||||
And web/src/data/__tests__/apiCatalogue.spec.ts covers them
|
||||
|
||||
Scenario: French strings for the catalogue groups
|
||||
Given the language is "fr"
|
||||
Then the catalogue group titles "Sites", "Sites · owner only", "Authors" and "Authors · owner and admin" and their descriptions have French entries
|
||||
|
||||
Scenario: Session-only rows
|
||||
Then the session-only routes of Sites and Authors appear in SESSION_GROUPS with SESSION_REQUIRED_MESSAGE
|
||||
|
||||
Scenario: The owner_only copy of the design board
|
||||
Then the 403 page copy "You don't have access to Sites" with "Only the owner can manage sites. Ask the owner of this site if you need a change."
|
||||
And the action toast "Only the owner can manage sites" with "Ask the owner of this site."
|
||||
exist in the SPA and have French entries
|
||||
@@ -0,0 +1,64 @@
|
||||
# QA: Sites and authors in Editor, Articles and Profile
|
||||
|
||||
SPA slice only. No Go change: the API already exists (commits "Sites feature backend" and "Authors feature backend"). A wrong contract is reported as a backend defect.
|
||||
|
||||
Roles: the domain defines only `owner` and `admin` (`internal/domain/user`). There is no "member" constant. "verified" in the feature and below is a verified account without the owner or admin role, which has no author rights. The earlier `sites_authors_screens` files still say "member" for the same account; that wording is left as is.
|
||||
|
||||
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s `request` per case for GET /v1/site/, GET /v1/author/, POST /v1/author/, POST /v1/author/me, GET /v1/auth/me, GET and PATCH /v1/article/{id} and GET /v1/article/. Mount EditorView, ArticlesView and ProfileView inside DashboardShell with a resolved session and a test router. Roles: "owner" (Marta Alves, no author), "admin", "verified". Mock matchMedia for desktop and mobile. Use localStorage fakes for the remembered section state and the dismissed hint. Do not freeze the system date. Report the web tests as run or NOT RUN, never as passing when not run.
|
||||
|
||||
Regression: every existing Editor, Articles, ArticlesPagination, ArticlesPageSizeMemory and Profile spec now sees the new calls. Give them default mocks (one site, one author, author null where relevant) rather than loosening unmocked-call checks, and show that they still pass.
|
||||
|
||||
1. MetaSection: collapsed summary "Inès Duarte"; open/closed Examples (sites x authors); remembered choice wins over auto-open; same key in the desktop sidebar and the mobile sheet; mobile closed in the Details sheet; loading skeleton; no section on 404; summary variants.
|
||||
2. SiteSelect: absent with one site; present with helper "Canonical host: ..."; options with name and host and the default first; choosing marks "Unsaved changes"; the PATCH contains `site_id` and only the changed fields; GET /v1/site/ failure renders no select and no notice.
|
||||
3. AuthorList: Primary label, handle, remove, truncation with title and aria-label; keyboard reorder (grab, ArrowUp/Down, drop, Escape, live region text including dropped and cancelled); pointer reorder (use the sortable test helper; assert order and "Unsaved changes"); removing the last author shows the no-byline text; PATCH `author_ids` order, `[]` when emptied, absent when unchanged.
|
||||
4. AuthorSelect: opens with GET /v1/author/ size 100; only sizes 5, 25, 50 or 100 are ever sent (other sizes answer 400); `q` search beyond the first 100; checked and dimmed items do nothing when activated; append on pick; "Create author…" per role (owner, admin, verified); no-match message with and without the create row; error state with "Try again".
|
||||
5. Create author dialog: prefilled name and auto slug; POST body; appended to the list and "Unsaved changes"; the article's authors are not sent before Save; "This is me" only when author is null (default unchecked) and sends `user_id`; session store updated; slug taken and the "Use zoe-lambert-2" action; failure Examples; 403 toast.
|
||||
6. CreateAuthorHint: shown by role Examples; hidden for author present, article with authors, dismissed; POST /v1/author/me with no body and no dialog; list and toast; session updated; failure Examples; 409 refetch; dismissal stored in localStorage by user id and never re-shown; no request on dismiss.
|
||||
7. Editor save: a site-only or author-only edit enables Save, "Save and publish", leave-confirmation and the sessionStorage draft restore; 404 `site_not_found` / `author_not_found` handling with the "Save failed" state and the info toast.
|
||||
8. ArticlesView: Authors cell Examples (0, 1, 3, 5 authors); mobile cell and no site on the cards; Site column and filter only with several sites; `?site=` read, written, combined with q/tab/sort/size/page and page reset; "All sites"; unknown key and single-site key dropped silently; "No articles on "Archive"" and "Show all sites"; "No results"/"No drafts" with a site and a search or tab; GET /v1/site/ failure falls back to single-site; tab counts unchanged.
|
||||
9. ProfileView AuthorProfileCard: position between Account and Password for all roles; skeleton while loading; states 1 to 5 (no profile, error, created, created with adjusted slug, edit own author with locked link and no Unlink or delete); owner/admin Edit author reuses AuthorFormPanel with Unlink allowed; POST /v1/author/me count; 409 refetch; author present on load.
|
||||
10. Slug adjusted comes from the server: POST /v1/author/me returns `slug_adjusted` and the SPA shows the "Your slug is" notice from that flag only. Vitest cases feed mocked responses (accented names, apostrophes, collision `marta-alves-2`, `agent-007` with the flag false) and check `baseSlugOf` strips only the one collision suffix. The slug rule itself is covered in Go (`features/authors_backend.feature`, `test/authors_backend_http_test.go`).
|
||||
11. i18n: every new `t()` key has a French entry (extend the i18n spec); one French walk through the three screens.
|
||||
12. Catalogue group (final scenario group): the Authors routes and groups in `apiCatalogue.ts` and its spec, French titles and descriptions for the Sites and Authors groups, Sites and Authors rows in `SESSION_GROUPS` with `SESSION_REQUIRED_MESSAGE`, and the owner_only 403 page and toast copy with French entries.
|
||||
|
||||
Handoff note for the coder: a grep of the SPA after the earlier commit found the Sites catalogue groups ("Sites", "Sites · owner only") but no `/v1/author` group, no French entry for "Sites · owner only", and no `owner_only` copy in `web/src`; `SESSION_REQUIRED_MESSAGE` and `SESSION_GROUPS` exist. Treat item 12 as work to do, and verify each part before adding it.
|
||||
|
||||
## Visual (`task visual:check -- <screen>`, desktop and mobile, light and dark)
|
||||
|
||||
Setups go in `web/visual/states.ts` (one setup per state, mocked /v1, the board's own sample data, no `skip` unless the state depends on the date). Hard gate: every capture within 10 %, with no per-state exception. Reference frames: `AppFrame.dc.html` (section, Articles columns, Profile card) and `DirectoryFrame.dc.html` (section content).
|
||||
|
||||
- Editor, all existing frames that show the sidebar: the section collapsed with the summary "Inès Duarte" (one site, one author). Their existing setups need default mocks for /v1/site/ and the article's authors. Not found has none. Loading shows the skeleton summary.
|
||||
- Editor: site and authors (16): open one site, several sites, Site select open, no authors with hint, many authors, add open, no match, add open (no create), no match (no create), add error, reorder keyboard, reorder pointer, create dialog, for me, slug taken, failed. The "(no create)" frames use a verified session.
|
||||
- Articles (existing frames + pagination frames): the Authors column in every frame. Articles: sites and authors (3): several sites, Site filter open, no articles on a site.
|
||||
- Profile (existing frames): the Author profile card in its default state in every frame. Profile: author profile (5): no profile, error, created, slug adjusted, edit own author.
|
||||
- Re-run Settings, Users and Home too: report any capture that moves above 10 %. Any failure needs the user's decision (an `approvedBy` exception or a design update).
|
||||
|
||||
## Exit conditions
|
||||
|
||||
- All existing Editor, Articles and Profile frames pass `task visual:check`, with no per-state exception.
|
||||
- The new frames pass.
|
||||
- Settings, Users and Home are re-run for regressions.
|
||||
- Vitest specs are reported honestly, run or NOT RUN.
|
||||
|
||||
## Copy and behaviour not in the design (feed back to docs/design later)
|
||||
|
||||
Each has a French entry in `web/src/i18n/messages.ts`.
|
||||
|
||||
1. Editor hint-success toast: "Author profile created" / "Marta Alves is now your byline." (reuses the Profile copy).
|
||||
2. The row label "Primary".
|
||||
3. The "dropped" and "cancelled" live-region announcements (only "grabbed" is in the design).
|
||||
4. Collapsed-summary variants: "Inès Duarte +2", "No authors", "Journal · Inès Duarte", "Journal · No authors".
|
||||
5. Profile line without a display name: "Uses your username, marta. You can change it afterwards."
|
||||
6. Profile "Edit author" for owner and admin: AuthorFormPanel in place on /profile with Unlink allowed.
|
||||
|
||||
## Known gap (no change in this slice)
|
||||
|
||||
`GET /media/{id}/usage` returns articles only. An image used as an author avatar or a site social image can therefore look unused in the Media detail panel and in the delete confirmation. This needs a Go change and is not part of this slice.
|
||||
|
||||
## Manual checks screenshots cannot cover
|
||||
|
||||
- Keyboard: tab order through the section, the Site select, handles, remove buttons, "Add an author", the dialog and the hint close button; focus returns to the opener after the dialog; the live region reads the grabbed, moved, dropped and cancelled messages in a screen reader.
|
||||
- Pointer: drag feel, the drop line, scroll while dragging on mobile.
|
||||
- Real `task dev`: an owner creates an author inline with "This is me", then opens /profile; a verified account creates a profile from the hint, saves, and the Authors column shows the avatar; filter by site and share the URL.
|
||||
@@ -11,7 +11,7 @@ Feature: Sites and Authors screens
|
||||
DirectoryList, DirectoryPanel (the slide-over), ReassignDialog, SlugField and MediaField
|
||||
And the screens call, with relative URLs through useApi only, GET, POST, PATCH and DELETE on
|
||||
/v1/site/ and /v1/author/, PATCH /v1/site/{id}/default and GET /v1/user/
|
||||
And POST /v1/author/me is not exposed in the UI by this slice
|
||||
And POST /v1/author/me and the changes to the Editor, Articles and Profile screens are specified in features/sites_authors_integration.feature
|
||||
And a site has the fields id, name, key, base_url, default_social_image_id, default_robots
|
||||
("index" or "noindex"), locale, is_default and article_count
|
||||
And an author has the fields id, name, slug, bio, avatar_id, website, social_links, user_id,
|
||||
@@ -630,5 +630,5 @@ Feature: Sites and Authors screens
|
||||
When the user opens each state of both screens
|
||||
Then every visible string is the French message and none falls back to English
|
||||
|
||||
Scenario: Existing screens are unchanged
|
||||
Then the Editor, Articles and Profile views and their specs are not modified by this slice
|
||||
Scenario: Editor, Articles and Profile
|
||||
Then their changes are specified in features/sites_authors_integration.feature
|
||||
@@ -32,12 +32,12 @@ Authors (AuthorsView):
|
||||
19. Row menu: guest has Edit, Link user, Delete…; linked has Edit, Delete…; mobile sheet.
|
||||
20. Read-only list and detail for member (no button, no menu, no bottom button, search works, no email); `?edit=` opens the read-only detail.
|
||||
21. Form: Add empty copy; slug from the name; add succeeds with the exact POST body; slug collision and suggestions; validation Examples; server codes Examples (bio_too_long, invalid_url, user_already_linked); social links (types, placeholders, limit of 5, bad link); linked user options computed from GET /v1/user/ minus user_ids paged from GET /v1/author/?size=100, and "Every user already has an author."; edit linked; unlink (PATCH user_id null, toast); re-link; avatar picker; save failed Examples; 403 author_rights toast; deep link and unknown slug toast.
|
||||
22. Own author by deep link only: member opens `/authors?edit=<own slug>` (own author from GET /v1/auth/me), form with locked link and no delete, PATCH on save; another slug opens the read-only detail. No test calls POST /v1/author/me, because the UI does not expose it in this slice.
|
||||
22. Own author by deep link only: member opens `/authors?edit=<own slug>` (own author from GET /v1/auth/me), form with locked link and no delete, PATCH on save; another slug opens the read-only detail. POST /v1/author/me is covered by features/sites_authors_integration.qa.md.
|
||||
23. Delete: reassign (copy, linked line, first other author preselected, one DELETE ?reassign_to=2, "Reassigning…", toast); choose who (no deleted author, "Nobody" last); nobody sends `reassign_to=none`; failed Examples; 409 fallback dialog and "Reassign articles"; no articles; 404.
|
||||
24. 401 and 403 app-wide redirects on both routes.
|
||||
25. French: `setLocale('fr')`, walk the list, form, delete and 403 page of each screen, no English source string remains.
|
||||
26. Messages: every new `t()` key has a French entry (extend the i18n spec). Router: /settings/sites and /authors are registered and verified.
|
||||
27. Regression: the existing Editor, Articles, Profile specs pass unchanged.
|
||||
27. Editor, Articles and Profile changes and their regression specs are covered by features/sites_authors_integration.qa.md.
|
||||
|
||||
## Visual (`task visual:check -- <screen>`, desktop and mobile, light and dark)
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ import (
|
||||
|
||||
const maxOwnSlugAttempts = 50
|
||||
|
||||
type MyAuthorCreated struct {
|
||||
Author author.Author
|
||||
SlugAdjusted bool
|
||||
}
|
||||
|
||||
type CreateMyAuthorUseCase struct {
|
||||
authors author.Repository
|
||||
users user.Repository
|
||||
@@ -21,10 +26,10 @@ func NewCreateMyAuthorUseCase(authors author.Repository, users user.Repository,
|
||||
return &CreateMyAuthorUseCase{authors: authors, users: users, recorder: recorder}
|
||||
}
|
||||
|
||||
func (u *CreateMyAuthorUseCase) Create(ctx context.Context, userID uint32) (author.Author, error) {
|
||||
func (u *CreateMyAuthorUseCase) Create(ctx context.Context, userID uint32) (MyAuthorCreated, error) {
|
||||
caller, err := u.users.Get(userID)
|
||||
if err != nil {
|
||||
return author.Author{}, err
|
||||
return MyAuthorCreated{}, err
|
||||
}
|
||||
name := authorNameOf(caller)
|
||||
event := audit.NewEvent(audit.ActionAuthorCreate, audit.CategoryAuthors)
|
||||
@@ -32,11 +37,11 @@ func (u *CreateMyAuthorUseCase) Create(ctx context.Context, userID uint32) (auth
|
||||
created, err := u.create(caller, name)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return author.Author{}, err
|
||||
return MyAuthorCreated{}, err
|
||||
}
|
||||
event.Target = authorTarget(created)
|
||||
u.recorder.Record(ctx, event)
|
||||
return created, nil
|
||||
return MyAuthorCreated{Author: created, SlugAdjusted: created.Slug != author.GenerateSlug(name)}, nil
|
||||
}
|
||||
|
||||
func authorNameOf(caller user.User) string {
|
||||
|
||||
@@ -100,9 +100,10 @@ func TestAuthorUseCases(t *testing.T) {
|
||||
authors.fail.failNextWith("Create", author.ErrSlugTaken)
|
||||
uc := useCases.NewCreateMyAuthorUseCase(authors, users, discardRecorder)
|
||||
|
||||
created, err := uc.Create(context.Background(), 1)
|
||||
result, err := uc.Create(context.Background(), 1)
|
||||
created := result.Author
|
||||
|
||||
if err != nil || created.Slug != "marta-alves-2" {
|
||||
if err != nil || created.Slug != "marta-alves-2" || !result.SlugAdjusted {
|
||||
t.Errorf("created %+v %v", created, err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -60,9 +60,10 @@ func TestAuthorUseCaseGuards(t *testing.T) {
|
||||
}
|
||||
uc := useCases.NewCreateMyAuthorUseCase(authors, users, discardRecorder)
|
||||
|
||||
created, err := uc.Create(context.Background(), 1)
|
||||
result, err := uc.Create(context.Background(), 1)
|
||||
created := result.Author
|
||||
|
||||
if err != nil || created.Slug != author.SlugWithSuffix(base, 50) {
|
||||
if err != nil || created.Slug != author.SlugWithSuffix(base, 50) || !result.SlugAdjusted {
|
||||
t.Errorf("created %+v %v", created, err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -420,7 +420,7 @@ func TestAuthorsBackendOwnAuthor(t *testing.T) {
|
||||
rec := e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", "")
|
||||
|
||||
body := decodeAuthorBody(t, rec)
|
||||
if rec.Code != http.StatusCreated || body["name"] != c.name || body["slug"] != c.slug || body["user_id"] != float64(authorEditorID) || body["is_linked"] != true {
|
||||
if rec.Code != http.StatusCreated || body["name"] != c.name || body["slug"] != c.slug || body["user_id"] != float64(authorEditorID) || body["is_linked"] != true || body["slug_adjusted"] != false {
|
||||
t.Errorf("status %d body %v", rec.Code, body)
|
||||
}
|
||||
})
|
||||
@@ -446,7 +446,7 @@ func TestAuthorsBackendOwnAuthor(t *testing.T) {
|
||||
|
||||
rec := e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", "")
|
||||
|
||||
if body := decodeAuthorBody(t, rec); rec.Code != http.StatusCreated || body["slug"] != "marta-alves-3" {
|
||||
if body := decodeAuthorBody(t, rec); rec.Code != http.StatusCreated || body["slug"] != "marta-alves-3" || body["slug_adjusted"] != true {
|
||||
t.Errorf("status %d body %v", rec.Code, body)
|
||||
}
|
||||
})
|
||||
@@ -459,9 +459,20 @@ func TestAuthorsBackendOwnAuthor(t *testing.T) {
|
||||
|
||||
rec := e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", "")
|
||||
|
||||
slug, _ := decodeAuthorBody(t, rec)["slug"].(string)
|
||||
if rec.Code != http.StatusCreated || utf8.RuneCountInString(slug) > 100 || !strings.HasSuffix(slug, "-2") {
|
||||
t.Errorf("status %d slug %q", rec.Code, slug)
|
||||
body := decodeAuthorBody(t, rec)
|
||||
slug, _ := body["slug"].(string)
|
||||
if rec.Code != http.StatusCreated || utf8.RuneCountInString(slug) > 100 || !strings.HasSuffix(slug, "-2") || body["slug_adjusted"] != true {
|
||||
t.Errorf("status %d slug %q body %v", rec.Code, slug, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Only the own-author creation carries slug_adjusted", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
|
||||
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/author/", `{"name": "Camille Roche"}`)
|
||||
|
||||
if _, present := decodeAuthorBody(t, rec)["slug_adjusted"]; rec.Code != http.StatusCreated || present {
|
||||
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -125,7 +125,9 @@ function moveFocus(event: KeyboardEvent) {
|
||||
<USeparator class="my-1" />
|
||||
|
||||
<div role="group" :aria-labelledby="themeLabelId">
|
||||
<div :id="themeLabelId" :class="SECTION_LABEL_CLASS">{{ t('Theme') }}</div>
|
||||
<div :id="themeLabelId" :class="SECTION_LABEL_CLASS">
|
||||
{{ t('Theme') }}
|
||||
</div>
|
||||
<UButton
|
||||
v-for="option in colorModeOptions"
|
||||
:key="option.value"
|
||||
|
||||
@@ -4,9 +4,18 @@ import { computed } from 'vue'
|
||||
export type ActivityBadgeTone = 'ok' | 'fail' | 'security' | 'deleted' | 'neutral'
|
||||
|
||||
const TONES: Record<ActivityBadgeTone, { class: string; icon?: string }> = {
|
||||
ok: { class: 'border border-accented bg-transparent text-toned', icon: 'i-lucide-check' },
|
||||
fail: { class: 'bg-(--rc-error-soft) text-error', icon: 'i-lucide-circle-x' },
|
||||
security: { class: 'bg-(--rc-warning-soft) text-warning', icon: 'i-lucide-shield-alert' },
|
||||
ok: {
|
||||
class: 'border border-accented bg-transparent text-toned',
|
||||
icon: 'i-lucide-check',
|
||||
},
|
||||
fail: {
|
||||
class: 'bg-(--rc-error-soft) text-error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
},
|
||||
security: {
|
||||
class: 'bg-(--rc-warning-soft) text-warning',
|
||||
icon: 'i-lucide-shield-alert',
|
||||
},
|
||||
deleted: {
|
||||
class: 'border border-accented bg-transparent text-toned',
|
||||
icon: 'i-lucide-trash-2',
|
||||
@@ -29,6 +38,9 @@ const config = computed(() => TONES[props.tone])
|
||||
:label="label"
|
||||
:icon="config.icon"
|
||||
:class="config.class"
|
||||
:ui="{ base: 'h-5 shrink-0 gap-1 px-1.5 whitespace-nowrap', leadingIcon: 'size-3' }"
|
||||
:ui="{
|
||||
base: 'h-5 shrink-0 gap-1 px-1.5 whitespace-nowrap',
|
||||
leadingIcon: 'size-3',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -136,19 +136,27 @@ function onOpenChange(isOpen: boolean) {
|
||||
}}</span>
|
||||
</dd>
|
||||
<dt class="text-muted">{{ t('Actor') }}</dt>
|
||||
<dd class="m-0 min-w-0"><ActivityActor :actor="current.actor" /></dd>
|
||||
<dd class="m-0 min-w-0">
|
||||
<ActivityActor :actor="current.actor" />
|
||||
</dd>
|
||||
<dt class="text-muted">{{ t('Source') }}</dt>
|
||||
<dd class="m-0 text-highlighted">{{ sourceLabel(current.source) }}</dd>
|
||||
<dd class="m-0 text-highlighted">
|
||||
{{ sourceLabel(current.source) }}
|
||||
</dd>
|
||||
<dt class="text-muted">{{ t('IP address') }}</dt>
|
||||
<dd v-if="current.ip" class="m-0 font-mono text-xs/5 text-highlighted">
|
||||
{{ current.ip }}
|
||||
</dd>
|
||||
<dd v-else class="m-0 text-muted">{{ t('Not applicable') }}</dd>
|
||||
<dd v-else class="m-0 text-muted">
|
||||
{{ t('Not applicable') }}
|
||||
</dd>
|
||||
<dt class="text-muted">{{ t('Browser') }}</dt>
|
||||
<dd v-if="current.user_agent" class="m-0 text-highlighted">
|
||||
{{ current.user_agent }}
|
||||
</dd>
|
||||
<dd v-else class="m-0 text-muted">{{ t('Not applicable') }}</dd>
|
||||
<dd v-else class="m-0 text-muted">
|
||||
{{ t('Not applicable') }}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<section
|
||||
@@ -156,7 +164,9 @@ function onOpenChange(isOpen: boolean) {
|
||||
class="flex flex-col gap-2"
|
||||
data-testid="activity-target-section"
|
||||
>
|
||||
<h3 class="text-base font-semibold text-highlighted">{{ t('Target') }}</h3>
|
||||
<h3 class="text-base font-semibold text-highlighted">
|
||||
{{ t('Target') }}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-1.5 border border-default px-3 py-2.5">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<template v-if="!current.target.exists">
|
||||
@@ -191,7 +201,9 @@ function onOpenChange(isOpen: boolean) {
|
||||
</section>
|
||||
|
||||
<section v-if="current.changes.length > 0" class="flex flex-col gap-2">
|
||||
<h3 class="text-base font-semibold text-highlighted">{{ t('Changes') }}</h3>
|
||||
<h3 class="text-base font-semibold text-highlighted">
|
||||
{{ t('Changes') }}
|
||||
</h3>
|
||||
<ActivityDiff :changes="current.changes" :is-desktop="isDesktop" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,10 @@ const CLOSE_BUTTON = {
|
||||
|
||||
const preparing = computed(() => props.stage === 'preparing')
|
||||
const rangeItems = computed(() =>
|
||||
EXPORT_RANGE_KEYS.map((key) => ({ value: key, label: exportRangeLabel(key) })),
|
||||
EXPORT_RANGE_KEYS.map((key) => ({
|
||||
value: key,
|
||||
label: exportRangeLabel(key),
|
||||
})),
|
||||
)
|
||||
const showCustomFields = computed(() => range.value === 'custom')
|
||||
|
||||
@@ -78,7 +81,10 @@ function onOpenChange(value: boolean) {
|
||||
<UFormField
|
||||
:label="t('Date range')"
|
||||
:help="t('Default is all time. Filters on the page are ignored.')"
|
||||
:ui="{ label: FIELD_LABEL_CLASS, help: 'mt-1.5 text-xs text-muted' }"
|
||||
:ui="{
|
||||
label: FIELD_LABEL_CLASS,
|
||||
help: 'mt-1.5 text-xs text-muted',
|
||||
}"
|
||||
>
|
||||
<USelect
|
||||
v-model="range"
|
||||
@@ -103,7 +109,9 @@ function onOpenChange(value: boolean) {
|
||||
icon="i-lucide-calendar"
|
||||
:aria-label="t('Start date')"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm' }"
|
||||
:ui="{
|
||||
base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm',
|
||||
}"
|
||||
/>
|
||||
<UInput
|
||||
v-model="to"
|
||||
@@ -111,7 +119,9 @@ function onOpenChange(value: boolean) {
|
||||
icon="i-lucide-calendar"
|
||||
:aria-label="t('End date')"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm' }"
|
||||
:ui="{
|
||||
base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -72,7 +72,12 @@ function onOutcome(outcome: OutcomeFilter) {
|
||||
<span class="font-medium text-highlighted">{{ t('Category') }}</span>
|
||||
<ActivityCategorySelect
|
||||
:category="filters.category"
|
||||
@select="emit('update:filters', { ...filters, category: $event })"
|
||||
@select="
|
||||
emit('update:filters', {
|
||||
...filters,
|
||||
category: $event,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
|
||||
@@ -74,7 +74,10 @@ function keepOpenOnOutsideInteraction(event: Event) {
|
||||
:close="false"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:title="t('You won\'t be able to see this key again.')"
|
||||
:ui="{ root: 'gap-2.5 px-3 py-2.5', title: 'text-sm font-medium' }"
|
||||
:ui="{
|
||||
root: 'gap-2.5 px-3 py-2.5',
|
||||
title: 'text-sm font-medium',
|
||||
}"
|
||||
/>
|
||||
<CodeSnippet ref="snippet" :tabs="keyTabs" />
|
||||
<span class="text-muted">{{
|
||||
|
||||
@@ -42,7 +42,10 @@ const summary = computed(() => {
|
||||
})
|
||||
|
||||
const sizeItems = computed(() =>
|
||||
ARTICLE_PAGE_SIZES.map((value) => ({ label: t('{0} per page', [value]), value })),
|
||||
ARTICLE_PAGE_SIZES.map((value) => ({
|
||||
label: t('{0} per page', [value]),
|
||||
value,
|
||||
})),
|
||||
)
|
||||
|
||||
const FOCUS_RING =
|
||||
|
||||
@@ -3,6 +3,8 @@ import { t } from '@/i18n'
|
||||
|
||||
import { useRouter } from 'vue-router'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import AuthorStack from '@/components/AuthorStack.vue'
|
||||
import { authorsSummary } from '@/lib/articleMeta'
|
||||
import { formatRelativeDate } from '@/lib/relativeDate'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
|
||||
@@ -140,6 +142,14 @@ function onRowClick(article: Article, event: MouseEvent) {
|
||||
{{ formatRelativeDate(article.updated_at, now) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2 text-xs text-muted">
|
||||
<AuthorStack
|
||||
v-if="article.authors?.length"
|
||||
:authors="article.authors"
|
||||
:size="22"
|
||||
/>
|
||||
<span class="truncate">{{ authorsSummary(article.authors ?? []) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<UDropdownMenu
|
||||
:items="actionsFor(article)"
|
||||
|
||||
@@ -110,6 +110,7 @@ async function copyUrl() {
|
||||
{{ t('Details') }}
|
||||
</h2>
|
||||
</template>
|
||||
<slot name="meta" :sheet="false" />
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
|
||||
<dt class="text-muted">{{ t('Created') }}</dt>
|
||||
<dd class="text-highlighted">{{ createdLabel }}</dd>
|
||||
@@ -144,9 +145,28 @@ async function copyUrl() {
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<USlideover v-model:open="open" side="right" :title="t('Details')" class="lg:hidden">
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="t('Details')"
|
||||
:close="{
|
||||
'aria-label': t('Close'),
|
||||
class: 'size-11 justify-center text-muted',
|
||||
ui: { leadingIcon: 'size-4' },
|
||||
}"
|
||||
class="lg:hidden"
|
||||
:ui="{
|
||||
content:
|
||||
'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||
header: 'min-h-0 gap-3 px-4 pt-2 pb-0 sm:px-4',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: 'sr-only',
|
||||
body: 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pt-3 pb-4 sm:px-4',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4 text-sm">
|
||||
<slot name="meta" :sheet="true" />
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
|
||||
<dt class="text-muted">{{ t('Created') }}</dt>
|
||||
<dd class="text-highlighted">{{ createdLabel }}</dd>
|
||||
@@ -156,10 +176,6 @@ async function copyUrl() {
|
||||
<dd class="font-mono text-highlighted">
|
||||
{{ article.id }}
|
||||
</dd>
|
||||
<dt class="text-muted">{{ t('Images') }}</dt>
|
||||
<dd class="text-highlighted">
|
||||
{{ formatNumber(imagesCount) }}
|
||||
</dd>
|
||||
</dl>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<p class="text-muted">{{ t('Public API URL') }}</p>
|
||||
@@ -177,13 +193,6 @@ async function copyUrl() {
|
||||
@click="copyUrl"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 border-t border-default pt-3">
|
||||
<p class="text-muted">{{ t('Title') }}</p>
|
||||
<p class="text-xs/[18px] text-toned">
|
||||
{{ t(TITLE_NOTE) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
|
||||
const ALL_SITES = 'all'
|
||||
const SITE_PREFIX = 'site:'
|
||||
|
||||
const props = defineProps<{ sites: Site[] }>()
|
||||
|
||||
const siteKey = defineModel<string>({ default: '' })
|
||||
|
||||
const items = computed(() => [
|
||||
{ label: t('All sites'), value: ALL_SITES },
|
||||
...props.sites.map((site) => ({
|
||||
label: site.name,
|
||||
value: SITE_PREFIX + site.key,
|
||||
})),
|
||||
])
|
||||
const selected = computed({
|
||||
get: () => (siteKey.value ? SITE_PREFIX + siteKey.value : ALL_SITES),
|
||||
set: (value: string) => {
|
||||
siteKey.value = value === ALL_SITES ? '' : value.slice(SITE_PREFIX.length)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelectMenu
|
||||
v-model="selected"
|
||||
:items="items"
|
||||
value-key="value"
|
||||
icon="i-lucide-globe"
|
||||
:search-input="false"
|
||||
:aria-label="t('Site')"
|
||||
class="w-full sm:w-37.5"
|
||||
:ui="{
|
||||
base: `h-11 rounded-none text-lg ring-(--ui-border-accented) sm:h-8 sm:text-sm ${siteKey ? 'text-highlighted' : 'text-muted'}`,
|
||||
leadingIcon: 'size-3.5',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'rounded-none shadow-(--shadow-popover)',
|
||||
item: 'min-h-11 px-2 py-0 data-[state=checked]:bg-elevated sm:min-h-8',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -4,6 +4,8 @@ import { t } from '@/i18n'
|
||||
import { computed, ref, watchPostEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import AuthorStack from '@/components/AuthorStack.vue'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
|
||||
@@ -15,9 +17,10 @@ const props = withDefaults(
|
||||
skeletonRows?: number
|
||||
sort?: string
|
||||
busyIds: Set<number>
|
||||
sites?: Site[]
|
||||
now?: Date
|
||||
}>(),
|
||||
{ skeletonRows: 5, sort: DEFAULT_ARTICLE_SORT },
|
||||
{ skeletonRows: 5, sort: DEFAULT_ARTICLE_SORT, sites: () => [] },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -30,8 +33,13 @@ const emit = defineEmits<{
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const columnWidths =
|
||||
'[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11'
|
||||
const SINGLE_SITE_WIDTHS =
|
||||
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-27.5 [&:nth-child(4)]:w-32.5 [&:nth-child(5)]:w-35 [&:nth-child(6)]:w-11'
|
||||
const SEVERAL_SITES_WIDTHS =
|
||||
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-27.5 [&:nth-child(5)]:w-32.5 [&:nth-child(6)]:w-35 [&:nth-child(7)]:w-11'
|
||||
|
||||
const showSite = computed(() => props.sites.length > 1)
|
||||
const columnWidths = computed(() => (showSite.value ? SEVERAL_SITES_WIDTHS : SINGLE_SITE_WIDTHS))
|
||||
|
||||
const sorting = computed(() => {
|
||||
const [id, direction] = props.sort.split(':')
|
||||
@@ -44,7 +52,7 @@ const SORTABLE_COLUMNS = ['title', 'status', 'created_at', 'updated_at']
|
||||
watchPostEffect(() => {
|
||||
const [active, direction] = props.sort.split(':')
|
||||
tableRoot.value?.querySelectorAll('thead th').forEach((header, index) => {
|
||||
if (SORTABLE_COLUMNS[index] === active) {
|
||||
if (SORTABLE_COLUMNS.includes(active!) && columns.value[index]?.id === active) {
|
||||
header.setAttribute('aria-sort', direction === 'desc' ? 'descending' : 'ascending')
|
||||
} else {
|
||||
header.removeAttribute('aria-sort')
|
||||
@@ -70,14 +78,33 @@ const caption = computed(() => {
|
||||
])
|
||||
})
|
||||
|
||||
const columns = [
|
||||
const columns = computed(() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
id: 'title',
|
||||
get header() {
|
||||
return t('Title')
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'authors',
|
||||
get header() {
|
||||
return t('Authors')
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
...(showSite.value
|
||||
? [
|
||||
{
|
||||
id: 'site',
|
||||
get header() {
|
||||
return t('Site')
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessorKey: 'is_online',
|
||||
id: 'status',
|
||||
@@ -89,6 +116,7 @@ const columns = [
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
id: 'created_at',
|
||||
get header() {
|
||||
return t('Created')
|
||||
},
|
||||
@@ -97,6 +125,7 @@ const columns = [
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
id: 'updated_at',
|
||||
get header() {
|
||||
return t('Updated')
|
||||
},
|
||||
@@ -104,7 +133,15 @@ const columns = [
|
||||
enableSorting: false,
|
||||
},
|
||||
{ id: 'actions', header: '', size: 44 },
|
||||
]
|
||||
])
|
||||
|
||||
function siteNameOf(article: Article) {
|
||||
return props.sites.find((site) => site.id === article.site_id)?.name ?? ''
|
||||
}
|
||||
|
||||
function authorNames(article: Article) {
|
||||
return (article.authors ?? []).map((author) => author.name).join(', ')
|
||||
}
|
||||
|
||||
function sortIcon(field: string) {
|
||||
const [id, direction] = props.sort.split(':')
|
||||
@@ -188,6 +225,8 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
|
||||
>
|
||||
<USkeleton class="h-3 max-w-[55%] flex-1 rounded-none bg-accented" />
|
||||
<USkeleton class="h-6 w-15 rounded-none bg-accented" />
|
||||
<USkeleton v-if="showSite" class="h-3 w-17.5 rounded-none bg-accented" />
|
||||
<USkeleton class="h-5 w-15 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
|
||||
@@ -290,6 +329,20 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<template #authors-cell="{ row }">
|
||||
<span class="flex min-w-0 items-center" :title="authorNames(row.original)">
|
||||
<AuthorStack
|
||||
v-if="row.original.authors?.length"
|
||||
:authors="row.original.authors"
|
||||
/>
|
||||
<span v-else :aria-label="t('No authors')" class="text-dimmed">—</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #site-cell="{ row }">
|
||||
<span class="block truncate text-toned">{{ siteNameOf(row.original) }}</span>
|
||||
</template>
|
||||
|
||||
<template #status-cell="{ row }">
|
||||
<ArticleStatusBadge
|
||||
:online="row.original.is_online"
|
||||
|
||||
@@ -42,7 +42,12 @@ const props = withDefaults(
|
||||
ownEmail?: string
|
||||
knownSlugs?: string[]
|
||||
}>(),
|
||||
{ author: undefined, ownProfile: false, ownEmail: '', knownSlugs: () => [] },
|
||||
{
|
||||
author: undefined,
|
||||
ownProfile: false,
|
||||
ownEmail: '',
|
||||
knownSlugs: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -58,7 +63,11 @@ const overlay = useLocalizedOverlay()
|
||||
|
||||
const editing = computed(() => props.author !== undefined)
|
||||
const initialLinks: SocialLinkRow[] = (props.author?.social_links ?? []).map(
|
||||
(link: SocialLink, index) => ({ key: index + 1, type: link.type, url: link.url }),
|
||||
(link: SocialLink, index) => ({
|
||||
key: index + 1,
|
||||
type: link.type,
|
||||
url: link.url,
|
||||
}),
|
||||
)
|
||||
|
||||
const values = reactive({
|
||||
@@ -348,7 +357,9 @@ async function confirmUnlink() {
|
||||
tone: 'neutral',
|
||||
action: async () => {
|
||||
try {
|
||||
const updated = await updateAuthor(author.id, { user_id: null })
|
||||
const updated = await updateAuthor(author.id, {
|
||||
user_id: null,
|
||||
})
|
||||
baseUserId.value = null
|
||||
linkedEmail.value = ''
|
||||
emit('updated', updated)
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, useId } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import type { Byline } from '@/composables/useArticles'
|
||||
import { insertionTarget, moveItem } from '@/lib/articleMeta'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 64rem)'
|
||||
const DESKTOP_AVATAR = 22
|
||||
const MOBILE_AVATAR = 28
|
||||
|
||||
const props = withDefaults(defineProps<{ authors: Byline[]; hintHidden?: boolean }>(), {
|
||||
hintHidden: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ 'update:authors': [authors: Byline[]] }>()
|
||||
|
||||
const labelId = useId()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const list = ref<HTMLElement | null>(null)
|
||||
const working = ref<Byline[] | null>(null)
|
||||
const grabbed = ref<number | null>(null)
|
||||
const origin = ref(0)
|
||||
const announcement = ref('')
|
||||
const dragFrom = ref<number | null>(null)
|
||||
const dropGap = ref<number | null>(null)
|
||||
|
||||
const shown = computed(() => working.value ?? props.authors)
|
||||
const avatarSize = computed(() => (isDesktop.value ? DESKTOP_AVATAR : MOBILE_AVATAR))
|
||||
const showPrimary = computed(() => shown.value.length > 1)
|
||||
const grabbedText = computed(() => (grabbed.value === null ? '' : announcement.value))
|
||||
const showHint = computed(
|
||||
() => props.authors.length > 1 && !props.hintHidden && grabbed.value === null,
|
||||
)
|
||||
|
||||
function positionText(key: 'moved' | 'dropped', author: Byline, position: number) {
|
||||
const total = shown.value.length
|
||||
return key === 'moved'
|
||||
? t('{0} moved to position {1} of {2}.', [author.name, position, total])
|
||||
: t('{0} dropped. Position {1} of {2}.', [author.name, position, total])
|
||||
}
|
||||
|
||||
function focusHandle(id: number) {
|
||||
nextTick(() => list.value?.querySelector<HTMLElement>(`[data-handle="${id}"]`)?.focus())
|
||||
}
|
||||
|
||||
function grab(index: number) {
|
||||
const author = props.authors[index]
|
||||
if (!author) {
|
||||
return
|
||||
}
|
||||
working.value = [...props.authors]
|
||||
grabbed.value = index
|
||||
origin.value = index
|
||||
announcement.value = t(
|
||||
'{0} grabbed. Position {1} of {2}. Use the up and down arrow keys to move, Space to drop, Escape to cancel.',
|
||||
[author.name, index + 1, props.authors.length],
|
||||
)
|
||||
}
|
||||
|
||||
function move(delta: number) {
|
||||
const current = grabbed.value
|
||||
const items = working.value
|
||||
if (current === null || !items) {
|
||||
return
|
||||
}
|
||||
const target = current + delta
|
||||
if (target < 0 || target >= items.length) {
|
||||
return
|
||||
}
|
||||
const author = items[current]!
|
||||
working.value = moveItem(items, current, target)
|
||||
grabbed.value = target
|
||||
announcement.value = positionText('moved', author, target + 1)
|
||||
focusHandle(author.id)
|
||||
}
|
||||
|
||||
function drop() {
|
||||
const current = grabbed.value
|
||||
const items = working.value
|
||||
if (current === null || !items) {
|
||||
return
|
||||
}
|
||||
const author = items[current]!
|
||||
announcement.value = positionText('dropped', author, current + 1)
|
||||
working.value = null
|
||||
grabbed.value = null
|
||||
if (current !== origin.value) {
|
||||
emit('update:authors', items)
|
||||
}
|
||||
focusHandle(author.id)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
const current = grabbed.value
|
||||
const items = working.value
|
||||
if (current === null || !items) {
|
||||
return
|
||||
}
|
||||
const author = items[current]!
|
||||
announcement.value = t('Reorder cancelled. {0} is back at position {1} of {2}.', [
|
||||
author.name,
|
||||
origin.value + 1,
|
||||
items.length,
|
||||
])
|
||||
working.value = null
|
||||
grabbed.value = null
|
||||
focusHandle(author.id)
|
||||
}
|
||||
|
||||
function onHandleKeydown(event: KeyboardEvent, index: number) {
|
||||
const grabKeys = [' ', 'Enter']
|
||||
if (grabbed.value === null) {
|
||||
if (grabKeys.includes(event.key)) {
|
||||
event.preventDefault()
|
||||
grab(index)
|
||||
}
|
||||
return
|
||||
}
|
||||
const actions: Record<string, () => void> = {
|
||||
ArrowUp: () => move(-1),
|
||||
ArrowDown: () => move(1),
|
||||
' ': drop,
|
||||
Enter: drop,
|
||||
Escape: cancel,
|
||||
}
|
||||
const action = actions[event.key]
|
||||
if (action) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
function rowElements() {
|
||||
return Array.from(list.value?.querySelectorAll<HTMLElement>('[data-testid="author-row"]') ?? [])
|
||||
}
|
||||
|
||||
function gapFor(clientY: number) {
|
||||
const rows = rowElements()
|
||||
const index = rows.findIndex((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return clientY < rect.top + rect.height / 2
|
||||
})
|
||||
return index === -1 ? rows.length : index
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent, index: number) {
|
||||
if (event.button > 0 || grabbed.value !== null) {
|
||||
return
|
||||
}
|
||||
dragFrom.value = index
|
||||
dropGap.value = gapFor(event.clientY)
|
||||
;(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (dragFrom.value !== null) {
|
||||
dropGap.value = gapFor(event.clientY)
|
||||
}
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
dragFrom.value = null
|
||||
dropGap.value = null
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
const from = dragFrom.value
|
||||
if (from === null) {
|
||||
return
|
||||
}
|
||||
const target = insertionTarget(from, gapFor(event.clientY))
|
||||
endDrag()
|
||||
if (target !== from) {
|
||||
emit('update:authors', moveItem(props.authors, from, target))
|
||||
}
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
emit(
|
||||
'update:authors',
|
||||
props.authors.filter((_, position) => position !== index),
|
||||
)
|
||||
}
|
||||
|
||||
function rowClass(index: number) {
|
||||
if (grabbed.value === index) {
|
||||
return 'border-primary outline-2 outline-offset-1 outline-primary'
|
||||
}
|
||||
if (dragFrom.value === index) {
|
||||
return 'relative z-10 translate-x-1 border-primary shadow-(--shadow-popover)'
|
||||
}
|
||||
return 'border-accented'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span :id="labelId" class="font-medium text-highlighted">{{ t('Authors') }}</span>
|
||||
<div
|
||||
v-if="authors.length === 0"
|
||||
class="border border-dashed border-accented p-3 text-pretty text-muted"
|
||||
>
|
||||
{{ t('No authors. This article will have no byline.') }}
|
||||
</div>
|
||||
<ul v-else ref="list" :aria-labelledby="labelId" class="flex flex-col gap-1">
|
||||
<template v-for="(author, index) in shown" :key="author.id">
|
||||
<li
|
||||
v-if="dropGap === index"
|
||||
role="presentation"
|
||||
data-testid="drop-line"
|
||||
class="-my-px h-0.5 bg-primary"
|
||||
/>
|
||||
<li
|
||||
data-testid="author-row"
|
||||
class="flex min-h-12 items-center gap-2 border bg-default pr-1 pl-0.5 lg:min-h-8 lg:gap-1.5 lg:pr-0.5"
|
||||
:class="rowClass(index)"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-grip-vertical"
|
||||
:data-handle="author.id"
|
||||
:aria-label="t('Reorder {0}', [author.name])"
|
||||
class="size-9 shrink-0 cursor-grab touch-none justify-center text-muted lg:size-6"
|
||||
:class="{
|
||||
'text-highlighted': grabbed === index || dragFrom === index,
|
||||
}"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@keydown="onHandleKeydown($event, index)"
|
||||
@pointerdown="onPointerDown($event, index)"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="endDrag"
|
||||
/>
|
||||
<AuthorAvatar :name="author.name" :size="avatarSize" />
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate font-medium text-highlighted"
|
||||
:title="author.name"
|
||||
:aria-label="author.name"
|
||||
>{{ author.name }}</span
|
||||
>
|
||||
<UBadge
|
||||
v-if="index === 0 && showPrimary"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-4.5 shrink-0 rounded-none px-1.5 text-xs font-medium text-toned"
|
||||
>{{ t('Primary') }}</UBadge
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
:aria-label="t('Remove {0}', [author.name])"
|
||||
class="size-11 shrink-0 justify-center text-muted lg:size-6"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="remove(index)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
<li
|
||||
v-if="dropGap === shown.length"
|
||||
role="presentation"
|
||||
data-testid="drop-line"
|
||||
class="-my-px h-0.5 bg-primary"
|
||||
/>
|
||||
</ul>
|
||||
<slot name="add" />
|
||||
<p
|
||||
v-if="grabbedText"
|
||||
aria-hidden="true"
|
||||
class="bg-elevated px-2 py-1.5 text-xs/4.5 text-toned"
|
||||
>
|
||||
{{ grabbedText }}
|
||||
</p>
|
||||
<p v-else-if="showHint" class="text-xs/4.5 text-muted">
|
||||
{{ t('Drag to reorder. The first author is the primary byline.') }}
|
||||
</p>
|
||||
<p role="status" aria-live="polite" class="sr-only" data-testid="author-live">
|
||||
{{ announcement }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import AuthorFormPanel from '@/components/AuthorFormPanel.vue'
|
||||
import ProfileCard from '@/components/ProfileCard.vue'
|
||||
import { useAuthors, type Author, type MyAuthor } from '@/composables/useAuthors'
|
||||
import { useMyAuthorProfile } from '@/composables/useMyAuthorProfile'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { baseSlugOf } from '@/lib/authorProfile'
|
||||
import { copy } from '@/lib/directoryCopy'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const SLUG_LOOKUP_SIZE = 25
|
||||
|
||||
const session = useSessionStore()
|
||||
const profile = useMyAuthorProfile()
|
||||
const { listAuthors } = useAuthors()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const failed = ref(false)
|
||||
const created = ref<MyAuthor | null>(null)
|
||||
const editing = ref<Author | null>(null)
|
||||
const loadingEdit = ref(false)
|
||||
|
||||
const author = computed(() => session.session?.author)
|
||||
const canManage = computed(() => session.canManageContent)
|
||||
const nameLine = computed(() => {
|
||||
const displayName = session.session?.displayName
|
||||
return displayName
|
||||
? t('Uses your display name, {0}. You can change it afterwards.', [displayName])
|
||||
: t('Uses your username, {0}. You can change it afterwards.', [
|
||||
session.session?.username ?? '',
|
||||
])
|
||||
})
|
||||
const adjustedSlug = computed(() => {
|
||||
const current = author.value
|
||||
const result = created.value
|
||||
if (!current || !result || result.id !== current.id) {
|
||||
return null
|
||||
}
|
||||
return result.slug_adjusted ? current.slug : null
|
||||
})
|
||||
|
||||
async function create() {
|
||||
failed.value = false
|
||||
const outcome = await profile.create()
|
||||
if (outcome.kind === 'created') {
|
||||
created.value = outcome.author
|
||||
}
|
||||
failed.value = outcome.kind === 'failed'
|
||||
}
|
||||
|
||||
function announceLoadFailed() {
|
||||
toast.add(() => ({
|
||||
id: 'author-load-failed',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
get title() {
|
||||
return copy('loadAuthorsFailed')
|
||||
},
|
||||
get description() {
|
||||
return copy('checkConnection')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async function edit() {
|
||||
const current = author.value
|
||||
if (!current || loadingEdit.value) {
|
||||
return
|
||||
}
|
||||
loadingEdit.value = true
|
||||
try {
|
||||
const page = await listAuthors({
|
||||
q: current.slug,
|
||||
page: 1,
|
||||
size: SLUG_LOOKUP_SIZE,
|
||||
})
|
||||
const full = page.items.find((candidate) => candidate.id === current.id)
|
||||
if (full) {
|
||||
editing.value = full
|
||||
} else {
|
||||
announceLoadFailed()
|
||||
}
|
||||
} catch {
|
||||
announceLoadFailed()
|
||||
} finally {
|
||||
loadingEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
function onSaved(saved: Author) {
|
||||
session.setAuthor({ id: saved.id, name: saved.name, slug: saved.slug })
|
||||
closePanel()
|
||||
}
|
||||
|
||||
function onUpdated(updated: Author) {
|
||||
if (updated.user_id === null) {
|
||||
session.setAuthor(undefined)
|
||||
closePanel()
|
||||
return
|
||||
}
|
||||
session.setAuthor({
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
slug: updated.slug,
|
||||
})
|
||||
}
|
||||
|
||||
function onGone() {
|
||||
session.setAuthor(undefined)
|
||||
closePanel()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProfileCard
|
||||
:title="t('Author profile')"
|
||||
:description="t('Your public byline on articles. It\'s separate from your account.')"
|
||||
>
|
||||
<template v-if="author">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<AuthorAvatar :name="author.name" :size="40" />
|
||||
<div class="flex min-w-35 flex-1 flex-col">
|
||||
<span class="font-medium text-highlighted">{{ author.name }}</span>
|
||||
<span class="font-mono text-xs/4.5 text-muted">{{ author.slug }}</span>
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil"
|
||||
:loading="loadingEdit"
|
||||
class="h-11 justify-center border border-accented bg-default px-3 text-highlighted ring-0 lg:h-8"
|
||||
@click="edit"
|
||||
>
|
||||
{{ t('Edit author') }}
|
||||
</UButton>
|
||||
</div>
|
||||
<UAlert
|
||||
v-if="adjustedSlug"
|
||||
role="status"
|
||||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-info"
|
||||
:title="t('Your slug is {0}', [adjustedSlug])"
|
||||
:description="
|
||||
t('{0} was already used by another author. You can change it in Edit author.', [
|
||||
baseSlugOf(adjustedSlug),
|
||||
])
|
||||
"
|
||||
:close="false"
|
||||
:ui="{
|
||||
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||
title: 'font-semibold',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
<p class="flex items-center gap-1.5 text-xs text-muted">
|
||||
<UIcon name="i-lucide-link" class="size-3 shrink-0" />
|
||||
{{ t('Linked to your account. New articles start with this author.') }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UAlert
|
||||
v-if="failed"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="t('Couldn\'t create your author profile')"
|
||||
:description="t('Try again in a moment.')"
|
||||
:close="false"
|
||||
:ui="{
|
||||
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||
title: 'font-semibold',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
<p class="text-pretty text-toned">
|
||||
{{
|
||||
t(
|
||||
"You don't have an author profile yet. Create one to be credited on your own articles.",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<UButton
|
||||
icon="i-lucide-feather"
|
||||
:loading="profile.pending.value"
|
||||
:disabled="profile.pending.value"
|
||||
class="h-11 justify-center px-3 lg:h-8"
|
||||
@click="create"
|
||||
>
|
||||
{{ profile.pending.value ? t('Creating…') : t('Create my author profile') }}
|
||||
</UButton>
|
||||
<span class="text-xs text-muted">{{ nameLine }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AuthorFormPanel
|
||||
v-if="editing"
|
||||
:author="editing"
|
||||
:own-profile="!canManage"
|
||||
:own-email="session.session?.email ?? ''"
|
||||
@close="closePanel"
|
||||
@saved="onSaved"
|
||||
@updated="onUpdated"
|
||||
@gone="onGone"
|
||||
/>
|
||||
</ProfileCard>
|
||||
</template>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import { useAuthors, type Author } from '@/composables/useAuthors'
|
||||
import type { Byline } from '@/composables/useArticles'
|
||||
import { copy } from '@/lib/directoryCopy'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const SEARCH_DELAY_MS = 200
|
||||
|
||||
type Status = 'loading' | 'ready' | 'error'
|
||||
|
||||
const props = defineProps<{ authors: Byline[]; canCreate: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ pick: [author: Author]; create: [name: string] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { listAuthors } = useAuthors()
|
||||
|
||||
const status = ref<Status>('loading')
|
||||
const found = ref<Author[]>([])
|
||||
const searchTerm = ref('')
|
||||
let token = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const chosenIds = computed(() => props.authors.map((author) => author.id))
|
||||
const query = computed(() => searchTerm.value.trim())
|
||||
const items = computed(() =>
|
||||
status.value === 'ready'
|
||||
? found.value.map((author) => ({
|
||||
label: author.name,
|
||||
description: author.slug,
|
||||
value: author.id,
|
||||
avatarName: author.name,
|
||||
class: chosenIds.value.includes(author.id) ? 'opacity-60' : undefined,
|
||||
}))
|
||||
: [],
|
||||
)
|
||||
const createLabel = computed(() =>
|
||||
query.value ? t('Create author "{0}"…', [query.value]) : t('Create author…'),
|
||||
)
|
||||
const showCreate = computed(() => props.canCreate && status.value !== 'error')
|
||||
|
||||
async function load() {
|
||||
const current = ++token
|
||||
status.value = 'loading'
|
||||
try {
|
||||
const page = await listAuthors({
|
||||
q: query.value,
|
||||
page: 1,
|
||||
size: PAGE_SIZE,
|
||||
})
|
||||
if (current !== token) {
|
||||
return
|
||||
}
|
||||
found.value = page.items
|
||||
status.value = 'ready'
|
||||
} catch {
|
||||
if (current === token) {
|
||||
status.value = 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimer() {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(open, (value) => {
|
||||
if (value) {
|
||||
searchTerm.value = ''
|
||||
load()
|
||||
return
|
||||
}
|
||||
clearTimer()
|
||||
token++
|
||||
})
|
||||
|
||||
watch(searchTerm, () => {
|
||||
if (!open.value) {
|
||||
return
|
||||
}
|
||||
clearTimer()
|
||||
timer = setTimeout(load, SEARCH_DELAY_MS)
|
||||
})
|
||||
|
||||
onBeforeUnmount(clearTimer)
|
||||
|
||||
function onSelect(ids: number[]) {
|
||||
const added = ids.find((id) => !chosenIds.value.includes(id))
|
||||
const author = found.value.find((candidate) => candidate.id === added)
|
||||
if (author) {
|
||||
emit('pick', author)
|
||||
}
|
||||
}
|
||||
|
||||
function createFromQuery() {
|
||||
const name = query.value
|
||||
open.value = false
|
||||
emit('create', name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelectMenu
|
||||
:model-value="chosenIds"
|
||||
v-model:open="open"
|
||||
v-model:search-term="searchTerm"
|
||||
multiple
|
||||
ignore-filter
|
||||
:portal="false"
|
||||
:content="{ position: 'inline' }"
|
||||
:items="items"
|
||||
value-key="value"
|
||||
:search-input="{ placeholder: t('Search authors') }"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none text-lg ring-(--ui-border-accented) lg:h-10 lg:text-sm',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'rounded-none shadow-(--shadow-popover)',
|
||||
item: 'min-h-12 gap-2 px-2 py-1 data-[state=checked]:bg-elevated lg:min-h-9',
|
||||
itemLabel: 'truncate font-medium',
|
||||
itemDescription: 'truncate font-mono text-xs text-muted',
|
||||
}"
|
||||
@update:model-value="onSelect"
|
||||
>
|
||||
<template #default>
|
||||
<UIcon name="i-lucide-plus" class="size-3.5 shrink-0 text-muted" />
|
||||
<span class="flex-1 truncate text-left text-muted">{{ t('Add an author') }}</span>
|
||||
</template>
|
||||
<template #item-leading="{ item }">
|
||||
<AuthorAvatar v-if="'avatarName' in item" :name="String(item.avatarName)" :size="24" />
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="status === 'error'" class="flex flex-col items-start gap-2 p-2">
|
||||
<span class="font-medium text-error">{{ copy('loadAuthorsFailed') }}</span>
|
||||
<span class="text-muted">{{ copy('checkConnection') }}</span>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 lg:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="load"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'loading'"
|
||||
class="flex flex-col gap-2.5 p-2"
|
||||
aria-busy="true"
|
||||
>
|
||||
<USkeleton v-for="n in 3" :key="n" class="h-6 rounded-none bg-accented" />
|
||||
</div>
|
||||
<div v-else-if="query" class="p-2 text-muted">
|
||||
{{ t('No author matches "{0}".', [query]) }}
|
||||
</div>
|
||||
<div v-else class="p-2 text-muted">{{ t('No authors yet') }}</div>
|
||||
</template>
|
||||
<template #content-bottom>
|
||||
<UButton
|
||||
v-if="showCreate"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-plus"
|
||||
block
|
||||
data-testid="create-author-row"
|
||||
class="min-h-12 justify-start gap-2 rounded-none border-t border-default px-2 py-1 font-medium text-highlighted lg:min-h-9"
|
||||
:ui="{ leadingIcon: 'size-4', label: 'truncate' }"
|
||||
@click="createFromQuery"
|
||||
>
|
||||
{{ createLabel }}
|
||||
</UButton>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import type { Byline } from '@/composables/useArticles'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
authors: Byline[]
|
||||
size?: number
|
||||
max?: number
|
||||
}>(),
|
||||
{ size: 24, max: 3 },
|
||||
)
|
||||
|
||||
const shown = computed(() => props.authors.slice(0, props.max))
|
||||
const extra = computed(() => Math.max(0, props.authors.length - props.max))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex min-w-0 items-center">
|
||||
<UAvatarGroup
|
||||
:ui="{ base: 'ring-0 -me-1.5 first-of-type:me-0' }"
|
||||
data-testid="author-stack"
|
||||
>
|
||||
<AuthorAvatar
|
||||
v-for="author in shown"
|
||||
:key="author.id"
|
||||
:name="author.name"
|
||||
:size="size"
|
||||
ring
|
||||
/>
|
||||
</UAvatarGroup>
|
||||
<span v-if="extra > 0" class="ml-1.5 text-xs text-muted" data-testid="author-stack-more"
|
||||
>+{{ extra }}</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
@@ -8,7 +8,10 @@ import { useApiKeys, type CreateApiKeyInput, type CreatedApiKey } from '@/compos
|
||||
import { expiryInstant, isFutureExpiry } from '@/lib/apiKeys'
|
||||
|
||||
const NAME_MAX_LENGTH = 60
|
||||
const fieldUi = { help: 'mt-1.5 text-sm/5 text-muted', error: 'mt-1.5 text-sm/5' }
|
||||
const fieldUi = {
|
||||
help: 'mt-1.5 text-sm/5 text-muted',
|
||||
error: 'mt-1.5 text-sm/5',
|
||||
}
|
||||
const OPTIONAL_SUFFIX = /\s*\([^)]*\)$/
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import { useAuthors, type Author } from '@/composables/useAuthors'
|
||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||
import { useSlugField } from '@/composables/useSlugField'
|
||||
import { AUTHOR_SLUG_MAX, suggestFreeSlug } from '@/lib/directory'
|
||||
import { classifyFailure } from '@/lib/directoryErrors'
|
||||
import { authorSlugProblem, nameProblem } from '@/lib/directoryRules'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const props = defineProps<{ initialName: string }>()
|
||||
|
||||
const emit = defineEmits<{ close: []; created: [author: Author] }>()
|
||||
|
||||
const session = useSessionStore()
|
||||
const { createAuthor } = useAuthors()
|
||||
const toasts = useDirectoryToasts()
|
||||
|
||||
const name = ref(props.initialName)
|
||||
const slug = useSlugField(AUTHOR_SLUG_MAX)
|
||||
slug.setSource(props.initialName)
|
||||
const isMe = ref(false)
|
||||
const pending = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
const errors = reactive({ name: '', slug: '', suggestion: '' })
|
||||
const rejectedSlugs = new Set<string>()
|
||||
|
||||
const offerMe = computed(
|
||||
() => session.session?.author === undefined && session.session?.id !== undefined,
|
||||
)
|
||||
const email = computed(() => session.session?.email ?? '')
|
||||
|
||||
function clearSlugError() {
|
||||
errors.slug = ''
|
||||
errors.suggestion = ''
|
||||
}
|
||||
|
||||
function onName(value: string) {
|
||||
name.value = value
|
||||
slug.setSource(value)
|
||||
errors.name = ''
|
||||
clearSlugError()
|
||||
}
|
||||
|
||||
function onSlug(value: string) {
|
||||
slug.setValue(value)
|
||||
clearSlugError()
|
||||
}
|
||||
|
||||
function resetSlug() {
|
||||
slug.reset()
|
||||
clearSlugError()
|
||||
}
|
||||
|
||||
function showSlugTaken() {
|
||||
const taken = slug.value.value
|
||||
if (slug.mode.value === 'auto') {
|
||||
slug.unlock()
|
||||
}
|
||||
rejectedSlugs.add(taken)
|
||||
errors.slug = t('The slug "{0}" is already used by another author.', [taken])
|
||||
errors.suggestion = suggestFreeSlug(
|
||||
taken,
|
||||
(candidate) => rejectedSlugs.has(candidate),
|
||||
AUTHOR_SLUG_MAX,
|
||||
)
|
||||
}
|
||||
|
||||
function fail(error: unknown) {
|
||||
const kind = classifyFailure(error, 'author')
|
||||
if (kind === 'session') {
|
||||
return
|
||||
}
|
||||
if (kind === 'slugTaken') {
|
||||
showSlugTaken()
|
||||
return
|
||||
}
|
||||
if (kind === 'rights') {
|
||||
toasts.authorRights()
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
notice.value = {
|
||||
title: t("Couldn't create the author"),
|
||||
description: t('The article is unchanged. Try again in a moment.'),
|
||||
}
|
||||
}
|
||||
|
||||
function validate() {
|
||||
errors.name = nameProblem(name.value)
|
||||
errors.slug = authorSlugProblem(slug.value.value)
|
||||
return errors.name === '' && errors.slug === ''
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (pending.value || !validate()) {
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
notice.value = null
|
||||
const userId = isMe.value ? session.session?.id : undefined
|
||||
try {
|
||||
const created = await createAuthor({
|
||||
name: name.value.trim(),
|
||||
slug: slug.value.value,
|
||||
...(userId === undefined ? {} : { user_id: userId }),
|
||||
})
|
||||
if (userId !== undefined) {
|
||||
session.setAuthor({
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
slug: created.slug,
|
||||
})
|
||||
}
|
||||
emit('created', created)
|
||||
} catch (error) {
|
||||
fail(error)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
:title="t('Create author')"
|
||||
:description="
|
||||
t('Adds a byline for this article. You can add a bio and avatar later in Authors.')
|
||||
"
|
||||
:confirm-label="t('Create author')"
|
||||
:pending-label="t('Creating…')"
|
||||
tone="neutral"
|
||||
closable
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:confirm-disabled="name.trim() === ''"
|
||||
@confirm="confirm"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<DirectoryField :label="t('Name')" :error="errors.name">
|
||||
<UInput
|
||||
:model-value="name"
|
||||
:placeholder="t('e.g. Camille Roche')"
|
||||
:color="errors.name ? 'error' : undefined"
|
||||
:disabled="pending"
|
||||
data-autofocus
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
|
||||
@update:model-value="onName(String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<SlugField
|
||||
:label="t('Slug')"
|
||||
:mode="slug.mode.value"
|
||||
:value="slug.value.value"
|
||||
:error="errors.slug"
|
||||
:suggestion="errors.suggestion"
|
||||
:help="t('Generated from the name. Unique across all authors.')"
|
||||
:manual-help="t('Lowercase letters, numbers and hyphens. Unique across all authors.')"
|
||||
@update:value="onSlug"
|
||||
@unlock="slug.unlock"
|
||||
@reset="resetSlug"
|
||||
@suggest="onSlug"
|
||||
/>
|
||||
|
||||
<UCheckbox
|
||||
v-if="offerMe"
|
||||
v-model="isMe"
|
||||
:label="t('This is me')"
|
||||
:description="t('Links the author to {0}.', [email])"
|
||||
:disabled="pending"
|
||||
:ui="{
|
||||
label: 'font-medium text-highlighted',
|
||||
description: 'text-xs text-muted',
|
||||
base: 'rounded-none',
|
||||
}"
|
||||
/>
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{ pending?: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ create: []; dismiss: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAlert
|
||||
data-testid="author-hint"
|
||||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-info"
|
||||
:title="t('You don\'t have an author profile yet')"
|
||||
:description="t('Create one to be credited on your articles.')"
|
||||
:close="{
|
||||
'aria-label': t('Dismiss'),
|
||||
class: 'size-11 justify-center lg:size-6',
|
||||
ui: { leadingIcon: 'size-3.5' },
|
||||
}"
|
||||
:ui="{
|
||||
root: 'items-start gap-2.5 p-3',
|
||||
title: 'font-semibold',
|
||||
description: 'text-toned',
|
||||
close: 'static -mt-1 -mr-1 lg:-mt-1 lg:-mr-1',
|
||||
}"
|
||||
@update:open="emit('dismiss')"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-feather"
|
||||
:loading="pending"
|
||||
:disabled="pending"
|
||||
class="h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 lg:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('create')"
|
||||
>
|
||||
{{ t('Create my author profile') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
</template>
|
||||
@@ -61,7 +61,10 @@ function onCancel() {
|
||||
:description="description"
|
||||
:dismissible="!pending"
|
||||
:close="closeButton"
|
||||
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
|
||||
:content="{
|
||||
role: 'alertdialog',
|
||||
'aria-busy': pending ? 'true' : undefined,
|
||||
}"
|
||||
:ui="{
|
||||
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
|
||||
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
|
||||
@@ -20,7 +20,13 @@ const props = withDefaults(
|
||||
readOnly?: boolean
|
||||
notice?: DirectoryNotice | null
|
||||
}>(),
|
||||
{ submitLabel: '', saving: false, dirty: false, readOnly: false, notice: null },
|
||||
{
|
||||
submitLabel: '',
|
||||
saving: false,
|
||||
dirty: false,
|
||||
readOnly: false,
|
||||
notice: null,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ close: []; submit: [] }>()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { computed } from 'vue'
|
||||
const QUERY_MAX_LENGTH = 40
|
||||
|
||||
const props = defineProps<{
|
||||
variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online'
|
||||
variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online' | 'no-site'
|
||||
query?: string
|
||||
}>()
|
||||
|
||||
@@ -51,6 +51,17 @@ const variants = computed(() => ({
|
||||
action: t('New article'),
|
||||
primary: false,
|
||||
},
|
||||
'no-site': {
|
||||
icon: 'i-lucide-globe',
|
||||
get title() {
|
||||
return t('No articles on \u0022{0}\u0022', [truncatedQuery.value])
|
||||
},
|
||||
get description() {
|
||||
return t('Move articles here from the editor, or choose another site.')
|
||||
},
|
||||
action: t('Show all sites'),
|
||||
primary: false,
|
||||
},
|
||||
'no-online': {
|
||||
icon: 'i-lucide-globe',
|
||||
get title() {
|
||||
|
||||
@@ -17,7 +17,8 @@ const activeTab = ref('fetch')
|
||||
|
||||
const examples = computed(() => endpointExamples(props.endpoint, props.origin))
|
||||
const rowLabel = computed(
|
||||
() => `${props.endpoint.method} ${props.endpoint.path}. ${translate(props.endpoint.description)}`,
|
||||
() =>
|
||||
`${props.endpoint.method} ${props.endpoint.path}. ${translate(props.endpoint.description)}`,
|
||||
)
|
||||
const bodyTitle = computed(() => {
|
||||
if (props.endpoint.bodyKind === 'multipart') {
|
||||
@@ -62,7 +63,9 @@ const snippetTabs = computed(() => [
|
||||
class="size-4 shrink-0 text-muted lg:hidden"
|
||||
/>
|
||||
</span>
|
||||
<span class="min-w-0 text-toned lg:truncate">{{ translate(endpoint.description) }}</span>
|
||||
<span class="min-w-0 text-toned lg:truncate">{{
|
||||
translate(endpoint.description)
|
||||
}}</span>
|
||||
<span class="flex flex-wrap gap-1.5">
|
||||
<UBadge
|
||||
:label="translate(endpoint.access)"
|
||||
|
||||
@@ -63,7 +63,11 @@ const variantDisplay = computed(() =>
|
||||
removed.value ? t('{0} (removed)', [selected.value]) : selected.value,
|
||||
)
|
||||
const urlTabs = computed(() => [
|
||||
{ k: 'url', label: t('URL'), code: variantUrl(props.origin, selected.value) },
|
||||
{
|
||||
k: 'url',
|
||||
label: t('URL'),
|
||||
code: variantUrl(props.origin, selected.value),
|
||||
},
|
||||
])
|
||||
|
||||
const previewLabel = computed(() => {
|
||||
|
||||
@@ -46,7 +46,10 @@ watch(open, (value) => {
|
||||
sideOffset: 8,
|
||||
loop: true,
|
||||
}"
|
||||
:ui="{ content: `w-42 p-1 ${LANGUAGE_MENU_CLASS}`, itemTrailingIcon: 'size-4' }"
|
||||
:ui="{
|
||||
content: `w-42 p-1 ${LANGUAGE_MENU_CLASS}`,
|
||||
itemTrailingIcon: 'size-4',
|
||||
}"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
|
||||
@@ -219,7 +219,10 @@ function onCancel() {
|
||||
spellcheck="false"
|
||||
:highlight="focusedField === 'url' || !!urlError"
|
||||
:aria-invalid="urlError ? 'true' : undefined"
|
||||
:ui="{ root: 'min-w-0', base: 'h-11 text-base md:h-10' }"
|
||||
:ui="{
|
||||
root: 'min-w-0',
|
||||
base: 'h-11 text-base md:h-10',
|
||||
}"
|
||||
class="w-full min-w-0"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
@@ -13,7 +13,11 @@ import { classifyFailure } from '@/lib/directoryErrors'
|
||||
|
||||
const props = defineProps<{ author: Author }>()
|
||||
|
||||
const emit = defineEmits<{ close: []; linked: [author: Author]; gone: [id: number] }>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
linked: [author: Author]
|
||||
gone: [id: number]
|
||||
}>()
|
||||
|
||||
const { listLinkableUsers, updateAuthor } = useAuthors()
|
||||
const toasts = useDirectoryToasts()
|
||||
@@ -68,7 +72,9 @@ async function confirm() {
|
||||
notice.value = null
|
||||
fieldError.value = ''
|
||||
try {
|
||||
const updated = await updateAuthor(props.author.id, { user_id: userId.value })
|
||||
const updated = await updateAuthor(props.author.id, {
|
||||
user_id: userId.value,
|
||||
})
|
||||
emit('linked', updated)
|
||||
} catch (error) {
|
||||
fail(error)
|
||||
|
||||
@@ -44,7 +44,10 @@ const sizeClass = computed(() => (props.size === 'md' ? 'h-5.5 px-1.5' : 'h-5 px
|
||||
:icon="badge.icon"
|
||||
:class="[badge.class, sizeClass]"
|
||||
:data-kind="kind"
|
||||
:ui="{ base: 'gap-1 py-0 text-xs whitespace-nowrap', leadingIcon: 'size-3' }"
|
||||
:ui="{
|
||||
base: 'gap-1 py-0 text-xs whitespace-nowrap',
|
||||
leadingIcon: 'size-3',
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
</UBadge>
|
||||
|
||||
@@ -193,7 +193,10 @@ function reviewDuplicates() {
|
||||
<template v-else>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<h2 id="media-cleanup-h" class="text-[0.9375rem]/6 font-semibold text-highlighted">
|
||||
<h2
|
||||
id="media-cleanup-h"
|
||||
class="text-[0.9375rem]/6 font-semibold text-highlighted"
|
||||
>
|
||||
{{ t('Media cleanup') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">
|
||||
|
||||
@@ -241,7 +241,10 @@ const duplicateAlert = computed(() => {
|
||||
icon="i-lucide-copy"
|
||||
:title="duplicateAlert.title"
|
||||
:description="duplicateAlert.description"
|
||||
:ui="{ actions: 'mt-2 w-full', description: 'text-wrap' }"
|
||||
:ui="{
|
||||
actions: 'mt-2 w-full',
|
||||
description: 'text-wrap',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
@@ -261,7 +264,9 @@ const duplicateAlert = computed(() => {
|
||||
<dl class="grid grid-cols-[110px_minmax(0,1fr)] gap-x-3 gap-y-2 text-toned">
|
||||
<template v-for="row in metadata" :key="row.label">
|
||||
<dt class="text-muted">{{ row.label }}</dt>
|
||||
<dd class="m-0 text-highlighted">{{ row.value }}</dd>
|
||||
<dd class="m-0 text-highlighted">
|
||||
{{ row.value }}
|
||||
</dd>
|
||||
</template>
|
||||
<dt class="text-muted">URL</dt>
|
||||
<dd class="m-0 truncate font-mono text-xs/5 text-highlighted">
|
||||
@@ -284,7 +289,9 @@ const duplicateAlert = computed(() => {
|
||||
:key="article.id"
|
||||
data-testid="usage-row"
|
||||
class="flex min-h-12 items-center gap-2 px-3 md:min-h-10"
|
||||
:class="{ 'border-t border-muted': index > 0 }"
|
||||
:class="{
|
||||
'border-t border-muted': index > 0,
|
||||
}"
|
||||
>
|
||||
<RouterLink
|
||||
:to="`/article/${article.id}/edit`"
|
||||
|
||||
@@ -6,7 +6,9 @@ import MediaLinksCard from './MediaLinksCard.vue'
|
||||
|
||||
describe('MediaLinksCard', () => {
|
||||
it('links to the variants and cache pages', () => {
|
||||
const wrapper = mount(MediaLinksCard, { global: { plugins: [router, uiPlugin] } })
|
||||
const wrapper = mount(MediaLinksCard, {
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
|
||||
const hrefs = wrapper.findAll('a').map((link) => link.attributes('href'))
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ const SEGMENT =
|
||||
|
||||
defineProps<{ modelValue: MediaVariantsFilter }>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: MediaVariantsFilter] }>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: MediaVariantsFilter]
|
||||
}>()
|
||||
|
||||
const OPTIONS: { value: MediaVariantsFilter; label: () => string }[] = [
|
||||
{ value: 'all', label: () => t('All') },
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, useId } from 'vue'
|
||||
import AuthorList from '@/components/AuthorList.vue'
|
||||
import AuthorSelect from '@/components/AuthorSelect.vue'
|
||||
import CreateAuthorDialog from '@/components/CreateAuthorDialog.vue'
|
||||
import CreateAuthorHint from '@/components/CreateAuthorHint.vue'
|
||||
import SiteSelect from '@/components/SiteSelect.vue'
|
||||
import type { Byline } from '@/composables/useArticles'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import { useMyAuthorProfile } from '@/composables/useMyAuthorProfile'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { metaSummary } from '@/lib/articleMeta'
|
||||
import { readHintDismissed, storeHintDismissed } from '@/lib/metaSectionMemory'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
sites: Site[]
|
||||
siteId: number | null
|
||||
authors: Byline[]
|
||||
remembered: boolean | null
|
||||
sheet?: boolean
|
||||
}>(),
|
||||
{ sheet: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:siteId': [siteId: number]
|
||||
'update:authors': [authors: Byline[]]
|
||||
'update:remembered': [open: boolean]
|
||||
}>()
|
||||
|
||||
const session = useSessionStore()
|
||||
const profile = useMyAuthorProfile()
|
||||
const bodyId = useId()
|
||||
|
||||
const startedWithoutAuthors = props.authors.length === 0
|
||||
const autoOpen = computed(() => !props.sheet && (props.sites.length > 1 || startedWithoutAuthors))
|
||||
const isOpen = computed(() => props.remembered ?? autoOpen.value)
|
||||
const hintDismissed = ref(readHintDismissed(session.session?.id))
|
||||
const comboOpen = ref(false)
|
||||
const creating = ref<string | null>(null)
|
||||
|
||||
const severalSites = computed(() => props.sites.length > 1)
|
||||
const siteName = computed(() => props.sites.find((site) => site.id === props.siteId)?.name ?? null)
|
||||
const summary = computed(() =>
|
||||
metaSummary(props.authors, severalSites.value ? siteName.value : null),
|
||||
)
|
||||
const canCreate = computed(() => session.canManageContent)
|
||||
const showHint = computed(
|
||||
() =>
|
||||
session.session !== null &&
|
||||
session.session.author === undefined &&
|
||||
props.authors.length === 0 &&
|
||||
!hintDismissed.value,
|
||||
)
|
||||
|
||||
function toByline(author: Author): Byline {
|
||||
return { id: author.id, name: author.name, slug: author.slug }
|
||||
}
|
||||
|
||||
function dismissHint() {
|
||||
hintDismissed.value = true
|
||||
storeHintDismissed(session.session?.id)
|
||||
}
|
||||
|
||||
async function createMine() {
|
||||
const outcome = await profile.create()
|
||||
if (outcome.kind === 'created') {
|
||||
emit('update:authors', [toByline(outcome.author), ...props.authors])
|
||||
}
|
||||
if (outcome.kind === 'failed') {
|
||||
profile.announceFailed()
|
||||
}
|
||||
}
|
||||
|
||||
function append(author: Author) {
|
||||
if (props.authors.some((existing) => existing.id === author.id)) {
|
||||
return
|
||||
}
|
||||
emit('update:authors', [...props.authors, toByline(author)])
|
||||
}
|
||||
|
||||
function onCreated(author: Author) {
|
||||
creating.value = null
|
||||
append(author)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-3 border-b border-default pb-4" data-testid="meta-section">
|
||||
<UCollapsible :open="isOpen" @update:open="emit('update:remembered', $event)">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="link"
|
||||
class="min-h-6 w-full justify-start gap-2 p-0 text-sm font-semibold text-highlighted"
|
||||
:aria-controls="bodyId"
|
||||
data-testid="meta-toggle"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-down"
|
||||
class="size-3.5 shrink-0 transition-transform"
|
||||
:class="{ '-rotate-90': !isOpen }"
|
||||
/>
|
||||
{{ t('Site and authors') }}
|
||||
</UButton>
|
||||
<template #content>
|
||||
<div :id="bodyId" class="mt-3 flex flex-col gap-4" data-testid="meta-body">
|
||||
<CreateAuthorHint
|
||||
v-if="showHint"
|
||||
:pending="profile.pending.value"
|
||||
@create="createMine"
|
||||
@dismiss="dismissHint"
|
||||
/>
|
||||
<SiteSelect
|
||||
v-if="severalSites"
|
||||
:sites="sites"
|
||||
:model-value="siteId"
|
||||
@update:model-value="(id) => id !== null && emit('update:siteId', id)"
|
||||
/>
|
||||
<AuthorList
|
||||
:authors="authors"
|
||||
:hint-hidden="comboOpen"
|
||||
@update:authors="emit('update:authors', $event)"
|
||||
>
|
||||
<template #add>
|
||||
<AuthorSelect
|
||||
v-model:open="comboOpen"
|
||||
:authors="authors"
|
||||
:can-create="canCreate"
|
||||
@pick="append"
|
||||
@create="creating = $event"
|
||||
/>
|
||||
</template>
|
||||
</AuthorList>
|
||||
</div>
|
||||
</template>
|
||||
</UCollapsible>
|
||||
<span
|
||||
v-if="!isOpen"
|
||||
class="truncate pl-5.5 text-muted"
|
||||
data-testid="meta-summary"
|
||||
:title="summary"
|
||||
>{{ summary }}</span
|
||||
>
|
||||
<CreateAuthorDialog
|
||||
v-if="creating !== null"
|
||||
:initial-name="creating"
|
||||
@close="creating = null"
|
||||
@created="onCreated"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -9,7 +9,9 @@ defineProps<{
|
||||
<section class="border border-default bg-default">
|
||||
<div class="flex flex-col gap-3.5 p-4 lg:p-5">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h2 class="text-base/5 font-semibold text-highlighted">{{ title }}</h2>
|
||||
<h2 class="text-base/5 font-semibold text-highlighted">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">{{ description }}</p>
|
||||
</div>
|
||||
<slot />
|
||||
|
||||
@@ -12,7 +12,9 @@ import { t } from '@/i18n'
|
||||
<h2 id="publishing-links-h" class="text-md/6 font-semibold text-highlighted">
|
||||
{{ t('Publishing') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">{{ t('Where your content is shown.') }}</p>
|
||||
<p class="text-pretty text-muted">
|
||||
{{ t('Where your content is shown.') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<RouterLink
|
||||
|
||||
@@ -164,7 +164,10 @@ async function settle(result: PurgeResult) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
partialCounts.value = { purged: result.groups_purged, total: result.groups_total }
|
||||
partialCounts.value = {
|
||||
purged: result.groups_purged,
|
||||
total: result.groups_total,
|
||||
}
|
||||
notice.value = 'partial'
|
||||
status.value = 'partial'
|
||||
emit('partial', result)
|
||||
@@ -238,7 +241,10 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
:description="nothing ? t(NOTHING_COPY) : t(DESCRIPTION)"
|
||||
:dismissible="!busy"
|
||||
:close="false"
|
||||
:content="{ role: 'alertdialog', 'aria-busy': busy ? 'true' : undefined }"
|
||||
:content="{
|
||||
role: 'alertdialog',
|
||||
'aria-busy': busy ? 'true' : undefined,
|
||||
}"
|
||||
:ui="ui"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
|
||||
@@ -39,7 +39,9 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{ confirm: []; close: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
const selected = defineModel<string | number | undefined>('selected', { default: undefined })
|
||||
const selected = defineModel<string | number | undefined>('selected', {
|
||||
default: undefined,
|
||||
})
|
||||
|
||||
const selectOpen = ref(false)
|
||||
const current = computed(() => props.options.find((option) => option.value === selected.value))
|
||||
|
||||
@@ -37,7 +37,9 @@ const SUBGROUP_TITLE_CLASS =
|
||||
:key="group.title"
|
||||
class="border-t border-dashed border-accented"
|
||||
>
|
||||
<div :class="SUBGROUP_TITLE_CLASS">{{ translate(group.title) }}</div>
|
||||
<div :class="SUBGROUP_TITLE_CLASS">
|
||||
{{ translate(group.title) }}
|
||||
</div>
|
||||
<ApiRouteRow
|
||||
v-for="row in group.rows"
|
||||
:key="`${row.method} ${row.path}`"
|
||||
|
||||
@@ -56,7 +56,13 @@ const imageItem = ref<MediaItem | null>(null)
|
||||
const slug = useSlugField(SITE_KEY_MAX, props.site?.key)
|
||||
slug.setSource(values.name)
|
||||
|
||||
const errors = reactive({ name: '', key: '', suggestion: '', url: '', locale: '' })
|
||||
const errors = reactive({
|
||||
name: '',
|
||||
key: '',
|
||||
suggestion: '',
|
||||
url: '',
|
||||
locale: '',
|
||||
})
|
||||
const saving = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
const panel = ref<{ focusFirstInvalid: () => void } | null>(null)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { hostOf } from '@/lib/directory'
|
||||
|
||||
const props = defineProps<{ sites: Site[] }>()
|
||||
|
||||
const siteId = defineModel<number | null>({ default: null })
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
const ordered = computed(() =>
|
||||
[...props.sites].sort((left, right) => Number(right.is_default) - Number(left.is_default)),
|
||||
)
|
||||
const items = computed(() =>
|
||||
ordered.value.map((site) => ({
|
||||
label: site.name,
|
||||
description: hostOf(site.base_url),
|
||||
value: site.id,
|
||||
})),
|
||||
)
|
||||
const chosen = computed(() => props.sites.find((site) => site.id === siteId.value))
|
||||
const chosenHost = computed(() => (chosen.value ? hostOf(chosen.value.base_url) : ''))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField :label="t('Site')">
|
||||
<USelectMenu
|
||||
v-model="siteId"
|
||||
v-model:open="open"
|
||||
:portal="false"
|
||||
:content="{ position: 'inline' }"
|
||||
:items="items"
|
||||
value-key="value"
|
||||
:search-input="false"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none text-lg ring-(--ui-border-accented) lg:h-10 lg:text-sm',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'rounded-none shadow-(--shadow-popover)',
|
||||
item: 'min-h-12 gap-2 px-2 py-1 data-[state=checked]:bg-elevated lg:min-h-9',
|
||||
itemLabel: 'truncate font-medium',
|
||||
itemDescription: 'truncate text-xs text-muted',
|
||||
}"
|
||||
>
|
||||
<template #default>
|
||||
<span v-if="chosen" class="truncate text-highlighted"
|
||||
>{{ chosen.name }} · {{ chosenHost }}</span
|
||||
>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</DirectoryField>
|
||||
<p v-if="chosen && !open" class="flex items-center gap-1.5 text-xs/4.5 text-muted">
|
||||
<UIcon name="i-lucide-link" class="size-3 shrink-0" />
|
||||
<span
|
||||
>{{ t('Canonical host:') }}
|
||||
<span class="font-mono text-toned">{{ chosenHost }}</span></span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -75,7 +75,9 @@ function update(key: number, patch: Partial<SocialLinkRow>) {
|
||||
:color="errors[row.key] ? 'error' : undefined"
|
||||
:disabled="disabled"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
|
||||
:ui="{
|
||||
base: 'h-11 font-mono text-base md:h-10 md:text-code',
|
||||
}"
|
||||
@update:model-value="update(row.key, { url: String($event) })"
|
||||
@blur="emit('blur', row.key)"
|
||||
/>
|
||||
|
||||
@@ -147,7 +147,9 @@ const items = computed(() => [
|
||||
:aria-expanded="chipExpanded"
|
||||
:class="chipClass"
|
||||
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
|
||||
:ui="{ trailingIcon: 'ms-auto size-3.5 text-current max-lg:hidden' }"
|
||||
:ui="{
|
||||
trailingIcon: 'ms-auto size-3.5 text-current max-lg:hidden',
|
||||
}"
|
||||
:aria-label="triggerAriaLabel"
|
||||
>
|
||||
<template #leading>
|
||||
|
||||
@@ -109,7 +109,9 @@ function onOpen(value: boolean) {
|
||||
:class="n === 3 ? 'w-3/5' : 'w-4/5'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="p-3 text-muted">{{ t('Every user already has an author.') }}</div>
|
||||
<div v-else class="p-3 text-muted">
|
||||
{{ t('Every user already has an author.') }}
|
||||
</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</template>
|
||||
@@ -8,7 +8,9 @@ import RootComponent from '@/App.vue'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
|
||||
vi.mock('@nuxt/ui/composables', () => ({
|
||||
useOverlay: () => ({ create: () => ({ open: () => Promise.resolve(false) }) }),
|
||||
useOverlay: () => ({
|
||||
create: () => ({ open: () => Promise.resolve(false) }),
|
||||
}),
|
||||
useToast: () => ({ add: vi.fn() }),
|
||||
}))
|
||||
|
||||
@@ -96,7 +98,9 @@ describe('insertImage stamps the inserted image with its numeric id', () => {
|
||||
const editor = wrapper.findComponent(ArticleEditor)
|
||||
|
||||
;(
|
||||
editor.vm as unknown as { insertImage: (src: string, alt: string, id: number) => void }
|
||||
editor.vm as unknown as {
|
||||
insertImage: (src: string, alt: string, id: number) => void
|
||||
}
|
||||
).insertImage('/static/uploadedImages/photo.png', 'alt text', 10)
|
||||
await flushPromises()
|
||||
|
||||
|
||||
@@ -267,7 +267,10 @@ describe('The link button across its states', () => {
|
||||
try {
|
||||
await mountEditor()
|
||||
linkButton().dispatchEvent(
|
||||
new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }),
|
||||
new PointerEvent('pointermove', {
|
||||
bubbles: true,
|
||||
pointerType: 'mouse',
|
||||
}),
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(399)
|
||||
expect(document.body.textContent).not.toContain('Insert link (Ctrl+K)')
|
||||
@@ -284,7 +287,10 @@ describe('The link button across its states', () => {
|
||||
try {
|
||||
await mountEditor()
|
||||
linkButton().dispatchEvent(
|
||||
new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }),
|
||||
new PointerEvent('pointermove', {
|
||||
bubbles: true,
|
||||
pointerType: 'mouse',
|
||||
}),
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(401)
|
||||
expect(document.body.textContent).toContain('Insert link (⌘K)')
|
||||
@@ -299,7 +305,10 @@ describe('The link button across its states', () => {
|
||||
try {
|
||||
await mountEditor(SENTENCE, PHONE)
|
||||
linkButton().dispatchEvent(
|
||||
new PointerEvent('pointermove', { bubbles: true, pointerType: 'touch' }),
|
||||
new PointerEvent('pointermove', {
|
||||
bubbles: true,
|
||||
pointerType: 'touch',
|
||||
}),
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(document.body.textContent).not.toContain('Insert link (')
|
||||
@@ -380,16 +389,27 @@ describe('Ctrl+K', () => {
|
||||
const Host = defineComponent({
|
||||
render: () =>
|
||||
h(UApp, null, () =>
|
||||
h(ArticleEditor, { modelValue: '<p>Hello</p>', articleId: 1, disabled: true }),
|
||||
h(ArticleEditor, {
|
||||
modelValue: '<p>Hello</p>',
|
||||
articleId: 1,
|
||||
disabled: true,
|
||||
}),
|
||||
),
|
||||
})
|
||||
const host = mount(Host, { attachTo: document.body, global: { plugins: [uiPlugin] } })
|
||||
const host = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
await settle()
|
||||
const vm = host.findComponent(ArticleEditor).vm as unknown as {
|
||||
$: { setupState: { editorInstance: Editor } }
|
||||
}
|
||||
vm.$.setupState.editorInstance.view.dom.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
await settle()
|
||||
|
||||
@@ -403,7 +423,11 @@ describe('Ctrl+K', () => {
|
||||
preview.click()
|
||||
await settle()
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
await settle()
|
||||
|
||||
@@ -731,7 +755,11 @@ describe('Leaving the dialog without saving', () => {
|
||||
it('on Esc', async () => {
|
||||
await openWithChanges()
|
||||
field('text').dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await settle()
|
||||
expectUntouched()
|
||||
@@ -809,7 +837,11 @@ describe('Dialog or sheet by viewport, popover or sheet by input', () => {
|
||||
editor()
|
||||
.view.dom.querySelector('a')!
|
||||
.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await settle()
|
||||
|
||||
@@ -870,8 +902,14 @@ describe('Toolbar overflow', () => {
|
||||
|
||||
function layout(scrollLeft: number) {
|
||||
const element = toolbar()
|
||||
Object.defineProperty(element, 'clientWidth', { configurable: true, value: 390 })
|
||||
Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 1100 })
|
||||
Object.defineProperty(element, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 390,
|
||||
})
|
||||
Object.defineProperty(element, 'scrollWidth', {
|
||||
configurable: true,
|
||||
value: 1100,
|
||||
})
|
||||
element.scrollLeft = scrollLeft
|
||||
element.dispatchEvent(new Event('scroll'))
|
||||
}
|
||||
@@ -919,7 +957,10 @@ describe('Selections that straddle a link or span blocks', () => {
|
||||
await mountEditor(BODY)
|
||||
const [first, last] = [selection.split(' ')[0]!, selection.split(' ').at(-1)!]
|
||||
const from = positionOf(first)
|
||||
editor().commands.setTextSelection({ from, to: positionOf(last) + last.length })
|
||||
editor().commands.setTextSelection({
|
||||
from,
|
||||
to: positionOf(last) + last.length,
|
||||
})
|
||||
const before = editor().state.doc.textContent
|
||||
await openDialog()
|
||||
type(field('url'), '/b')
|
||||
|
||||
@@ -15,7 +15,10 @@ let wrapper: VueWrapper | null = null
|
||||
|
||||
function stubClipboard() {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
return writeText
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,13 @@ async function mountShell(path: string, options: ShellOptions = {}) {
|
||||
setViewportWidth(width)
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName, role }),
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
username,
|
||||
email,
|
||||
displayName,
|
||||
role,
|
||||
}),
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
await router.push(path)
|
||||
@@ -408,7 +414,9 @@ describe('DashboardShell Settings expands to the Media subpages', () => {
|
||||
)
|
||||
|
||||
it('marks Variants current and Settings not current on the variants page', async () => {
|
||||
const wrapper = await mountShell('/settings/media/variants', { role: 'admin' })
|
||||
const wrapper = await mountShell('/settings/media/variants', {
|
||||
role: 'admin',
|
||||
})
|
||||
|
||||
const nav = wrapper.find('nav[aria-label="Main"]')
|
||||
expect(nav.find('a[href="/settings/media/variants"]').attributes('aria-current')).toBe(
|
||||
@@ -418,7 +426,9 @@ describe('DashboardShell Settings expands to the Media subpages', () => {
|
||||
})
|
||||
|
||||
it('marks Cache current and Settings not current on the cache page', async () => {
|
||||
const wrapper = await mountShell('/settings/media/cache', { role: 'admin' })
|
||||
const wrapper = await mountShell('/settings/media/cache', {
|
||||
role: 'admin',
|
||||
})
|
||||
|
||||
const nav = wrapper.find('nav[aria-label="Main"]')
|
||||
expect(nav.find('a[href="/settings/media/cache"]').attributes('aria-current')).toBe('page')
|
||||
@@ -471,7 +481,9 @@ describe('DashboardShell selected nav rows use sand with on-sand text', () => {
|
||||
})
|
||||
|
||||
it('keeps the unfilled Settings parent bold when a child is selected', async () => {
|
||||
const wrapper = await mountShell('/settings/media/variants', { role: 'owner' })
|
||||
const wrapper = await mountShell('/settings/media/variants', {
|
||||
role: 'owner',
|
||||
})
|
||||
|
||||
const parent = wrapper.find('nav[aria-label="Main"] a[href="/settings"]')
|
||||
expect(parent.attributes('aria-current')).toBeUndefined()
|
||||
|
||||
@@ -21,7 +21,12 @@ const ROWS: Row[] = [
|
||||
|
||||
function menu(row: Row): DirectoryMenuItem[] {
|
||||
return [
|
||||
{ key: 'edit', label: 'Edit', icon: 'i-lucide-pencil', onSelect: () => undefined },
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: 'i-lucide-pencil',
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete…',
|
||||
|
||||
@@ -103,7 +103,11 @@ describe('DirectoryPanel', () => {
|
||||
it('shows the failure notice inside the panel', async () => {
|
||||
await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'T', submitLabel: 'Add', notice: { description: 'Could not save.' } },
|
||||
{
|
||||
title: 'T',
|
||||
submitLabel: 'Add',
|
||||
notice: { description: 'Could not save.' },
|
||||
},
|
||||
{ default: body },
|
||||
)
|
||||
|
||||
|
||||
@@ -18,9 +18,7 @@ async function mountNotFound(breadcrumb?: string) {
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
return mount(ErrorView, {
|
||||
props: breadcrumb
|
||||
? { kind: 'not-found', breadcrumb }
|
||||
: { kind: 'not-found' },
|
||||
props: breadcrumb ? { kind: 'not-found', breadcrumb } : { kind: 'not-found' },
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
@@ -31,30 +29,20 @@ describe('ErrorView not-found kind', () => {
|
||||
it('renders the not-found copy with a breadcrumb, primary and secondary actions', async () => {
|
||||
const wrapper = await mountNotFound('Not found')
|
||||
|
||||
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe(
|
||||
'Not found',
|
||||
)
|
||||
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe('Not found')
|
||||
expect(wrapper.find('h1').text()).toBe('Page not found')
|
||||
expect(wrapper.text()).toContain(
|
||||
'The link may be mistyped, or the page may have moved.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('The link may be mistyped, or the page may have moved.')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(buttons.some((button) => button.text() === 'All articles')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(true)
|
||||
expect(buttons.some((button) => button.text() === 'All articles')).toBe(true)
|
||||
})
|
||||
|
||||
it('routes the primary action to /home and the secondary action to /article', async () => {
|
||||
const wrapper = await mountNotFound()
|
||||
const router = wrapper.vm.$router
|
||||
|
||||
const primary = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'Go to Home')
|
||||
const primary = wrapper.findAll('button').find((button) => button.text() === 'Go to Home')
|
||||
await primary?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/home')
|
||||
|
||||
@@ -45,7 +45,10 @@ vi.mock('@/lib/imageCrop', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
|
||||
return {
|
||||
...actual,
|
||||
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
|
||||
readNaturalImageSize: vi.fn(async () => ({
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
})),
|
||||
renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])),
|
||||
}
|
||||
})
|
||||
@@ -127,7 +130,10 @@ function mib(value: number) {
|
||||
|
||||
function fileFixture(name: string, type: string, sizeMB: number) {
|
||||
const file = new File(['x'], name, { type })
|
||||
Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true })
|
||||
Object.defineProperty(file, 'size', {
|
||||
value: mib(sizeMB),
|
||||
configurable: true,
|
||||
})
|
||||
return file
|
||||
}
|
||||
|
||||
@@ -219,7 +225,9 @@ describe('Dragging a file over the drop zone shows the dragging-over state', ()
|
||||
describe('Dropping a file opens Upload with the file', () => {
|
||||
it('looks the hash up, sends no list request and opens the crop step', async () => {
|
||||
installApi()
|
||||
wrapper = mountUploader({ initialFile: fileFixture('before.png', 'image/png', 3.9) })
|
||||
wrapper = mountUploader({
|
||||
initialFile: fileFixture('before.png', 'image/png', 3.9),
|
||||
})
|
||||
await openUploader(wrapper)
|
||||
|
||||
expect(hashCalls()).toEqual([NORTH_WALL_HASH])
|
||||
@@ -326,7 +334,9 @@ describe('A file whose content exists shows the duplicate card and sends nothing
|
||||
})
|
||||
|
||||
it('drops the "by" part when the uploader is unknown', async () => {
|
||||
await openOnUpload({ hash: () => pageBody([mediaItem({ uploaded_by: '' })], null) })
|
||||
await openOnUpload({
|
||||
hash: () => pageBody([mediaItem({ uploaded_by: '' })], null),
|
||||
})
|
||||
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
|
||||
@@ -544,7 +554,9 @@ describe('A server answer for a file that passed the browser checks', () => {
|
||||
async ({ file, type, sizeMB, status, title, text, confirm }) => {
|
||||
await openOnUpload()
|
||||
const chosen = fileFixture(file, type, sizeMB)
|
||||
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
|
||||
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({
|
||||
ok: true,
|
||||
})
|
||||
await chooseFile(chosen)
|
||||
if (findButton('Upload image')) {
|
||||
await clickButton('Upload image')
|
||||
|
||||
@@ -98,7 +98,10 @@ function mib(value: number) {
|
||||
|
||||
function fileFixture(name: string, type: string, sizeMB: number) {
|
||||
const file = new File(['x'], name, { type })
|
||||
Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true })
|
||||
Object.defineProperty(file, 'size', {
|
||||
value: mib(sizeMB),
|
||||
configurable: true,
|
||||
})
|
||||
return file
|
||||
}
|
||||
|
||||
@@ -208,9 +211,27 @@ describe('An SVG skips the crop step and uploads immediately', () => {
|
||||
|
||||
describe('Choosing an aspect ratio locks the frame to it', () => {
|
||||
it.each([
|
||||
{ ratio: '16:9', cropWidth: 4032, cropHeight: 2268, outWidth: 1600, outHeight: 900 },
|
||||
{ ratio: '4:3', cropWidth: 4032, cropHeight: 3024, outWidth: 1600, outHeight: 1200 },
|
||||
{ ratio: '1:1', cropWidth: 3024, cropHeight: 3024, outWidth: 1600, outHeight: 1600 },
|
||||
{
|
||||
ratio: '16:9',
|
||||
cropWidth: 4032,
|
||||
cropHeight: 2268,
|
||||
outWidth: 1600,
|
||||
outHeight: 900,
|
||||
},
|
||||
{
|
||||
ratio: '4:3',
|
||||
cropWidth: 4032,
|
||||
cropHeight: 3024,
|
||||
outWidth: 1600,
|
||||
outHeight: 1200,
|
||||
},
|
||||
{
|
||||
ratio: '1:1',
|
||||
cropWidth: 3024,
|
||||
cropHeight: 3024,
|
||||
outWidth: 1600,
|
||||
outHeight: 1600,
|
||||
},
|
||||
])(
|
||||
'locks the frame for $ratio',
|
||||
async ({ ratio, cropWidth, cropHeight, outWidth, outHeight }) => {
|
||||
@@ -221,7 +242,9 @@ describe('Choosing an aspect ratio locks the frame to it', () => {
|
||||
await selectRatioPill(ratio)
|
||||
|
||||
const rect = (
|
||||
wrapper.findComponent(ImageUploader).vm as unknown as { cropRect: CropRect }
|
||||
wrapper.findComponent(ImageUploader).vm as unknown as {
|
||||
cropRect: CropRect
|
||||
}
|
||||
).cropRect
|
||||
expect(rect.width).toBe(cropWidth)
|
||||
expect(rect.height).toBe(cropHeight)
|
||||
@@ -308,7 +331,11 @@ describe('Arrow keys move the crop frame and Shift+arrow keys resize it', () =>
|
||||
expect(document.body.textContent).toContain('Uploads as 1600 × 1600 px · about')
|
||||
|
||||
cropFrame().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true, bubbles: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'ArrowRight',
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
@@ -329,7 +356,11 @@ describe('Dragging the crop frame moves it, and dragging a corner handle resizes
|
||||
const before = { ...uploader.cropRect }
|
||||
|
||||
cropFrame().dispatchEvent(
|
||||
new MouseEvent('mousedown', { clientX: 0, clientY: 0, bubbles: true }),
|
||||
new MouseEvent('mousedown', {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 100, clientY: 40 }))
|
||||
window.dispatchEvent(new MouseEvent('mouseup'))
|
||||
@@ -348,7 +379,13 @@ describe('Dragging the crop frame moves it, and dragging a corner handle resizes
|
||||
const handle = document.body.querySelectorAll<HTMLElement>(
|
||||
'[data-testid="crop-handle"]',
|
||||
)[3]!
|
||||
handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 0, clientY: 0, bubbles: true }))
|
||||
handle.dispatchEvent(
|
||||
new MouseEvent('mousedown', {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
const afterMove = { ...uploader.cropRect }
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX: -50, clientY: -50 }))
|
||||
window.dispatchEvent(new MouseEvent('mouseup'))
|
||||
|
||||
@@ -34,7 +34,10 @@ vi.mock('@/lib/imageCrop', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
|
||||
return {
|
||||
...actual,
|
||||
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
|
||||
readNaturalImageSize: vi.fn(async () => ({
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -353,9 +356,12 @@ describe('Insert', () => {
|
||||
[
|
||||
'presses Escape',
|
||||
async () => {
|
||||
document.body
|
||||
.querySelector('[role="dialog"]')!
|
||||
.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
document.body.querySelector('[role="dialog"]')!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
await settle()
|
||||
},
|
||||
],
|
||||
|
||||
@@ -82,7 +82,10 @@ describe('MediaField', () => {
|
||||
})
|
||||
|
||||
it('returns the picked image id through Use image and offers Change image and Remove', async () => {
|
||||
const wrapper = await mountInApp(MediaField, { label: 'Avatar', picker: PICKER })
|
||||
const wrapper = await mountInApp(MediaField, {
|
||||
label: 'Avatar',
|
||||
picker: PICKER,
|
||||
})
|
||||
byText('button', 'Choose image')!.click()
|
||||
await flushPromises()
|
||||
const picker = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
|
||||
@@ -34,7 +34,10 @@ const Host = defineComponent({
|
||||
emits: ['created'],
|
||||
setup(_props, { emit }) {
|
||||
const open = ref(true)
|
||||
return { open, onCreated: (article: Article) => emit('created', article) }
|
||||
return {
|
||||
open,
|
||||
onCreated: (article: Article) => emit('created', article),
|
||||
}
|
||||
},
|
||||
template: `<NewArticleModal v-model:open="open" @created="onCreated" />`,
|
||||
})
|
||||
@@ -155,7 +158,10 @@ describe('Submitting disables the form and cannot be dismissed', () => {
|
||||
resolveRequest = resolve
|
||||
}),
|
||||
)
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn(),
|
||||
request: requestMock,
|
||||
})
|
||||
const wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
@@ -199,7 +205,10 @@ describe('Submitting disables the form and cannot be dismissed', () => {
|
||||
describe('A successful create closes the modal and hands off to the editor with a toast', () => {
|
||||
it('closes the modal and emits created with the response body', async () => {
|
||||
requestMock = vi.fn().mockResolvedValue(articleFixture())
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn(),
|
||||
request: requestMock,
|
||||
})
|
||||
const wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
@@ -219,7 +228,10 @@ describe('A successful create closes the modal and hands off to the editor with
|
||||
describe("Title rejected — a 400 that got past this form's own check", () => {
|
||||
it('keeps the values, shows the title error and the form alert', async () => {
|
||||
requestMock = vi.fn().mockRejectedValue(new ApiError(400))
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn(),
|
||||
request: requestMock,
|
||||
})
|
||||
const wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
@@ -246,7 +258,10 @@ describe('Error — a 500 or a network failure keeps the text and offers to retr
|
||||
{ name: 'a network failure', error: () => new NetworkError() },
|
||||
])('keeps the values and shows the generic alert for $name', async ({ error }) => {
|
||||
requestMock = vi.fn().mockRejectedValue(error())
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn(),
|
||||
request: requestMock,
|
||||
})
|
||||
const wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
@@ -269,7 +284,10 @@ describe('Error — a 500 or a network failure keeps the text and offers to retr
|
||||
describe('Retrying after an Error succeeds', () => {
|
||||
it('sends the same body again and hands off to the editor on success', async () => {
|
||||
requestMock = vi.fn().mockRejectedValueOnce(new ApiError(500))
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn(),
|
||||
request: requestMock,
|
||||
})
|
||||
const wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
|
||||
@@ -3,11 +3,7 @@ import { mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import PublishControl from '../PublishControl.vue'
|
||||
|
||||
function mountControl(props: {
|
||||
online: boolean
|
||||
dirty: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
function mountControl(props: { online: boolean; dirty: boolean; disabled?: boolean }) {
|
||||
return mount(PublishControl, {
|
||||
props,
|
||||
global: { plugins: [uiPlugin] },
|
||||
@@ -46,8 +42,6 @@ describe('Disabled', () => {
|
||||
disabled: true,
|
||||
})
|
||||
|
||||
expect(
|
||||
(wrapper.find('button').element as HTMLButtonElement).disabled,
|
||||
).toBe(true)
|
||||
expect((wrapper.find('button').element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,12 @@ import ReassignDialog from '../ReassignDialog.vue'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 1, label: 'Renew', description: 'renew.example', display: 'Renew · renew.example' },
|
||||
{
|
||||
value: 1,
|
||||
label: 'Renew',
|
||||
description: 'renew.example',
|
||||
display: 'Renew · renew.example',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: 'Projects',
|
||||
@@ -75,7 +80,12 @@ describe('ReassignDialog', () => {
|
||||
it('shows the failure notice with its title and text', async () => {
|
||||
await mountInApp(
|
||||
ReassignDialog,
|
||||
props({ notice: { title: 'Failed', description: 'Nothing was changed.' } }),
|
||||
props({
|
||||
notice: {
|
||||
title: 'Failed',
|
||||
description: 'Nothing was changed.',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(text(dialog().querySelector('[role="alert"]'))).toBe('Failed Nothing was changed.')
|
||||
|
||||
@@ -77,7 +77,12 @@ describe('SlugField', () => {
|
||||
it('draws the locked variant with a lock and its own help, with no pencil', async () => {
|
||||
await mountInApp(
|
||||
SlugField,
|
||||
props({ mode: 'saved', value: 'renew', locked: true, lockedHelp: "Can't change." }),
|
||||
props({
|
||||
mode: 'saved',
|
||||
value: 'renew',
|
||||
locked: true,
|
||||
lockedHelp: "Can't change.",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(input().readOnly).toBe(true)
|
||||
|
||||
@@ -33,8 +33,7 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
|
||||
async function mountLogin() {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = (() =>
|
||||
mockResponse(200, { open: false })) as unknown as typeof fetch
|
||||
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
|
||||
await router.push('/login')
|
||||
const wrapper = mount(LoginView, {
|
||||
attachTo: document.body,
|
||||
@@ -70,9 +69,7 @@ describe('AppLogo renders the brand mark as inline SVG, and theme-switches', ()
|
||||
expect(card.element.contains(svg.element)).toBe(true)
|
||||
|
||||
const html = wrapper.html()
|
||||
expect(html.indexOf('<svg')).toBeGreaterThan(
|
||||
html.indexOf('data-testid="auth-card"'),
|
||||
)
|
||||
expect(html.indexOf('<svg')).toBeGreaterThan(html.indexOf('data-testid="auth-card"'))
|
||||
expect(html.indexOf('<svg')).toBeLessThan(html.indexOf('<h1'))
|
||||
|
||||
let centeringAncestor = svg.element.parentElement
|
||||
@@ -113,9 +110,7 @@ describe("UPageCard's title is the page h1, and its description uses UPageCard's
|
||||
expect(heading.text()).toBe('Log in')
|
||||
expect(heading.attributes('tabindex')).toBe('-1')
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'Welcome back. Log in to manage your articles.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
|
||||
expect(wrapper.findAll('p').length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -180,12 +175,8 @@ describe('The login form is a UForm', () => {
|
||||
const form = wrapper.find('form')
|
||||
expect(form.exists()).toBe(true)
|
||||
expect(form.attributes('id')).toMatch(/^v-/)
|
||||
expect(form.find('input[placeholder="Your username"]').exists()).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
form.find('input[autocomplete="current-password"]').exists(),
|
||||
).toBe(true)
|
||||
expect(form.find('input[placeholder="Your username"]').exists()).toBe(true)
|
||||
expect(form.find('input[autocomplete="current-password"]').exists()).toBe(true)
|
||||
expect(form.find('button[type="submit"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -194,20 +185,14 @@ describe('"Forgot password?" renders in the Password field\'s hint slot', () =>
|
||||
it('renders the link through the hint slot, and the label reads only Password', async () => {
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const passwordInput = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
const fieldRoot = passwordInput.element.closest(
|
||||
'[data-slot="root"][data-orientation]',
|
||||
)
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
const fieldRoot = passwordInput.element.closest('[data-slot="root"][data-orientation]')
|
||||
expect(fieldRoot).toBeTruthy()
|
||||
|
||||
const hint = fieldRoot!.querySelector('[data-slot="hint"]')
|
||||
expect(hint).toBeTruthy()
|
||||
expect(hint!.textContent?.trim()).toBe('Forgot password?')
|
||||
expect(
|
||||
hint!.querySelector('a[href="/register/reset/request"]'),
|
||||
).toBeTruthy()
|
||||
expect(hint!.querySelector('a[href="/register/reset/request"]')).toBeTruthy()
|
||||
|
||||
const label = fieldRoot!.querySelector('[data-slot="label"]')
|
||||
expect(label!.textContent?.trim()).toBe('Password')
|
||||
@@ -219,9 +204,7 @@ describe('Inputs are neutral by default and turn to the error edge only when the
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
||||
const passwordInput = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
expect(usernameInput.classes()).toContain('ring-accented')
|
||||
expect(usernameInput.classes()).not.toContain('ring-error')
|
||||
expect(passwordInput.classes()).toContain('ring-accented')
|
||||
@@ -232,9 +215,7 @@ describe('Inputs are neutral by default and turn to the error edge only when the
|
||||
await flushPromises()
|
||||
|
||||
const usernameAfter = wrapper.find('input[placeholder="Your username"]')
|
||||
const passwordAfter = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
const passwordAfter = wrapper.find('input[autocomplete="current-password"]')
|
||||
expect(usernameAfter.classes()).toContain('ring-error')
|
||||
expect(usernameAfter.classes()).not.toContain('ring-accented')
|
||||
expect(passwordAfter.classes()).toContain('ring-error')
|
||||
|
||||
@@ -25,7 +25,10 @@ export async function mountInApp(
|
||||
default: () => h(component, props, slots as never),
|
||||
}),
|
||||
}),
|
||||
{ attachTo: document.body, global: { plugins: [router, uiPlugin, pinia] } },
|
||||
{
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin, pinia] },
|
||||
},
|
||||
)
|
||||
await flushPromises()
|
||||
return holder.wrapper
|
||||
|
||||
@@ -81,7 +81,11 @@ afterEach(async () => {
|
||||
|
||||
function press(element: Element, value: string) {
|
||||
element.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: value, bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: value,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -140,7 +144,11 @@ async function openAccountMenu(surface: Surface) {
|
||||
if (surface === 'desktop UserMenu popover') {
|
||||
wrapper = mount(UserMenu, {
|
||||
attachTo: document.body,
|
||||
props: { username: 'marta', email: SIGNED_IN.email, collapsed: false },
|
||||
props: {
|
||||
username: 'marta',
|
||||
email: SIGNED_IN.email,
|
||||
collapsed: false,
|
||||
},
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
document.body.querySelector<HTMLButtonElement>('button[aria-haspopup]')!.click()
|
||||
@@ -330,7 +338,9 @@ describe('Choosing a language in the account menu applies it at once and keeps t
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
expect.objectContaining({ mock: expect.objectContaining({ calls: [] }) }),
|
||||
expect.objectContaining({
|
||||
mock: expect.objectContaining({ calls: [] }),
|
||||
}),
|
||||
'/profile',
|
||||
'light',
|
||||
])
|
||||
@@ -351,7 +361,10 @@ describe('Choosing a language in the account menu applies it at once and keeps t
|
||||
signIn()
|
||||
setViewportWidth(layout === 'desktop' ? DESKTOP_WIDTH : MOBILE_WIDTH)
|
||||
await router.push('/profile')
|
||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [ui, router] } })
|
||||
wrapper = mount(App, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
await flushPromises()
|
||||
await wrapper.find('input[autocomplete="off"]').setValue('Alice B.')
|
||||
if (layout === 'mobile') {
|
||||
@@ -541,7 +554,9 @@ describe('Auth footer shows the language chip beside the theme chip', () => {
|
||||
)
|
||||
|
||||
it('keeps the footer slot content to the left of the chips', async () => {
|
||||
await mountAuthShell(false, { footer: '<a href="/register">Create an account</a>' })
|
||||
await mountAuthShell(false, {
|
||||
footer: '<a href="/register">Create an account</a>',
|
||||
})
|
||||
const footer = document.body.querySelector('footer')!
|
||||
const link = footer.querySelector('a')!
|
||||
|
||||
@@ -635,7 +650,10 @@ describe('Auth language popover', () => {
|
||||
signOut()
|
||||
await router.push('/login')
|
||||
const { default: view } = await import('@/views/LoginView.vue')
|
||||
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
|
||||
wrapper = mount(view, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
await flushPromises()
|
||||
authChip().focus()
|
||||
authChip().click()
|
||||
@@ -712,7 +730,10 @@ describe('Each auth screen carries the language chip', () => {
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
const { default: view } = await load()
|
||||
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
|
||||
wrapper = mount(view, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(authChip().textContent?.trim()).toBe('English')
|
||||
@@ -724,7 +745,10 @@ describe('Each auth screen carries the language chip', () => {
|
||||
signOut()
|
||||
await router.push('/login')
|
||||
const { default: view } = await import('@/views/LoginView.vue')
|
||||
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
|
||||
wrapper = mount(view, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
await flushPromises()
|
||||
await wrapper.find('input[autocomplete="username"]').setValue('marta')
|
||||
|
||||
|
||||
@@ -15,7 +15,13 @@ export const FIRST_PAGE_TOTAL = 48
|
||||
export const PICKER_COUNTS = { all: 48, unused: 2, duplicates: 2 }
|
||||
|
||||
export function pageBody(items: MediaItem[] = fixture(), total: number | null = FIRST_PAGE_TOTAL) {
|
||||
return { items, total: total ?? items.length, page: 1, size: 25, counts: PICKER_COUNTS }
|
||||
return {
|
||||
items,
|
||||
total: total ?? items.length,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: PICKER_COUNTS,
|
||||
}
|
||||
}
|
||||
|
||||
export function secondPage() {
|
||||
|
||||
@@ -20,7 +20,9 @@ const largest = computed(() => Math.max(0, ...props.items.map((item) => item.byt
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-highlighted">{{ t('By variant') }}</h2>
|
||||
<h2 class="font-semibold text-highlighted">
|
||||
{{ t('By variant') }}
|
||||
</h2>
|
||||
</template>
|
||||
<div
|
||||
v-for="item in items"
|
||||
@@ -36,7 +38,10 @@ const largest = computed(() => Math.max(0, ...props.items.map((item) => item.byt
|
||||
variant="outline"
|
||||
icon="i-lucide-circle-off"
|
||||
class="border border-accented bg-transparent text-toned ring-0"
|
||||
:ui="{ base: 'h-4.5 gap-1 px-1.5 py-0 text-xs', leadingIcon: 'size-3' }"
|
||||
:ui="{
|
||||
base: 'h-4.5 gap-1 px-1.5 py-0 text-xs',
|
||||
leadingIcon: 'size-3',
|
||||
}"
|
||||
>
|
||||
{{ t('Disabled') }}
|
||||
</UBadge>
|
||||
|
||||
@@ -26,7 +26,9 @@ const CARD_UI = {
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[460px_minmax(0,1fr)]">
|
||||
<UCard :ui="CARD_UI">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-highlighted">{{ t('By variant') }}</h2>
|
||||
<h2 class="font-semibold text-highlighted">
|
||||
{{ t('By variant') }}
|
||||
</h2>
|
||||
</template>
|
||||
<div v-for="row in VARIANT_ROWS" :key="row" class="flex flex-col gap-2">
|
||||
<USkeleton class="h-3.25 w-[50%]" :class="BAR" />
|
||||
|
||||
@@ -25,7 +25,12 @@ const rows = computed(() => [
|
||||
result: t('Unsupported parameter'),
|
||||
mono: false,
|
||||
},
|
||||
{ request: t('Missing source image'), status: 404, result: t('Not found'), mono: false },
|
||||
{
|
||||
request: t('Missing source image'),
|
||||
status: 404,
|
||||
result: t('Not found'),
|
||||
mono: false,
|
||||
},
|
||||
])
|
||||
|
||||
const columns = [
|
||||
@@ -47,7 +52,9 @@ function isSuccess(status: number) {
|
||||
body: 'flex flex-col gap-3 p-4 sm:p-4',
|
||||
}"
|
||||
>
|
||||
<h2 class="font-semibold text-highlighted">{{ t('How requests are handled') }}</h2>
|
||||
<h2 class="font-semibold text-highlighted">
|
||||
{{ t('How requests are handled') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
|
||||
<div class="border border-default">
|
||||
<UTable
|
||||
@@ -85,7 +92,10 @@ function isSuccess(status: number) {
|
||||
? 'bg-(--rc-success-soft) text-success'
|
||||
: 'bg-(--rc-error-soft) text-error'
|
||||
"
|
||||
:ui="{ base: 'h-5 gap-1 px-1.5 py-0 text-xs', leadingIcon: 'size-3' }"
|
||||
:ui="{
|
||||
base: 'h-5 gap-1 px-1.5 py-0 text-xs',
|
||||
leadingIcon: 'size-3',
|
||||
}"
|
||||
>{{ row.original.status }}</UBadge
|
||||
>
|
||||
</template>
|
||||
|
||||
@@ -64,6 +64,8 @@ function nameTone(item: ImageVariant) {
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
<p data-testid="variants-summary" class="py-1 text-muted">{{ summary }}</p>
|
||||
<p data-testid="variants-summary" class="py-1 text-muted">
|
||||
{{ summary }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -186,7 +186,10 @@ function setFormat(value: unknown) {
|
||||
:highlight="Boolean(errors.width)"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: `${FIELD_BASE} pe-10`, trailing: 'pe-3' }"
|
||||
:ui="{
|
||||
base: `${FIELD_BASE} pe-10`,
|
||||
trailing: 'pe-3',
|
||||
}"
|
||||
@blur="emit('blur', 'width')"
|
||||
>
|
||||
<template #trailing>
|
||||
@@ -218,7 +221,10 @@ function setFormat(value: unknown) {
|
||||
:highlight="Boolean(errors.height || errors.dimension)"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: `${FIELD_BASE} pe-10`, trailing: 'pe-3' }"
|
||||
:ui="{
|
||||
base: `${FIELD_BASE} pe-10`,
|
||||
trailing: 'pe-3',
|
||||
}"
|
||||
@blur="emit('blur', 'height')"
|
||||
>
|
||||
<template #trailing>
|
||||
|
||||
@@ -196,7 +196,11 @@ function handleFailure(error: unknown): FieldName | null {
|
||||
async function persist() {
|
||||
if (props.mode === 'edit' && props.variant) {
|
||||
const saved = await store.save(props.variant.name, toDefinition(form.values))
|
||||
emit('saved', { mode: 'edit', name: saved.name, purgedFiles: saved.purged_files ?? 0 })
|
||||
emit('saved', {
|
||||
mode: 'edit',
|
||||
name: saved.name,
|
||||
purgedFiles: saved.purged_files ?? 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
const created = await store.create(toNewVariant(form.values))
|
||||
|
||||
@@ -43,7 +43,10 @@ const ACTIONS_BY_STATUS: Record<VariantRow['status'], ActionKey[]> = {
|
||||
const item = computed(() => props.row.item)
|
||||
const generated = computed(() => props.row.status === 'generated')
|
||||
const actions = computed(() =>
|
||||
ACTIONS_BY_STATUS[props.row.status].map((key) => ({ key, ...ACTIONS[key] })),
|
||||
ACTIONS_BY_STATUS[props.row.status].map((key) => ({
|
||||
key,
|
||||
...ACTIONS[key],
|
||||
})),
|
||||
)
|
||||
const meta = computed(() => {
|
||||
const base = `${item.value.output_width}×${item.value.output_height} · ${formatLabel(item.value.output_format as VariantFormat)}`
|
||||
|
||||
@@ -19,7 +19,8 @@ defineProps<{ item: MediaItem }>()
|
||||
<span class="truncate font-semibold text-highlighted">{{ item.filename }}</span>
|
||||
<span class="text-xs/5 text-toned">
|
||||
{{ formatType(item.content_type) }} ·
|
||||
{{ formatDimensions(item.width, item.height) }} · {{ formatSize(item.size) }}
|
||||
{{ formatDimensions(item.width, item.height) }} ·
|
||||
{{ formatSize(item.size) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,9 @@ const props = defineProps<{
|
||||
ready: boolean
|
||||
}>()
|
||||
|
||||
const variant = defineModel<string>('variant', { default: ORIGINAL_OPTION_VALUE })
|
||||
const variant = defineModel<string>('variant', {
|
||||
default: ORIGINAL_OPTION_VALUE,
|
||||
})
|
||||
|
||||
const options = computed(() => buildOptions(props.image, props.variants))
|
||||
const vector = computed(() => isVector(props.image))
|
||||
|
||||
@@ -77,7 +77,9 @@ async function onCopy() {
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-3">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<h3 class="flex-1 font-semibold text-highlighted">{{ t('Preview') }}</h3>
|
||||
<h3 class="flex-1 font-semibold text-highlighted">
|
||||
{{ t('Preview') }}
|
||||
</h3>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
t('Sample: {0}', [SAMPLE_SOURCE.filename])
|
||||
}}</span>
|
||||
|
||||
@@ -23,7 +23,10 @@ function toItemValue(value: string) {
|
||||
}
|
||||
|
||||
const items = computed(() =>
|
||||
props.options.map((option) => ({ ...option, value: toItemValue(option.value) })),
|
||||
props.options.map((option) => ({
|
||||
...option,
|
||||
value: toItemValue(option.value),
|
||||
})),
|
||||
)
|
||||
const current = computed(() =>
|
||||
props.modelValue === undefined ? undefined : toItemValue(props.modelValue),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
withDefaults(defineProps<{ active?: 'variants' | 'cache' }>(), { active: 'variants' })
|
||||
withDefaults(defineProps<{ active?: 'variants' | 'cache' }>(), {
|
||||
active: 'variants',
|
||||
})
|
||||
|
||||
const TAB = 'h-9 gap-1.5 rounded-none border-b-2 px-0 py-0 text-sm hover:bg-transparent max-sm:h-11'
|
||||
const ACTIVE = 'border-primary font-medium text-highlighted'
|
||||
|
||||
@@ -244,14 +244,19 @@ describe('useApi abort and blob handling', () => {
|
||||
it('rethrows an AbortError without flagging the app offline', async () => {
|
||||
fetchMock.mockRejectedValue(new DOMException('aborted', 'AbortError'))
|
||||
|
||||
await expect(useApi().request('/v1/x')).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await expect(useApi().request('/v1/x')).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
})
|
||||
|
||||
expect(isOffline.value).toBe(false)
|
||||
})
|
||||
|
||||
it('requestBlob returns the response blob', async () => {
|
||||
const blob = new Blob(['a,b'])
|
||||
fetchMock.mockResolvedValue({ ...mockResponse(200), blob: async () => blob })
|
||||
fetchMock.mockResolvedValue({
|
||||
...mockResponse(200),
|
||||
blob: async () => blob,
|
||||
})
|
||||
|
||||
await expect(useApi().requestBlob('/v1/activity/export')).resolves.toBe(blob)
|
||||
})
|
||||
@@ -259,7 +264,9 @@ describe('useApi abort and blob handling', () => {
|
||||
it('requestBlob reuses the 401 handling and redirects to login', async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse(401))
|
||||
|
||||
await expectApiError(useApi().requestBlob('/v1/activity/export'), { status: 401 })
|
||||
await expectApiError(useApi().requestBlob('/v1/activity/export'), {
|
||||
status: 401,
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import {
|
||||
useArticleDraft,
|
||||
readMirroredDraft,
|
||||
} from '@/composables/useArticleDraft'
|
||||
import { useArticleDraft, readMirroredDraft } from '@/composables/useArticleDraft'
|
||||
|
||||
function mockResponse(status: number, body: unknown = null) {
|
||||
return {
|
||||
@@ -63,9 +60,7 @@ describe('A successful save sets state saved and records the time', () => {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
mockResponse(200, { id: 12, body: '<p>Changed</p>' }),
|
||||
)
|
||||
.mockResolvedValue(mockResponse(200, { id: 12, body: '<p>Changed</p>' }))
|
||||
const draft = useArticleDraft(12)
|
||||
draft.load('<p>Original</p>', new Date())
|
||||
draft.edit('<p>Changed</p>')
|
||||
|
||||
@@ -29,7 +29,12 @@ describe('useArticles', () => {
|
||||
})
|
||||
|
||||
it('sends the search text and the status when they are set', async () => {
|
||||
await useArticles().listArticles({ page: 2, size: 50, q: 'plaster', status: 'draft' })
|
||||
await useArticles().listArticles({
|
||||
page: 2,
|
||||
size: 50,
|
||||
q: 'plaster',
|
||||
status: 'draft',
|
||||
})
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'/v1/article/?format=html&page=2&size=50&q=plaster&status=draft',
|
||||
@@ -37,7 +42,11 @@ describe('useArticles', () => {
|
||||
})
|
||||
|
||||
it('sends the sort when it is set', async () => {
|
||||
await useArticles().listArticles({ page: 1, size: 25, sort: 'title:asc' })
|
||||
await useArticles().listArticles({
|
||||
page: 1,
|
||||
size: 25,
|
||||
sort: 'title:asc',
|
||||
})
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'/v1/article/?format=html&page=1&size=25&sort=title%3Aasc',
|
||||
@@ -45,7 +54,12 @@ describe('useArticles', () => {
|
||||
})
|
||||
|
||||
it('omits an empty search and an empty status', async () => {
|
||||
await useArticles().listArticles({ page: 3, size: 5, q: '', status: '' })
|
||||
await useArticles().listArticles({
|
||||
page: 3,
|
||||
size: 5,
|
||||
q: '',
|
||||
status: '',
|
||||
})
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/v1/article/?format=html&page=3&size=5')
|
||||
})
|
||||
|
||||
@@ -77,7 +77,11 @@ describe('useMedia list state', () => {
|
||||
request.mockResolvedValue(listBody([item(1)], { total: 312, page: 7 }))
|
||||
const media = useMedia()
|
||||
|
||||
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 7, size: 50 })
|
||||
const outcome = await media.load({
|
||||
...DEFAULT_CONTROLS,
|
||||
page: 7,
|
||||
size: 50,
|
||||
})
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&page=7&size=50')
|
||||
expect(media.total.value).toBe(312)
|
||||
@@ -116,7 +120,12 @@ describe('useMedia list state', () => {
|
||||
request.mockResolvedValueOnce(listBody(second, { total: 130, page: 2 }))
|
||||
const media = useMedia()
|
||||
|
||||
await media.load({ ...DEFAULT_CONTROLS, filter: 'duplicates', page: 4, size: 5 })
|
||||
await media.load({
|
||||
...DEFAULT_CONTROLS,
|
||||
filter: 'duplicates',
|
||||
page: 4,
|
||||
size: 5,
|
||||
})
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
@@ -251,7 +260,13 @@ describe('useMedia list state', () => {
|
||||
|
||||
describe('useMedia requests', () => {
|
||||
it('fetches the usage of an image', async () => {
|
||||
const usage = [{ id: 10, title: 'How we price a bathroom renovation', published: true }]
|
||||
const usage = [
|
||||
{
|
||||
id: 10,
|
||||
title: 'How we price a bathroom renovation',
|
||||
published: true,
|
||||
},
|
||||
]
|
||||
request.mockResolvedValue(usage)
|
||||
const media = useMedia()
|
||||
|
||||
@@ -298,7 +313,9 @@ describe('useMedia requests', () => {
|
||||
request.mockResolvedValueOnce(undefined)
|
||||
await media.deleteItem(1)
|
||||
|
||||
expect(request).toHaveBeenLastCalledWith('/v1/media/1', { method: 'DELETE' })
|
||||
expect(request).toHaveBeenLastCalledWith('/v1/media/1', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
expect(media.items.value.map((entry) => entry.id)).toEqual([2])
|
||||
})
|
||||
|
||||
@@ -316,7 +333,10 @@ describe('useMedia requests', () => {
|
||||
|
||||
it('copies the absolute address of the image', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
})
|
||||
const media = useMedia()
|
||||
|
||||
await media.copyUrl(item(1, { url: '/media/north-wall.jpg' }))
|
||||
|
||||
@@ -17,8 +17,14 @@ function mountEdges() {
|
||||
}
|
||||
|
||||
function layout(element: HTMLElement, scrollLeft: number) {
|
||||
Object.defineProperty(element, 'clientWidth', { configurable: true, value: 390 })
|
||||
Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 1100 })
|
||||
Object.defineProperty(element, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 390,
|
||||
})
|
||||
Object.defineProperty(element, 'scrollWidth', {
|
||||
configurable: true,
|
||||
value: 1100,
|
||||
})
|
||||
element.scrollLeft = scrollLeft
|
||||
element.dispatchEvent(new Event('scroll'))
|
||||
}
|
||||
@@ -53,8 +59,14 @@ describe('useScrollEdges', () => {
|
||||
|
||||
it('shows no fade when the content fits', async () => {
|
||||
const { element, edges } = mountEdges()
|
||||
Object.defineProperty(element, 'clientWidth', { configurable: true, value: 500 })
|
||||
Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 500 })
|
||||
Object.defineProperty(element, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 500,
|
||||
})
|
||||
Object.defineProperty(element, 'scrollWidth', {
|
||||
configurable: true,
|
||||
value: 500,
|
||||
})
|
||||
element.dispatchEvent(new Event('scroll'))
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -61,7 +61,12 @@ export function useActivity() {
|
||||
return {
|
||||
users: users.flatMap((item) =>
|
||||
item.kind === 'user'
|
||||
? [{ id: item.id, name: item.display_name || item.username }]
|
||||
? [
|
||||
{
|
||||
id: item.id,
|
||||
name: item.display_name || item.username,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
keys: keys.map((key) => ({ id: key.id, name: key.name })),
|
||||
|
||||
@@ -208,7 +208,9 @@ export function useActivityPage() {
|
||||
const { size: current, ...rest } = route.query
|
||||
const wanted = size.value === DEFAULT_PAGE_SIZE ? undefined : String(size.value)
|
||||
if (current === wanted) return
|
||||
router.replace({ query: wanted === undefined ? rest : { ...rest, size: wanted } })
|
||||
router.replace({
|
||||
query: wanted === undefined ? rest : { ...rest, size: wanted },
|
||||
})
|
||||
}
|
||||
|
||||
syncSizeQuery()
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { bodyImageCount } from '@/lib/bodyImageIds'
|
||||
|
||||
interface PublishHandlers {
|
||||
onUpdated: (updated: Article) => void
|
||||
onGone: () => void
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
interface DeleteHandlers {
|
||||
onDeleted: () => void
|
||||
onGone: () => void
|
||||
}
|
||||
|
||||
type ArticleRef = Pick<Article, 'id' | 'title'>
|
||||
|
||||
function deleteDescription(article: Pick<Article, 'title' | 'body'>) {
|
||||
const count = bodyImageCount(article.body)
|
||||
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
|
||||
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
|
||||
article.title,
|
||||
imagesPhrase,
|
||||
])
|
||||
}
|
||||
|
||||
function isNotFound(err: unknown) {
|
||||
return err instanceof ApiError && err.status === 404
|
||||
}
|
||||
|
||||
export function useArticleActions() {
|
||||
const { request } = useApi()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
function announcePublished(article: ArticleRef, makeOnline: boolean) {
|
||||
toast.add(() => ({
|
||||
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return makeOnline ? t('Article published') : t('Article unpublished')
|
||||
},
|
||||
get description() {
|
||||
return makeOnline
|
||||
? t('\u0022{0}\u0022 is now online.', [article.title])
|
||||
: t('\u0022{0}\u0022 is now offline.', [article.title])
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function announcePublishFailed(id: number, makeOnline: boolean, retry: () => void) {
|
||||
toast.add(() => ({
|
||||
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${id}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
get title() {
|
||||
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
|
||||
},
|
||||
get description() {
|
||||
return t("The server didn't respond. Nothing changed.")
|
||||
},
|
||||
duration: 0,
|
||||
actions: [
|
||||
{
|
||||
get label() {
|
||||
return t('Retry')
|
||||
},
|
||||
onClick: retry,
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function announceDeleted(article: ArticleRef) {
|
||||
toast.add(() => ({
|
||||
id: `article-deleted-${article.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Article deleted')
|
||||
},
|
||||
get description() {
|
||||
return t('\u0022{0}\u0022 is gone.', [article.title])
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async function publish(article: ArticleRef, makeOnline: boolean, handlers: PublishHandlers) {
|
||||
try {
|
||||
const updated = await request<Article>(
|
||||
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
|
||||
{ method: 'PATCH' },
|
||||
)
|
||||
handlers.onUpdated(updated)
|
||||
announcePublished(updated, makeOnline)
|
||||
} catch (err) {
|
||||
if (isNotFound(err)) {
|
||||
handlers.onGone()
|
||||
return
|
||||
}
|
||||
announcePublishFailed(article.id, makeOnline, handlers.retry)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteArticle(article: ArticleRef, handlers: DeleteHandlers) {
|
||||
try {
|
||||
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
|
||||
handlers.onDeleted()
|
||||
announceDeleted(article)
|
||||
} catch (err) {
|
||||
if (isNotFound(err)) {
|
||||
handlers.onGone()
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
t("Couldn't delete the article, so it's still there. Try again in a moment."),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(article: Article, action: () => Promise<void>) {
|
||||
return overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return t('Delete this article?')
|
||||
},
|
||||
get description() {
|
||||
return deleteDescription(article)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Delete article')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Deleting…')
|
||||
},
|
||||
tone: 'danger',
|
||||
action,
|
||||
},
|
||||
})
|
||||
.open()
|
||||
}
|
||||
|
||||
return { publish, deleteArticle, confirmDelete }
|
||||
}
|
||||
@@ -1,33 +1,86 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import type { Article, Byline } from '@/composables/useArticles'
|
||||
import { sameIds, toBylines } from '@/lib/articleMeta'
|
||||
import { AUTHOR_CODES, SITE_CODES, codeOf, statusOf } from '@/lib/directoryErrors'
|
||||
|
||||
export type ArticleDraftBusinessState =
|
||||
| 'clean'
|
||||
| 'dirty'
|
||||
| 'saving'
|
||||
export type ArticleDraftBusinessState = 'clean' | 'dirty' | 'saving' | 'saved' | 'error'
|
||||
export type ArticleDraftState = ArticleDraftBusinessState | 'offline'
|
||||
export type SaveOutcome =
|
||||
| 'saved'
|
||||
| 'error'
|
||||
export type ArticleDraftState = ArticleDraftBusinessState | 'offline'
|
||||
export type SaveOutcome = 'saved' | 'error' | 'not-found' | 'unauthorized'
|
||||
| 'not-found'
|
||||
| 'unauthorized'
|
||||
| 'site-gone'
|
||||
| 'author-gone'
|
||||
|
||||
export interface ArticleMeta {
|
||||
siteId: number | null
|
||||
authors: Byline[]
|
||||
}
|
||||
|
||||
function draftStorageKey(articleId: number) {
|
||||
return `renewcms:article-draft:${articleId}`
|
||||
}
|
||||
|
||||
function metaStorageKey(articleId: number) {
|
||||
return `renewcms:article-draft-meta:${articleId}`
|
||||
}
|
||||
|
||||
function readStorage(key: string): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key: string, value: string) {
|
||||
try {
|
||||
sessionStorage.setItem(key, value)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function removeStorage(key: string) {
|
||||
try {
|
||||
sessionStorage.removeItem(key)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function readMirroredMeta(articleId: number): ArticleMeta | null {
|
||||
const raw = readStorage(metaStorageKey(articleId))
|
||||
if (raw === null) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
site_id: number | null
|
||||
authors: Byline[]
|
||||
}
|
||||
return { siteId: parsed.site_id, authors: toBylines(parsed.authors) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readMirroredDraft(articleId: number): string | null {
|
||||
return sessionStorage.getItem(draftStorageKey(articleId))
|
||||
return readStorage(draftStorageKey(articleId))
|
||||
}
|
||||
|
||||
export function useArticleDraft(articleId: number) {
|
||||
const { request } = useApi()
|
||||
|
||||
const body = ref('')
|
||||
const bodyEdited = ref(false)
|
||||
const meta = ref<ArticleMeta>({ siteId: null, authors: [] })
|
||||
const savedMeta = ref<ArticleMeta>({ siteId: null, authors: [] })
|
||||
const businessState = ref<ArticleDraftBusinessState>('clean')
|
||||
const savedAt = ref<Date | null>(null)
|
||||
const offline = ref(
|
||||
typeof navigator === 'undefined' ? false : !navigator.onLine,
|
||||
)
|
||||
const offline = ref(typeof navigator === 'undefined' ? false : !navigator.onLine)
|
||||
|
||||
function handleOffline() {
|
||||
offline.value = true
|
||||
@@ -51,44 +104,100 @@ export function useArticleDraft(articleId: number) {
|
||||
offline.value ? 'offline' : businessState.value,
|
||||
)
|
||||
|
||||
function siteChanged() {
|
||||
return meta.value.siteId !== savedMeta.value.siteId
|
||||
}
|
||||
|
||||
function authorsChanged() {
|
||||
return !sameIds(meta.value.authors, savedMeta.value.authors)
|
||||
}
|
||||
|
||||
function mirrorToStorage() {
|
||||
sessionStorage.setItem(draftStorageKey(articleId), body.value)
|
||||
if (bodyEdited.value) {
|
||||
writeStorage(draftStorageKey(articleId), body.value)
|
||||
}
|
||||
if (siteChanged() || authorsChanged()) {
|
||||
writeStorage(
|
||||
metaStorageKey(articleId),
|
||||
JSON.stringify({
|
||||
site_id: meta.value.siteId,
|
||||
authors: meta.value.authors,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function clearStorage() {
|
||||
sessionStorage.removeItem(draftStorageKey(articleId))
|
||||
removeStorage(draftStorageKey(articleId))
|
||||
removeStorage(metaStorageKey(articleId))
|
||||
}
|
||||
|
||||
function load(initialBody: string, initialSavedAt: Date) {
|
||||
function load(initialBody: string, initialSavedAt: Date, initialMeta?: ArticleMeta) {
|
||||
const mirrored = readMirroredDraft(articleId)
|
||||
const mirroredMeta = readMirroredMeta(articleId)
|
||||
savedAt.value = initialSavedAt
|
||||
if (mirrored !== null) {
|
||||
body.value = mirrored
|
||||
businessState.value = 'dirty'
|
||||
return
|
||||
}
|
||||
body.value = initialBody
|
||||
businessState.value = 'clean'
|
||||
savedMeta.value = initialMeta ?? { siteId: null, authors: [] }
|
||||
meta.value = mirroredMeta ?? savedMeta.value
|
||||
bodyEdited.value = mirrored !== null
|
||||
body.value = mirrored ?? initialBody
|
||||
businessState.value = mirrored !== null || mirroredMeta !== null ? 'dirty' : 'clean'
|
||||
}
|
||||
|
||||
function edit(newBody: string) {
|
||||
body.value = newBody
|
||||
bodyEdited.value = true
|
||||
businessState.value = 'dirty'
|
||||
}
|
||||
|
||||
function editMeta(next: Partial<ArticleMeta>) {
|
||||
meta.value = { ...meta.value, ...next }
|
||||
businessState.value = 'dirty'
|
||||
}
|
||||
|
||||
function savePayload() {
|
||||
return {
|
||||
...(bodyEdited.value ? { body: body.value } : {}),
|
||||
...(siteChanged() ? { site_id: meta.value.siteId } : {}),
|
||||
...(authorsChanged()
|
||||
? { author_ids: meta.value.authors.map((author) => author.id) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function goneOutcome(err: unknown): SaveOutcome | null {
|
||||
if (statusOf(err) !== 404) {
|
||||
return null
|
||||
}
|
||||
const code = codeOf(err)
|
||||
if (code === SITE_CODES.notFound) {
|
||||
return 'site-gone'
|
||||
}
|
||||
return code === AUTHOR_CODES.notFound ? 'author-gone' : null
|
||||
}
|
||||
|
||||
async function save(): Promise<SaveOutcome> {
|
||||
businessState.value = 'saving'
|
||||
const payload = savePayload()
|
||||
try {
|
||||
await request<Article>(`/v1/article/${articleId}?format=html`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: body.value }),
|
||||
})
|
||||
if (Object.keys(payload).length > 0) {
|
||||
await request<Article>(`/v1/article/${articleId}?format=html`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
businessState.value = 'saved'
|
||||
savedAt.value = new Date()
|
||||
savedMeta.value = meta.value
|
||||
bodyEdited.value = false
|
||||
clearStorage()
|
||||
return 'saved'
|
||||
} catch (err) {
|
||||
const gone = goneOutcome(err)
|
||||
if (gone) {
|
||||
businessState.value = 'error'
|
||||
return gone
|
||||
}
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
businessState.value = 'dirty'
|
||||
return 'not-found'
|
||||
@@ -103,5 +212,5 @@ export function useArticleDraft(articleId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
return { body, state, savedAt, load, edit, save }
|
||||
return { body, meta, state, savedAt, load, edit, editMeta, save }
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { nextTick, ref, type Ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { useArticleActions } from '@/composables/useArticleActions'
|
||||
import type { Article, ArticleCounts } from '@/composables/useArticles'
|
||||
import type { ArticleListQuery } from '@/lib/articleListQuery'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
|
||||
interface ArticleRowActionsState {
|
||||
items: Ref<Article[]>
|
||||
total: Ref<number>
|
||||
counts: Ref<ArticleCounts>
|
||||
shown: Ref<ArticleListQuery>
|
||||
reload: (query: ArticleListQuery, mode: 'push' | 'replace') => void
|
||||
}
|
||||
|
||||
export function useArticleRowActions({
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
shown,
|
||||
reload,
|
||||
}: ArticleRowActionsState) {
|
||||
const actions = useArticleActions()
|
||||
const toast = useLocalizedToast()
|
||||
const busyIds = ref<Set<number>>(new Set())
|
||||
|
||||
function setBusy(id: number, busy: boolean) {
|
||||
const next = new Set(busyIds.value)
|
||||
if (busy) {
|
||||
next.add(id)
|
||||
} else {
|
||||
next.delete(id)
|
||||
}
|
||||
busyIds.value = next
|
||||
}
|
||||
|
||||
function shiftCounts(article: Article, delta: number) {
|
||||
const key = article.is_online ? 'online' : 'draft'
|
||||
counts.value = {
|
||||
...counts.value,
|
||||
all: Math.max(0, counts.value.all + delta),
|
||||
[key]: Math.max(0, counts.value[key] + delta),
|
||||
}
|
||||
}
|
||||
|
||||
function detachRow(id: number) {
|
||||
if (!items.value.some((article) => article.id === id)) {
|
||||
return
|
||||
}
|
||||
items.value = items.value.filter((article) => article.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
if (items.value.length === 0 && total.value > 0) {
|
||||
const page = Math.min(shown.value.page, pageCountFor(total.value, shown.value.size))
|
||||
reload({ ...shown.value, page }, 'replace')
|
||||
}
|
||||
}
|
||||
|
||||
function removeArticle(article: Article) {
|
||||
shiftCounts(article, -1)
|
||||
detachRow(article.id)
|
||||
}
|
||||
|
||||
function replaceArticle(updated: Article, previous: Article) {
|
||||
items.value = items.value.map((existing) =>
|
||||
existing.id === updated.id ? updated : existing,
|
||||
)
|
||||
if (updated.is_online === previous.is_online) {
|
||||
return
|
||||
}
|
||||
counts.value = {
|
||||
...counts.value,
|
||||
online: counts.value.online + (updated.is_online ? 1 : -1),
|
||||
draft: counts.value.draft + (updated.is_online ? -1 : 1),
|
||||
}
|
||||
const filter = shown.value.status
|
||||
if (filter && (filter === 'online') !== updated.is_online) {
|
||||
detachRow(updated.id)
|
||||
}
|
||||
}
|
||||
|
||||
function notFoundToast(article: Article) {
|
||||
return {
|
||||
id: `article-not-found-${article.id}`,
|
||||
color: 'info' as const,
|
||||
icon: 'i-lucide-info',
|
||||
get title() {
|
||||
return t('That article no longer exists')
|
||||
},
|
||||
get description() {
|
||||
return t('It was deleted somewhere else, so we removed it from the list.')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function setOnline(article: Article, makeOnline: boolean) {
|
||||
setBusy(article.id, true)
|
||||
try {
|
||||
await actions.publish(article, makeOnline, {
|
||||
onUpdated: (updated) => replaceArticle(updated, article),
|
||||
onGone: () => goneFromList(article),
|
||||
retry: () => setOnline(article, makeOnline),
|
||||
})
|
||||
} finally {
|
||||
setBusy(article.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
function findItem(id: number) {
|
||||
return items.value.find((existing) => existing.id === id)
|
||||
}
|
||||
|
||||
function handlePublish(id: number) {
|
||||
const article = findItem(id)
|
||||
if (article) {
|
||||
setOnline(article, true)
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnpublish(id: number) {
|
||||
const article = findItem(id)
|
||||
if (article) {
|
||||
setOnline(article, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyUrl(id: number) {
|
||||
const article = findItem(id)
|
||||
if (!article) {
|
||||
return
|
||||
}
|
||||
const url = `${window.location.origin}/v1/article/${id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.add(() => ({
|
||||
id: `article-link-copied-${id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Link copied')
|
||||
},
|
||||
get description() {
|
||||
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [
|
||||
article.title,
|
||||
])
|
||||
},
|
||||
}))
|
||||
} catch {
|
||||
toast.add(() => ({
|
||||
id: `article-link-copy-failed-${id}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
get title() {
|
||||
return t("Couldn't copy the link")
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function goneFromList(article: Article) {
|
||||
removeArticle(article)
|
||||
toast.add(() => notFoundToast(article))
|
||||
}
|
||||
|
||||
function performDelete(article: Article) {
|
||||
return actions.deleteArticle(article, {
|
||||
onDeleted: () => removeArticle(article),
|
||||
onGone: () => goneFromList(article),
|
||||
})
|
||||
}
|
||||
|
||||
async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
|
||||
await nextTick()
|
||||
const index = idsAtOpen.indexOf(deletedId)
|
||||
const nextId = idsAtOpen[index + 1] ?? idsAtOpen.slice(0, index).reverse()[0]
|
||||
if (nextId != null) {
|
||||
document.querySelector<HTMLElement>(`[data-row-title="${nextId}"]`)?.focus()
|
||||
return
|
||||
}
|
||||
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
|
||||
}
|
||||
|
||||
async function handleDelete(article: Article) {
|
||||
const idsAtOpen = items.value.map((existing) => existing.id)
|
||||
const confirmed = await actions.confirmDelete(article, () => performDelete(article))
|
||||
if (confirmed) {
|
||||
focusRowOrHeading(idsAtOpen, article.id)
|
||||
}
|
||||
}
|
||||
|
||||
return { busyIds, handlePublish, handleUnpublish, handleCopyUrl, handleDelete }
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { Byline } from '@/lib/articleTypes'
|
||||
import type { ArticleStatus } from '@/lib/articleListQuery'
|
||||
|
||||
export type { Byline }
|
||||
|
||||
export interface Article {
|
||||
id: number
|
||||
title: string
|
||||
body: string
|
||||
is_online: boolean
|
||||
site_id?: number
|
||||
authors?: Byline[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -32,6 +37,7 @@ export interface ListArticlesParams {
|
||||
q?: string
|
||||
status?: ArticleStatus | ''
|
||||
sort?: string
|
||||
site?: string
|
||||
}
|
||||
|
||||
export function useArticles() {
|
||||
@@ -52,6 +58,9 @@ export function useArticles() {
|
||||
if (params.sort) {
|
||||
query.set('sort', params.sort)
|
||||
}
|
||||
if (params.site) {
|
||||
query.set('site', params.site)
|
||||
}
|
||||
return request<ArticlePage>(`/v1/article/?${query.toString()}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ export interface Author {
|
||||
user_email?: string
|
||||
}
|
||||
|
||||
export interface MyAuthor extends Author {
|
||||
slug_adjusted: boolean
|
||||
}
|
||||
|
||||
export interface AuthorPage {
|
||||
items: Author[]
|
||||
total: number
|
||||
@@ -47,6 +51,12 @@ export interface NewAuthor {
|
||||
user_id: number | null
|
||||
}
|
||||
|
||||
export interface NewByline {
|
||||
name: string
|
||||
slug: string
|
||||
user_id?: number
|
||||
}
|
||||
|
||||
export type AuthorPatch = Partial<NewAuthor>
|
||||
|
||||
export type ReassignTarget = number | 'none' | undefined
|
||||
@@ -75,7 +85,11 @@ export function useAuthors() {
|
||||
let page = 1
|
||||
let total = 0
|
||||
do {
|
||||
const response = await listAuthors({ q: '', page, size: OPTION_PAGE_SIZE })
|
||||
const response = await listAuthors({
|
||||
q: '',
|
||||
page,
|
||||
size: OPTION_PAGE_SIZE,
|
||||
})
|
||||
all.push(...response.items)
|
||||
total = response.total
|
||||
page += 1
|
||||
@@ -91,16 +105,22 @@ export function useAuthors() {
|
||||
return listUsersWithoutAuthor(new Set(authors.map((author) => author.user_id)))
|
||||
}
|
||||
|
||||
function createAuthor(author: NewAuthor): Promise<Author> {
|
||||
function createAuthor(author: NewAuthor | NewByline): Promise<Author> {
|
||||
return request<Author>('/v1/author/', jsonInit('POST', author))
|
||||
}
|
||||
|
||||
function createMyAuthor(): Promise<MyAuthor> {
|
||||
return request<MyAuthor>('/v1/author/me', { method: 'POST' })
|
||||
}
|
||||
|
||||
function updateAuthor(id: number, patch: AuthorPatch): Promise<Author> {
|
||||
return request<Author>(`/v1/author/${id}`, jsonInit('PATCH', patch))
|
||||
}
|
||||
|
||||
function deleteAuthor(id: number, reassignTo: ReassignTarget): Promise<void> {
|
||||
return request<void>(`/v1/author/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
|
||||
return request<void>(`/v1/author/${id}${reassignQuery(reassignTo)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -108,6 +128,7 @@ export function useAuthors() {
|
||||
listAllAuthors,
|
||||
listLinkableUsers,
|
||||
createAuthor,
|
||||
createMyAuthor,
|
||||
updateAuthor,
|
||||
deleteAuthor,
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ export function useColorMode() {
|
||||
|
||||
return {
|
||||
get preference(): ColorModePreference {
|
||||
return store.value === 'auto'
|
||||
? 'system'
|
||||
: (store.value as ColorModePreference)
|
||||
return store.value === 'auto' ? 'system' : (store.value as ColorModePreference)
|
||||
},
|
||||
set preference(value: ColorModePreference) {
|
||||
store.value = value === 'system' ? 'auto' : value
|
||||
|
||||
@@ -72,5 +72,12 @@ export function useDirectoryView() {
|
||||
open(item)
|
||||
}
|
||||
|
||||
return { rememberOpener, restoreFocus, editParam, dropEditParam, leavePanel, followEditLink }
|
||||
return {
|
||||
rememberOpener,
|
||||
restoreFocus,
|
||||
editParam,
|
||||
dropEditParam,
|
||||
leavePanel,
|
||||
followEditLink,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { bodyImageIds } from '@/lib/bodyImageIds'
|
||||
import { formatSize } from '@/lib/mediaFormat'
|
||||
import { useSeenImagesStore } from '@/stores/seenImages'
|
||||
|
||||
export function useEditorImages(body: () => string) {
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
const seenImages = useSeenImagesStore()
|
||||
const editorRef = ref<InstanceType<typeof ArticleEditor> | null>(null)
|
||||
const imageUploaderOpen = ref(false)
|
||||
const imageUploaderFile = ref<File | null>(null)
|
||||
const imageIdsInArticle = ref<number[]>([])
|
||||
|
||||
function openImageUploader(file: File | null) {
|
||||
imageUploaderFile.value = file
|
||||
imageIdsInArticle.value = bodyImageIds(body())
|
||||
imageUploaderOpen.value = true
|
||||
}
|
||||
|
||||
function onRequestImage() {
|
||||
openImageUploader(null)
|
||||
}
|
||||
|
||||
function onEditorDrop(event: DragEvent) {
|
||||
const file = event.dataTransfer?.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
openImageUploader(file)
|
||||
}
|
||||
|
||||
function announceImageAdded() {
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Image added')
|
||||
},
|
||||
get description() {
|
||||
return t('Save to keep it in the article.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function onImageUploaded(item: MediaItem) {
|
||||
seenImages.remember([item])
|
||||
editorRef.value?.insertImage(item.url, '', item.id)
|
||||
announceImageAdded()
|
||||
}
|
||||
|
||||
function onImageInserted(item: MediaItem, alt: string, src: string) {
|
||||
seenImages.remember([item])
|
||||
editorRef.value?.insertImage(src, alt, item.id)
|
||||
announceImageAdded()
|
||||
}
|
||||
|
||||
async function performRemoveImage(imageId: number) {
|
||||
editorRef.value?.removeImage(imageId)
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Image removed')
|
||||
},
|
||||
get description() {
|
||||
return t("It's still in your library.")
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function removalPreview(imageId: number) {
|
||||
const seen = seenImages.find(imageId)
|
||||
return {
|
||||
src: editorRef.value?.imageSrc(imageId) ?? '',
|
||||
filename: seen?.filename,
|
||||
size: seen ? formatSize(seen.size) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function onRequestRemoveImage(imageId: number) {
|
||||
await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return t('Remove this image from the article?')
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
|
||||
)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Remove image')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Removing…')
|
||||
},
|
||||
tone: 'neutral',
|
||||
preview: removalPreview(imageId),
|
||||
action: () => performRemoveImage(imageId),
|
||||
},
|
||||
})
|
||||
.open()
|
||||
}
|
||||
|
||||
function onImageUploaderClosed() {
|
||||
imageUploaderFile.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
editorRef,
|
||||
imageUploaderOpen,
|
||||
imageUploaderFile,
|
||||
imageIdsInArticle,
|
||||
onRequestImage,
|
||||
onEditorDrop,
|
||||
onImageUploaded,
|
||||
onImageInserted,
|
||||
onRequestRemoveImage,
|
||||
onImageUploaderClosed,
|
||||
}
|
||||
}
|
||||
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user