Compare commits

...
167 changed files with 7995 additions and 1512 deletions

No files matched your search

+4
View File
@@ -15,6 +15,10 @@ cp .env.example .env # fill in PUBLIC_URL, JWT_SECRET and SMTP_*
Data is stored in `./data/` by default. For a real server (systemd, HTTPS, backups), see **[docs/deployment.md](docs/deployment.md)**. Data is stored in `./data/` by default. For a real server (systemd, HTTPS, backups), see **[docs/deployment.md](docs/deployment.md)**.
### Updates from the browser
On Linux with systemd, install v2.6.0-beta or newer manually, then follow [the browser-update setup guide](docs/deployment.md#browser-updates). It covers finding the actual database directory, installing the helper, testing a restart and troubleshooting. Other platforms use manual upgrades.
### Develop ### Develop
Requires Go 1.27+, Node.js 22, [Bun](https://bun.sh) and [Task](https://taskfile.dev). Requires Go 1.27+, Node.js 22, [Bun](https://bun.sh) and [Task](https://taskfile.dev).
+6 -4
View File
@@ -10,6 +10,7 @@ import (
"RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer" "RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/persistence/models" "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories" persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/update" "RenewCMS/internal/infrastructure/update"
@@ -152,13 +153,14 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
func getUseCases(repos *Repositories, settings config.Settings) *UseCases { func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
renderer := markdown.NewConverter()
return &UseCases{ return &UseCases{
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil), Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo), CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL), CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
+102 -24
View File
@@ -12,6 +12,7 @@ import (
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -41,6 +42,53 @@ func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleU
} }
} }
type articleResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body json.RawMessage `json:"body"`
BodyFormat domain.BodyFormat `json:"body_format"`
IsOnline bool `json:"is_online"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) {
body := json.RawMessage(article.Body)
if format != domain.BodyFormatJSON {
encoded, err := json.Marshal(article.Body)
if err != nil {
return articleResponse{}, err
}
body = encoded
}
return articleResponse{
ID: article.ID,
Title: article.Title,
Body: body,
BodyFormat: format,
IsOnline: article.IsOnline,
CreatedAt: article.CreatedAt,
UpdatedAt: article.UpdatedAt,
}, nil
}
func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) {
response, err := toArticleResponse(article, format)
if err != nil {
return nil, err
}
return json.Marshal(response)
}
func parseBodyFormat(w http.ResponseWriter, r *http.Request) (domain.BodyFormat, bool) {
format, err := domain.ParseBodyFormat(r.URL.Query().Get("format"))
if err != nil {
http.Error(w, apperr.ArticleBodyFormatErrorMessage, http.StatusBadRequest)
return "", false
}
return format, true
}
func parseArticleId(r *http.Request) (uint32, error) { func parseArticleId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err return uint32(id), err
@@ -53,11 +101,16 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var article domain.Article var article domain.Article
if h.callerIsVerified(r) { if h.callerIsVerified(r) {
article, err = h.GetUseCase.GetArticle(id) article, err = h.GetUseCase.GetArticle(id, format)
} else { } else {
article, err = h.GetUseCase.GetOnlineArticle(id) article, err = h.GetUseCase.GetOnlineArticle(id, format)
} }
if err != nil { if err != nil {
if errors.Is(err, domain.ErrArticleNotFound) { if errors.Is(err, domain.ErrArticleNotFound) {
@@ -68,7 +121,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
articleJson, err := json.Marshal(article) articleJson, err := encodeArticle(article, format)
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
@@ -80,6 +133,11 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) { func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var localArticle PostArticle var localArticle PostArticle
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil { if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
@@ -92,8 +150,9 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
} }
createdArticle, err := h.CreateUseCase.CreateArticle(useCases.CreateArticleCommand{ createdArticle, err := h.CreateUseCase.CreateArticle(useCases.CreateArticleCommand{
Title: localArticle.Title, Title: localArticle.Title,
Body: localArticle.Body, Body: localArticle.Body,
Format: format,
}) })
if err != nil { if err != nil {
if errors.Is(err, domain.ErrInvalidTitle) { if errors.Is(err, domain.ErrInvalidTitle) {
@@ -104,16 +163,7 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
articleJson, err := json.Marshal(createdArticle) writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
if _, err := w.Write(articleJson); err != nil {
log.Printf("Failed to write response: %v", err)
}
} }
func writeArticleErrorResponse(w http.ResponseWriter, err error) { func writeArticleErrorResponse(w http.ResponseWriter, err error) {
@@ -124,13 +174,16 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
} }
func writeArticleJsonResponse(w http.ResponseWriter, article domain.Article) { func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) {
articleJson, err := json.Marshal(article) articleJson, err := encodeArticle(article, format)
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
} }
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(articleJson); err != nil { if _, err := w.Write(articleJson); err != nil {
log.Printf("Failed to write response: %v", err) log.Printf("Failed to write response: %v", err)
} }
@@ -143,13 +196,18 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
return return
} }
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline) format, ok := parseBodyFormat(w, r)
if !ok {
return
}
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline, format)
if err != nil { if err != nil {
writeArticleErrorResponse(w, err) writeArticleErrorResponse(w, err)
return return
} }
writeArticleJsonResponse(w, updatedArticle) writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
} }
type UpdateArticleBody struct { type UpdateArticleBody struct {
@@ -163,6 +221,11 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return return
} }
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var localArticle UpdateArticleBody var localArticle UpdateArticleBody
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil { if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
@@ -174,13 +237,13 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return return
} }
updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body) updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body, format)
if err != nil { if err != nil {
writeArticleErrorResponse(w, err) writeArticleErrorResponse(w, err)
return return
} }
writeArticleJsonResponse(w, updatedArticle) writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
} }
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) { func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
@@ -206,19 +269,34 @@ func (h *Handler) callerIsVerified(r *http.Request) bool {
} }
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var articles []domain.Article var articles []domain.Article
var err error var err error
if h.callerIsVerified(r) { if h.callerIsVerified(r) {
articles, err = h.ListUseCase.ListAllArticles() articles, err = h.ListUseCase.ListAllArticles(format)
} else { } else {
articles, err = h.ListUseCase.ListOnlineArticles() articles, err = h.ListUseCase.ListOnlineArticles(format)
} }
if err != nil { if err != nil {
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError) http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
return return
} }
articlesJson, err := json.Marshal(articles) responses := make([]articleResponse, 0, len(articles))
for _, article := range articles {
response, err := toArticleResponse(article, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
responses = append(responses, response)
}
articlesJson, err := json.Marshal(responses)
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
+1
View File
@@ -34,3 +34,4 @@ const UpdateAlreadyRunningErrorMessage = "An update is already running or waitin
const NothingToUpdateErrorMessage = "There is nothing to update." const NothingToUpdateErrorMessage = "There is nothing to update."
const UpdateRefusedErrorMessage = "This update cannot be downloaded." const UpdateRefusedErrorMessage = "This update cannot be downloaded."
const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again." const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again."
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
+97
View File
@@ -0,0 +1,97 @@
# Reading articles
Every route that returns an article accepts the optional `format` query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of `POST /v1/article/` and `PATCH /v1/article/{id}` stay HTML.
## Routes that honour `format`
- `GET /v1/article/`
- `GET /v1/article/{id}`
- `POST /v1/article/`
- `PATCH /v1/article/{id}`
- `PATCH /v1/article/{id}/publish`
- `PATCH /v1/article/{id}/unpublish`
Visibility is unchanged: anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`.
## The `format` parameter
| Value | `body` | `body_format` |
| --- | --- | --- |
| empty or absent | markdown string | `markdown` |
| `markdown` | markdown string | `markdown` |
| `html` | the stored HTML string | `html` |
| `json` | JSON array of blocks | `json` |
Values are case-insensitive. When `format` is repeated, the first value is used. Any other value answers `400` with the plain-text message `The format must be one of: markdown, html, json.` before any article is read or written. An invalid id is reported first, ahead of the format.
Breaking change: the default used to be HTML. Clients that need HTML must send `?format=html`. The web interface does so.
## Article object
```json
{
"id": 1,
"title": "Backyard Renovation Update",
"body": "## Plan\n\nReplace the **old** deck.",
"body_format": "markdown",
"is_online": true,
"created_at": "2026-03-12T09:14:00Z",
"updated_at": "2026-09-10T07:00:00Z"
}
```
## Markdown output
Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline.
### Text alignment
Markdown has no syntax for alignment, so centered, right-aligned and justified paragraphs and headings are wrapped in a `div` with an inline style. The blank lines around the content make CommonMark parse the inside as markdown:
```markdown
<div style="text-align: center">
A **centered** paragraph
</div>
```
Left alignment is the default and produces no wrapper. Your markdown renderer must allow raw HTML for the alignment to show (`html: true` in markdown-it); otherwise the wrapper tags appear as text, and a client that does not want them can request `?format=json` instead, where the alignment is a field.
## JSON block schema
`body` is a native JSON array. Every block has a `type`.
| Block | Fields |
| --- | --- |
| `heading` | `level` (1 to 6), `align` (only when `center`, `right` or `justify`), `children` (text nodes) |
| `paragraph` | `align` (only when `center`, `right` or `justify`), `children` (text nodes, empty array for an empty paragraph) |
| `list` | `ordered` (boolean), `items` (array of items, each an array of blocks) |
| `blockquote` | `children` (blocks) |
| `code` | `language` (empty string when unknown), `text` |
| `hr` | none |
| `image` | `src`, `alt` (empty string when missing) |
Text node: `{"type":"text","text":"...","marks":[...]}`. `marks` is always an array, listed outermost first. A mark is `{"type":"bold"}`, `{"type":"italic"}` or `{"type":"link","href":"..."}`. A line break becomes a `"\n"` inside the text. Adjacent text with identical marks is merged. Inline code, underline, strike-through and other formatting degrade to plain text. An image inside a paragraph becomes its own block and splits the paragraph. An empty body is `[]`.
Example for `<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>`:
```json
[
{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},
{"type":"paragraph","children":[
{"type":"text","text":"Replace the ","marks":[]},
{"type":"text","text":"old","marks":[{"type":"bold"}]},
{"type":"text","text":" deck.","marks":[]}
]}
]
```
## Errors
| Status | Body |
| --- | --- |
| `400` | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` for an invalid id |
| `400` | `The format must be one of: markdown, html, json.` for an unsupported format |
| `404` | `Article not found` |
| `500` | `Internal server error` when the body cannot be converted |
+2
View File
@@ -34,6 +34,8 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)). Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
For example, `WorkingDirectory=/var/lib/renewcms` with `DB_FILE=./data/renewcms.db` puts the database in `/var/lib/renewcms/data`. Browser-update `--data-dir` must name that directory, because the app reads helper status and update records beside the database. It is not necessarily `WorkingDirectory` or `ReadWritePaths`. Do not change an existing `DB_FILE` merely to match the helper: that could open a different, empty database. See [browser-update setup](deployment.md#browser-updates).
## Adding a variable ## Adding a variable
1. Read it in the code where it is needed (infrastructure or `api/`, never `internal/domain`). 1. Read it in the code where it is needed (infrastructure or `api/`, never `internal/domain`).
+82 -8
View File
@@ -8,7 +8,7 @@ RenewCMS ships as **one self-contained executable**. It serves:
| `/healthz` | Liveness check, returns `200 ok` | | `/healthz` | Liveness check, returns `200 ok` |
| `/*` | The Vue SPA (embedded), with fallback to `index.html` for client-side routes | | `/*` | The Vue SPA (embedded), with fallback to `index.html` for client-side routes |
Its only runtime state is the SQLite database (`DB_FILE`) and the uploads directory (`UPLOAD_DIR`). Nothing else needs to be installed on the server. Runtime state includes the SQLite database (`DB_FILE`), uploads (`UPLOAD_DIR`), and update records, staged binaries and backups beside the database. Running a release binary needs no Go, Bun or Node installation. Browser updates additionally require Linux with systemd and the helper setup below.
## 1. Build ## 1. Build
@@ -26,7 +26,7 @@ The frontend is built once and embedded into every binary. The binaries are stat
### Release signing key (maintainers, once) ### Release signing key (maintainers, once)
1. Run `task release:keygen -- <path>` with a path outside the repository (for example `~/.renewcms/release.key`). The private key is written there and never leaves your machine; keygen refuses a path inside the work tree. 1. Run `task release:keygen -- <path>` with a path outside the repository (for example `~/.renewcms/release.key`). The private key is written there and never leaves your machine; keygen refuses a path inside the work tree.
2. Commit the printed public key as `internal/infrastructure/update/release.pub`. Until you do, the file holds a placeholder (32 zero bytes) and `task release` fails with a message saying so. 2. Commit the printed public key as `internal/infrastructure/update/release.pub`. A placeholder key (32 zero bytes) prevents signing. Official releases already embed the project's public key; this setup is for release maintainers, not site operators. Never copy the private signing key to a CMS server.
3. Export `RELEASE_SIGNING_KEY=<path to the private key>` before every `task release`. 3. Export `RELEASE_SIGNING_KEY=<path to the private key>` before every `task release`.
To check a download by hand: `go run ./cmd/releasesign verify checksums.txt`, with `checksums.txt.sig` next to it, then compare the binary's hash with the matching line of `checksums.txt`. To check a download by hand: `go run ./cmd/releasesign verify checksums.txt`, with `checksums.txt.sig` next to it, then compare the binary's hash with the matching line of `checksums.txt`.
@@ -43,13 +43,39 @@ Once the restart helper is installed (it writes `update-helper.json` next to `DB
The owner's Restart button installs a downloaded update, or just restarts the site, without touching the server by hand. The app itself stays unprivileged: the systemd unit below keeps `ProtectSystem=strict` and only the data directory (the directory that holds `DB_FILE`) is writable. The only privileged code is a small helper, the same binary started as `renewcms apply-update` by a systemd service that runs as root. The owner's Restart button installs a downloaded update, or just restarts the site, without touching the server by hand. The app itself stays unprivileged: the systemd unit below keeps `ProtectSystem=strict` and only the data directory (the directory that holds `DB_FILE`) is writable. The only privileged code is a small helper, the same binary started as `renewcms apply-update` by a systemd service that runs as root.
Enable it once, on the server, as root: First install v2.6.0-beta or newer manually, back up the database, uploads and current binary, and confirm the app runs as an unprivileged service user. The binary and its parent directory should be controlled by root. Owners can start updates and restarts; admins have read-only update status.
#### Find the actual database directory
The helper's `--data-dir` must be the directory containing `DB_FILE`. Inspect the relevant settings without printing the configuration's secrets:
```sh ```sh
sudo renewcms enable-browser-updates sudo systemctl show renewcms -p User -p WorkingDirectory
sudo grep '^DB_FILE=' /etc/renewcms/renewcms.env
``` ```
The command reads the service name, the binary path and the data directory from your `renewcms.service` unit. Pass `--service`, `--binary`, `--data-dir` and `--port` to override them or when there is no such unit. The port, used by the helper to poll `/healthz`, comes from `--port`, else from `PORT` in the unit's `EnvironmentFile`, else 8080. It then: | WorkingDirectory | DB_FILE | Correct --data-dir |
| --- | --- | --- |
| `/var/lib/renewcms` | `/var/lib/renewcms/renewcms.db` | `/var/lib/renewcms` |
| `/var/lib/renewcms` | `./data/renewcms.db` | `/var/lib/renewcms/data` |
Leave an existing `DB_FILE` unchanged: changing it can open a different, empty database. Configure the helper to match the existing database instead.
#### Enable the helper
For this guide's absolute database path and port 8080, run:
```sh
sudo /opt/renewcms/renewcms enable-browser-updates \
--service renewcms \
--binary /opt/renewcms/renewcms \
--data-dir /var/lib/renewcms \
--port 8080
```
Replace these flags with your actual paths and port. For the relative database example above and port 8788, use `--data-dir /var/lib/renewcms/data --port 8788`. The absolute executable path works even when `/opt/renewcms` is absent from sudo's PATH.
Without explicit flags, discovery reads `/etc/systemd/system/renewcms.service` and takes its first `ReadWritePaths` entry as the data directory. It does not resolve `DB_FILE`, so explicit flags are necessary when these directories differ or the unit lives elsewhere. The port comes from `--port`, else `PORT` in the unit's `EnvironmentFile`, else 8080. The helper polls `/healthz` on `127.0.0.1` at that port. It then:
- writes `renewcms-update.path` and `renewcms-update.service` next to your other units in `/etc/systemd/system` (no secret and no environment value goes into them); - writes `renewcms-update.path` and `renewcms-update.service` next to your other units in `/etc/systemd/system` (no secret and no environment value goes into them);
- runs `systemctl daemon-reload` and enables and starts the path unit; - runs `systemctl daemon-reload` and enables and starts the path unit;
@@ -57,13 +83,57 @@ The command reads the service name, the binary path and the data directory from
Running it again rewrites the units and the status file to the current values. If a step fails, the units it wrote are removed again and `update-helper.json` is not written. Running it again rewrites the units and the status file to the current values. If a step fails, the units it wrote are removed again and `update-helper.json` is not written.
To undo it: #### Verify setup and test a plain restart
For the layout above, run these checks, substituting your service user and database directory if different:
```sh ```sh
sudo renewcms disable-browser-updates systemctl is-active renewcms-update.path
systemctl is-enabled renewcms-update.path
sudo systemctl cat renewcms-update.service
sudo -u renewcms cat /var/lib/renewcms/update-helper.json
``` ```
This stops and disables the path unit, removes both unit files and `update-helper.json`, and runs `systemctl daemon-reload`. The restart request, the result, the staged files and the backups stay where they are, and the Settings page goes back to "Not prepared". Expect `active`, `enabled`, an `ExecStart` with the correct paths and port, and readable JSON. Reload Settings as owner. With no update waiting, Restart should appear. Press it, confirm the plain restart dialog, and expect the overlay followed by the site returning and a “Site restarted” message.
Then run:
```sh
systemctl is-active renewcms
sudo cat /var/lib/renewcms/update-result.json
sudo cat /var/lib/renewcms/update-running.json
sudo journalctl -u renewcms-update.service -n 30 --no-pager
curl --retry 5 --retry-connrefused --retry-delay 2 -fsS http://127.0.0.1:8080/healthz
```
Expect an active app service, result `kind: restart` with `outcome: succeeded`, the installed version and a new process in `update-running.json`, and health response `ok`. Check `/healthz` through your public HTTPS hostname too. A brief connection refusal during startup can be transient. The oneshot helper normally becomes inactive after success; the path unit stays active.
#### Install a future update
When a newer signed release exists, check for updates in Settings, read its notes and click Update. The site stays online while the files are downloaded, verified and backed up. Wait for Ready to install, then click Restart and confirm installation. Afterwards, check the version in Settings, public health, the helper journal and `update-result.json`: expect `kind: update` and `outcome: succeeded`.
On the latest release you can test only a plain restart. Test installation, forced rollback and hostile-file cases on an isolated Linux/systemd machine with its own data and service. The helper unit names are fixed, so setting up another instance on the same machine replaces that machine's existing helper configuration.
#### Troubleshooting
- **Restart is missing:** reload Settings, confirm you are owner, and inspect `GET /v1/system/update` in the browser's Network panel. For an idle owner, `can_apply: false` means the app did not detect the helper. Check that `update-helper.json` is beside the actual database and readable by the service user.
- **Wrong helper directory:** disable the helper with the old `--data-dir`, then enable it with the correct directory and port. Do not change `DB_FILE` to compensate.
- **Overlay does not clear:** inspect the helper journal, `update-result.json` and `update-running.json` in the actual database directory. Check localhost health on the configured port, then public health through the proxy. Do not cache `/healthz` at the proxy.
- **Old binary panics on `--version`:** older releases may interpret it as normal startup and require server environment variables. v2.6.0-beta and newer print their version without loading configuration. A separately launched process failing this way does not stop the systemd service.
- **Download or verification fails:** inspect the app journal. All assets and redirects must use HTTPS on the configured feed host. The release must include the six binaries, `checksums.txt` and `checksums.txt.sig`.
- **Update rolls back:** check the previous version and helper journal. Automatic rollback restores the binary, not the database; schema changes still require a recovery plan.
#### Disable the helper
Use the same database directory you supplied when enabling it:
```sh
sudo /opt/renewcms/renewcms disable-browser-updates \
--service renewcms \
--data-dir /var/lib/renewcms
```
This stops and disables the path unit, removes both unit files and `update-helper.json`, and runs `systemctl daemon-reload`. The restart request, result, staged files and backups stay. `can_apply` becomes false: Settings shows “Not prepared” when an update is available, or “You're up to date” without Restart otherwise.
How it works: pressing Restart makes the app write `update-restart-request.json` in the data directory. The path unit notices it and starts the helper, which deletes the request, checks the staged files again, installs the update, restarts the service and waits up to 60 seconds for the site to answer `/healthz` and for the new process to write `update-running.json`. The helper writes its outcome to `update-result.json`; the Settings page reads it. The technical reason of a failure goes to the journal only (`journalctl -u renewcms-update`). How it works: pressing Restart makes the app write `update-restart-request.json` in the data directory. The path unit notices it and starts the helper, which deletes the request, checks the staged files again, installs the update, restarts the service and waits up to 60 seconds for the site to answer `/healthz` and for the new process to write `update-running.json`. The helper writes its outcome to `update-result.json`; the Settings page reads it. The technical reason of a failure goes to the journal only (`journalctl -u renewcms-update`).
@@ -85,6 +155,10 @@ What is supported and what is not:
- A plain restart (no update waiting) restarts the service the same way and records `succeeded` or `failed`; no binary is replaced. - A plain restart (no update waiting) restarts the service the same way and records `succeeded` or `failed`; no binary is replaced.
- Only the owner can press Restart. The site is unavailable for about 10 seconds. - Only the owner can press Restart. The site is unavailable for about 10 seconds.
#### Verification status
A real v2.6.0-beta plain browser restart was confirmed on Linux/systemd on 2026-10-02: the path unit triggered the helper, a new server process started, the result was `succeeded`, and public health returned `ok`. Real signed installation, forced rollback and adversarial filesystem checks remain unverified. The manual cases are listed in [the restart QA procedure](../features/updates_restart_helper.qa.md).
## 2. Lay out the server ## 2. Lay out the server
The following is a suggested layout for Linux: The following is a suggested layout for Linux:
+17 -7
View File
@@ -47,7 +47,7 @@ These are the RenewCMS components, built from the primitives on the first page .
### AuthShell ### AuthShell
One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links and the theme select sit outside the card, so the card itself stays about one task. One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links, the language chip and the theme select sit outside the card, so the card itself stays about one task.
Log in Welcome back. Log in to manage your articles. Log in Welcome back. Log in to manage your articles.
@@ -55,7 +55,7 @@ default slot
Forgot password? System Forgot password? System
Built from Custom layout component AuthShell.vue : a UPageCard variant="outline" inside a centered main , with AppLogo , and UColorModeSelect size="sm" in the footer. Forms inside it use UForm rather than UAuthForm , because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose. Built from Custom layout component AuthShell.vue : a UPageCard variant="outline" inside a centered main , with AppLogo , and UColorModeSelect size="sm" in the footer, next to the language chip (see LanguageSwitcher). Forms inside it use UForm rather than UAuthForm , because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose.
Props, slots Props title: string , description?: string . Slots default , notice (an alert above the title, for session expired or password changed), footer (links). No emits. Props, slots Props title: string , description?: string . Slots default , notice (an alert above the title, for session expired or password changed), footer (links). No emits.
@@ -383,12 +383,13 @@ The Product components board was not regenerated for these. They are written fro
### UserMenu (changed) ### UserMenu (changed)
The chip at the foot of the sidebar no longer logs out on click. It opens the account menu, which lists Profile, the theme choice and Log out. The chip at the foot of the sidebar no longer logs out on click. It opens the account menu, which lists Profile, the theme choice, the language choice and Log out.
- **Chip:** 40 px high, an initials avatar (26 px, sand `#d4c5b0` with ink text), the name, and a chevrons-up-down icon. `aria-haspopup="menu"`, `aria-expanded`, title "Account". While the menu is open, or while `/profile` is the current page, it takes `--rc-sand` with `#111111` text. - **Chip:** 40 px high, an initials avatar (26 px, sand `#d4c5b0` with ink text), the name, and a chevrons-up-down icon. `aria-haspopup="menu"`, `aria-expanded`, title "Account". While the menu is open, or while `/profile` is the current page, it takes `--rc-sand` with `#111111` text.
- **Name:** the display name when there is one, otherwise the username. Initials come from the first letters of the first two words of the display name, uppercased. Without a display name they come from the username. - **Name:** the display name when there is one, otherwise the username. Initials come from the first letters of the first two words of the display name, uppercased. Without a display name they come from the username.
- **Desktop menu:** a 224 px popover anchored above the chip on its left edge, with `--rc-shadow-pop`. Header: the name in semibold, the email below it in muted 12 px, a bottom border. Items are 32 px: Profile (circle-user), a separator, System, Light and Dark (14 px text, a check icon at the right edge on the current choice), a separator, Log out (log-out). The current page's item is highlighted with `--ui-bg-elevated`. The theme items are radio-style: choosing one applies at once and keeps the menu open. Nuxt UI: `type: 'checkbox'` items bound to `useColorMode().preference`. - **Desktop menu:** a 224 px popover anchored above the chip on its left edge, with `--rc-shadow-pop`. Header: the name in semibold, the email below it in muted 12 px, a bottom border. Items are 32 px: Profile (circle-user), a separator, a "Theme" section label, System, Light and Dark (14 px text, a check icon at the right edge on the current choice), a separator, a "Language" section label, English and Français (same row as the theme items, a check icon on the current one), a separator, Log out (log-out). Section labels are 11 px, weight 500, uppercase, letter-spacing .04em, `--ui-text-muted`, padded 6px 8px 2px. The current page's item is highlighted with `--ui-bg-elevated`. The theme items are radio-style: choosing one applies at once and keeps the menu open. Nuxt UI: `type: 'checkbox'` items bound to `useColorMode().preference`.
- **Mobile:** the account row in the navigation drawer opens a bottom sheet over an `--rc-overlay` scrim, with a grab handle. Header: 36 px avatar, name in 15 px semibold, email in muted text. Items are 48 px at 15 px text; the theme items keep the same order and separators. - **Mobile:** the account row in the navigation drawer opens a bottom sheet over an `--rc-overlay` scrim, with a grab handle. Header: 36 px avatar, name in 15 px semibold, email in muted text. Items are 48 px at 15 px text; the theme and language items keep the same order, section labels (padded 8px 16px 2px) and separators.
- **Language items:** a `menuitemradio` group named by the "Language" label. Each item is labelled with the language's own name (English, Français) and carries `lang="en"` / `lang="fr"`. Choosing one applies the language at once and keeps the menu open, like the theme items. See LanguageSwitcher.
- **Keyboard:** Escape or a click outside closes it. Arrow keys move between items. - **Keyboard:** Escape or a click outside closes it. Arrow keys move between items.
- The board now draws the theme items, so the chip, menu and sheet need no visual exception. The board highlights Profile even on Home; the app highlights it only on `/profile`. - The board now draws the theme items, so the chip, menu and sheet need no visual exception. The board highlights Profile even on Home; the app highlights it only on `/profile`.
@@ -734,7 +735,16 @@ Three choices: System (the default), Light and Dark. The toggle at the top of th
Nuxt UI In the user menu: a UDropdownMenu group of three type: 'checkbox' items bound to useColorMode().preference . On auth screens: UColorModeSelect in the footer. Nuxt UI's UColorModeButton isn't used because it only toggles between light and dark and hides the system option. colorMode: true in the plugin handles storage and applying the .dark class before first paint. Nuxt UI In the user menu: a UDropdownMenu group of three type: 'checkbox' items bound to useColorMode().preference . On auth screens: UColorModeSelect in the footer. Nuxt UI's UColorModeButton isn't used because it only toggles between light and dark and hides the system option. colorMode: true in the plugin handles storage and applying the .dark class before first paint.
ARIA Menu items use menuitemcheckbox , with aria-checked on the current choice. The segmented variant is a radiogroup named "Theme". ARIA Menu items use menuitemradio , with aria-checked on the current choice. The segmented variant is a radiogroup named "Theme".
### LanguageSwitcher
Two choices: English and Français. Each is always written in its own language, whatever the interface language. It appears in two places and never as a form select.
- **Account menu (signed in):** a "Language" section between the theme items and Log out. See UserMenu (changed).
- **Auth footer (signed out):** a chip beside the System theme chip, in the footer outside the card. The chip is 28 px high on desktop and 44 px on mobile, 8 px side padding, 1 px `--ui-border-accented` border, `--ui-text-toned` text, a 14 px lucide `languages` icon, then the current language's own name. `aria-haspopup="menu"`, `aria-expanded`, `aria-label` "Language: English". While open it takes `--ui-bg-elevated`. The footer sits at the right with an 8 px gap between the two chips.
- **Auth popover:** opens above the chip (bottom 36 px, right-aligned, 168 px wide), `--ui-bg`, 1 px `--ui-border`, `--rc-shadow-pop`, 4 px padding, `role="menu"` named "Language". Rows are 32 px (44 px on mobile), 14 px text, `menuitemradio` with `lang`, a 16 px check icon at the right edge on the current one. Choosing a row applies the language and closes the popover; Escape or an outside click closes it without changing anything.
- The choice persists across reloads and applies to every screen at once.
## Added with roles, invite actions and Settings ## Added with roles, invite actions and Settings
@@ -792,7 +802,7 @@ Exact values live in `source/AppFrame.dc.html` (the Settings and Home states). C
| Component | Nuxt UI base | Props | Emits | Used in | | Component | Nuxt UI base | Props | Emits | Used in |
|---|---|---|---|---| |---|---|---|---|---|
| UpdatesCard | custom card, UProgress, UButton, UCollapsible | status: UpdateStatus, role | check, update, restart, copy-command | Settings | | UpdatesCard | custom card, UProgress, UButton, UCollapsible | status: UpdateStatus, role | check, update, restart, copy-command | Settings |
| UpdateStatusBlock | custom (polite live region) | tone, icon, title, text, meta | none | UpdatesCard | | UpdateStatusBlock | custom (polite live region) | tone, icon, title, text, meta, pendingTag?, action? (in-block Restart, Ready only) | none | UpdatesCard |
| UpdateProgress | UProgress | label, text, value or indeterminate | none | UpdatesCard | | UpdateProgress | UProgress | label, text, value or indeterminate | none | UpdatesCard |
| ReleaseNotes | UCollapsible | version, date, items | none | UpdatesCard | | ReleaseNotes | UCollapsible | version, date, items | none | UpdatesCard |
| ServerCommand | UButton, code block | line | copy | UpdatesCard (Not prepared) | | ServerCommand | UButton, code block | line | copy | UpdatesCard (Not prepared) |
+5 -1
View File
@@ -48,8 +48,12 @@ Desktop: a 224 px popover above the chip, aligned to its left. Mobile: the accou
| Header | The name in semibold (display name, or the username), and the email in muted 12 px (14 px on mobile, with a 36 px initials avatar beside them) | | Header | The name in semibold (display name, or the username), and the email in muted 12 px (14 px on mobile, with a 36 px initials avatar beside them) |
| Item | Profile (circle-user icon), highlighted while on `/profile` | | Item | Profile (circle-user icon), highlighted while on `/profile` |
| Separator | | | Separator | |
| Label | "Theme": 11 px, weight 500, uppercase, letter-spacing .04em, muted |
| Items | System, Light, Dark: a radio-style group, the current choice carries a check icon at the right edge; choosing one applies the theme at once and keeps the menu open | | Items | System, Light, Dark: a radio-style group, the current choice carries a check icon at the right edge; choosing one applies the theme at once and keeps the menu open |
| Separator | | | Separator | |
| Label | "Language", styled like "Theme" |
| Items | English, Français (each in its own language, with `lang`): a radio-style group with a check on the current one; choosing one applies the language at once and keeps the menu open |
| Separator | |
| Item | Log out (log-out icon) | | Item | Log out (log-out icon) |
Avatar initials come from the first letters of the first two words of the display name, uppercased ("Marta Alves" gives MA). Without a display name they come from the username. Avatar initials come from the first letters of the first two words of the display name, uppercased ("Marta Alves" gives MA). Without a display name they come from the username.
@@ -64,7 +68,7 @@ The board's menu now carries System, Light and Dark, matching the shipped UserMe
- Breadcrumbs: Home (mobile title "Home") - Breadcrumbs: Home (mobile title "Home")
- Page behind the menu: Home, default - Page behind the menu: Home, default
- Menu open, as described above, with the System, Light and Dark group between Profile and Log out. The board shows Light checked - Menu open, as described above, with the Theme group (System, Light, Dark) and the Language group (English, Français) between Profile and Log out. The board shows Light and English checked
### Loading ### Loading
+8 -6
View File
@@ -20,7 +20,9 @@ Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px betwe
| Part | Content | | Part | Content |
|---|---| |---|---|
| Status block | A polite live region (`aria-live="polite"`): 14 px padding, 12 px gap, an 18 px icon, a semibold title, the text, and a muted `meta` line. Background and icon color follow the tone: neutral `--ui-bg-muted`, success, info, warning, error (`--rc-<tone>-soft` fill, `--ui-<tone>` icon). | | Status block | A polite live region (`aria-live="polite"`): 14 px padding, 12 px gap, an 18 px icon, a semibold title, the text, and a muted `meta` line. Background and icon color follow the tone: neutral `--ui-bg-muted`, success, info, warning, error (`--rc-<tone>-soft` fill, `--ui-<tone>` icon). The title row is a flex row with an 8 px gap that wraps. In "Ready to install" it carries a **Pending tag** after the title and the block holds the **Restart** button (see below). |
| Pending tag | Only in "Ready to install", right after the title: 20 px high, 0 6 px padding, IBM Plex Mono 11 px medium, `--ui-bg` fill, `--ui-warning` text and 1 px `--ui-warning` border, square corners, text "Pending". |
| In-block button | Only in "Ready to install", "Restart: confirm" and "Restart: request failed": the primary Restart button sits inside the status block, under the `meta` line, after 8 px of extra top padding, left-aligned (content width, 14 px side padding, 32 px high, 44 px on mobile, not stretched). The buttons row under the block is not drawn in these states. The hint stays under the card. |
| Progress | A `progressbar` with a value (`aria-valuenow`) while downloading, labelled for example "Downloading version 2.3.0", with the text `42% · 11 of 26 MB`. Indeterminate while verifying (35 % wide moving bar), labelled "Checking the download", text "Almost there". Announced at 25 % steps and when the step changes, never on every tick. | | Progress | A `progressbar` with a value (`aria-valuenow`) while downloading, labelled for example "Downloading version 2.3.0", with the text `42% · 11 of 26 MB`. Indeterminate while verifying (35 % wide moving bar), labelled "Checking the download", text "Almost there". Announced at 25 % steps and when the step changes, never on every tick. |
| Release notes | A button "What's new in 2.3.0" with `aria-expanded` and a chevron, collapsed by default. Open: "Released 24 Sep 2026" and a plain-text list. | | Release notes | A button "What's new in 2.3.0" with `aria-expanded` and a chevron, collapsed by default. Open: "Released 24 Sep 2026" and a plain-text list. |
| Server command | Only in the "Not prepared" state: "For whoever manages the server", a one-line `code` block, a Copy button (reads "Copied" with a check for 2 s, announced), and the note "They run this once on the server. After that, Update and Restart appear here." The command shown is `renewcms enable-browser-updates` and is subject to the real one. | | Server command | Only in the "Not prepared" state: "For whoever manages the server", a one-line `code` block, a Copy button (reads "Copied" with a check for 2 s, announced), and the note "They run this once on the server. After that, Update and Restart appear here." The command shown is `renewcms enable-browser-updates` and is subject to the real one. |
@@ -28,7 +30,7 @@ Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px betwe
| Hint | 12 px muted line under the buttons. | | Hint | 12 px muted line under the buttons. |
| Link | Underlined, with an `arrow-up-right`: "How to update" or "Read the release notes". | | Link | Underlined, with an `arrow-up-right`: "How to update" or "Read the release notes". |
Buttons used: **Check for updates** (`refresh-cw`, secondary), **Update** (`download`, primary), **Restart** (`power`, primary when an update is ready, otherwise secondary), **Try again** (`rotate-cw`, primary), and the disabled "Checking…" and "Updating…" (`loader-circle`). Copy such as "Version 2.3.0" names the newer release. Decision for the spec: show the release tag without its leading `v` ("Version 2.3.0-beta" for tag `v2.3.0-beta`), while the Version card keeps the running build exactly as the server reports it. Buttons used: **Check for updates** (`refresh-cw`, secondary), **Update** (`download`, primary), **Restart** (`power`, primary and inside the status block when an update is ready, otherwise secondary in the buttons row), **Try again** (`rotate-cw`, primary), and the disabled "Checking…" and "Updating…" (`loader-circle`). Copy such as "Version 2.3.0" names the newer release. Decision for the spec: show the release tag without its leading `v` ("Version 2.3.0-beta" for tag `v2.3.0-beta`), while the Version card keeps the running build exactly as the server reports it.
## Copy ## Copy
@@ -40,7 +42,7 @@ Buttons used: **Check for updates** (`refresh-cw`, secondary), **Update** (`down
| Available | `circle-arrow-up`, info: "Version 2.3.0 is available" / "You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart."; "Update"; hint "Takes about 2 minutes. Your site stays online." | | Available | `circle-arrow-up`, info: "Version 2.3.0 is available" / "You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart."; "Update"; hint "Takes about 2 minutes. Your site stays online." |
| Downloading | `download`, info: "Downloading version 2.3.0…" / "You can leave this page. The download continues and your site stays online."; "Updating…" disabled | | Downloading | `download`, info: "Downloading version 2.3.0…" / "You can leave this page. The download continues and your site stays online."; "Updating…" disabled |
| Verifying | `shield-check`, info: "Checking the download…" / "Making sure the file is complete and safe to install. This takes a few seconds." | | Verifying | `shield-check`, info: "Checking the download…" / "Making sure the file is complete and safe to install. This takes a few seconds." |
| Ready | `circle-check`, success: "Version 2.3.0 is ready to install" / "The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." / "Nothing has changed yet."; "Restart"; hint "Not now? Leave this page. The update waits here, and the banner on Home reminds you." | | Ready | `clock`, warning, with the "Pending" tag after the title: "Version 2.3.0 is ready to install" / "The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." / "Nothing has changed yet."; "Restart" (primary, inside the block); hint "Not now? Leave this page. The update waits here, and the banner on Home reminds you." |
| Done | "You're on version 2.3.0" / "The update is installed and your site is running again." / "Updated today at 09:52"; link "Read the release notes"; toast `circle-check` "Update installed" / "RenewCMS is now on version 2.3.0." | | Done | "You're on version 2.3.0" / "The update is installed and your site is running again." / "Updated today at 09:52"; link "Read the release notes"; toast `circle-check` "Update installed" / "RenewCMS is now on version 2.3.0." |
| Rolled back | `triangle-alert`, warning: "The update didn't install" / "RenewCMS put the previous version back, so your site is running as before. Nothing was lost." / "What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update."; "Try again"; toast (error, stays) `circle-x` "The update didn't install" / "Your site is back on v2.2.0-beta." | | Rolled back | `triangle-alert`, warning: "The update didn't install" / "RenewCMS put the previous version back, so your site is running as before. Nothing was lost." / "What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update."; "Try again"; toast (error, stays) `circle-x` "The update didn't install" / "Your site is back on v2.2.0-beta." |
| Download failed | `circle-x`, error: "The download didn't finish" / "The connection dropped before the file was complete. Nothing was changed and your site is running as before."; "Try again" | | Download failed | `circle-x`, error: "The download didn't finish" / "The connection dropped before the file was complete. Nothing was changed and your site is running as before."; "Try again" |
@@ -105,9 +107,9 @@ For the owner only: an **Update** pill on the Settings sidebar item, and a dot o
| Update available, notes open | "What's new in 2.3.0" expanded. It is a button with aria-expanded, and the list is plain text. | | Update available, notes open | "What's new in 2.3.0" expanded. It is a button with aria-expanded, and the list is plain text. |
| Downloading | Update pressed (202). Progress polls every second. Announced as "Downloading, 25 percent" and so on, never every tick. | | Downloading | Update pressed (202). Progress polls every second. Announced as "Downloading, 25 percent" and so on, never every tick. |
| Verifying | The download finished. No percentage, so the bar is indeterminate. Announced as "Checking the download". | | Verifying | The download finished. No percentage, so the bar is indeterminate. Announced as "Checking the download". |
| Ready to install | The file passed its checks. Restart becomes the primary action. "Restart later" is closing the dialog or leaving the page. | | Ready to install | The file passed its checks. The card turns warning (orange) with a "Pending" tag and a `clock` icon, and Restart, the primary action, sits inside it. "Restart later" is closing the dialog or leaving the page. |
| Restart: confirm | Restart pressed. Same dialog behaviour as the delete confirm: focus moves to the first button, Escape and "Restart later" close it, and focus returns to Restart. The confirm button is neutral, not red, because nothing is lost. | | Restart: confirm | Restart pressed. The card behind the dialog keeps the Ready look. Same dialog behaviour as the delete confirm: focus moves to the first button, Escape and "Restart later" close it, and focus returns to Restart. The confirm button is neutral, not red, because nothing is lost. |
| Restart: request failed | 500 or network failure before the restart began. The site is still running. | | Restart: request failed | 500 or network failure before the restart began. The site is still running. The card behind the dialog keeps the Ready look. |
| Restarting to install | Full-page overlay, so it survives the server going away. The page polls until the site answers, then reloads. It is a live region announced once as "Installing version 2.3.0. Your site is restarting." | | Restarting to install | Full-page overlay, so it survives the server going away. The page polls until the site answers, then reloads. It is a live region announced once as "Installing version 2.3.0. Your site is restarting." |
| Restarting, taking longer | No answer after 60 seconds. The copy changes and a manual reload appears. The overlay stays until the site is back. | | Restarting, taking longer | No answer after 60 seconds. The copy changes and a manual reload appears. The overlay stays until the site is back. |
| Update done | The page reloaded on the new version. Focus lands on the "Updates" heading (ring shown), the toast is announced, and the version updates. | | Update done | The page reloaded on the new version. Focus lands on the "Updates" heading (ring shown), the toast is announced, and the version updates. |
+4 -4
View File
@@ -18,7 +18,7 @@
<sc-if value="{{ s.boot }}"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px"><span style="width:26px;height:26px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if> <sc-if value="{{ s.boot }}"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px"><span style="width:26px;height:26px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
<sc-if value="{{ notBoot }}"><div data-act="usermenu" role="button" aria-haspopup="menu" aria-expanded="{{ menuOpen }}" title="Account" style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;background:{{ chipBg }};color:{{ chipFg }}"><span style="width:26px;height:26px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500">{{ who }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></div></sc-if> <sc-if value="{{ notBoot }}"><div data-act="usermenu" role="button" aria-haspopup="menu" aria-expanded="{{ menuOpen }}" title="Account" style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;background:{{ chipBg }};color:{{ chipFg }}"><span style="width:26px;height:26px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500">{{ who }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></div></sc-if>
</div> </div>
<sc-if value="{{ deskMenu }}"><div role="menu" aria-label="Account" style="position:absolute;left:12px;bottom:56px;z-index:40;width:224px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div style="padding:8px 8px 10px;display:flex;flex-direction:column;gap:2px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ who }}</span><span style="font-size:12px;color:var(--ui-text-muted)">marta@renew.example</span></div><div data-act="nav-profile" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></sc-if> <sc-if value="{{ deskMenu }}"><div role="menu" aria-label="Account" style="position:absolute;left:12px;bottom:56px;z-index:40;width:224px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div style="padding:8px 8px 10px;display:flex;flex-direction:column;gap:2px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ who }}</span><span style="font-size:12px;color:var(--ui-text-muted)">marta@renew.example</span></div><div data-act="nav-profile" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:6px 8px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Theme</div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:6px 8px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Language</div><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></sc-if>
</sc-if> </sc-if>
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column"> <div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
<sc-if value="{{ desk }}" hint-placeholder-val="{{ true }}"> <sc-if value="{{ desk }}" hint-placeholder-val="{{ true }}">
@@ -233,7 +233,7 @@
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;align-items:center;justify-content:space-between;gap:16px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Version</span><span style="font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ s.upd.v }}</span></div> <div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;align-items:center;justify-content:space-between;gap:16px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Version</span><span style="font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ s.upd.v }}</span></div>
<div role="region" aria-labelledby="updates-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"> <div role="region" aria-labelledby="updates-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px">
<span id="updates-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted);outline:{{ s.upd.focus }};outline-offset:4px">Updates</span> <span id="updates-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted);outline:{{ s.upd.focus }};outline-offset:4px">Updates</span>
<div aria-live="polite" style="display:flex;gap:12px;padding:14px;background:{{ s.upd.bg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:{{ s.upd.fg }};-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat"></span></span><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px"><span style="font:600 14px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.upd.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ s.upd.text }}</span><sc-if value="{{ s.upd.bar }}"><div style="display:flex;flex-direction:column;gap:6px;padding-top:8px"><div role="progressbar" aria-label="{{ s.upd.barLabel }}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ s.upd.pct }}" style="height:6px;background:var(--ui-bg-accented)"><div style="height:6px;width:{{ s.upd.barW }};margin-left:{{ s.upd.barL }};background:var(--ui-primary)"></div></div><span style="font:400 12px/16px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ s.upd.barText }}</span></div></sc-if><sc-if value="{{ s.upd.meta }}"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.meta }}</span></sc-if></div></div> <div aria-live="polite" style="display:flex;gap:12px;padding:14px;background:{{ s.upd.bg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:{{ s.upd.fg }};-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat"></span></span><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px"><div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px"><span style="font:600 14px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.upd.title }}</span><sc-if value="{{ s.upd.pendingTag }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-bg);color:var(--ui-warning);border:1px solid var(--ui-warning);font:500 11px 'IBM Plex Mono',monospace">Pending</span></sc-if></div><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ s.upd.text }}</span><sc-if value="{{ s.upd.bar }}"><div style="display:flex;flex-direction:column;gap:6px;padding-top:8px"><div role="progressbar" aria-label="{{ s.upd.barLabel }}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ s.upd.pct }}" style="height:6px;background:var(--ui-bg-accented)"><div style="height:6px;width:{{ s.upd.barW }};margin-left:{{ s.upd.barL }};background:var(--ui-primary)"></div></div><span style="font:400 12px/16px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ s.upd.barText }}</span></div></sc-if><sc-if value="{{ s.upd.meta }}"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.meta }}</span></sc-if><sc-if value="{{ s.upd.inBtn }}"><div style="display:flex;padding-top:8px"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ s.upd.inBtn.bg }};color:{{ s.upd.inBtn.fg }};border:1px solid {{ s.upd.inBtn.bd }};font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.inBtn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.inBtn.icon }}.svg') center / contain no-repeat"></span>{{ s.upd.inBtn.t }}</span></div></sc-if></div></div>
<sc-if value="{{ s.upd.notes }}"><div style="display:flex;flex-direction:column;gap:10px"><span role="button" aria-expanded="{{ s.upd.notes.open }}" style="align-self:flex-start;display:flex;align-items:center;gap:6px;min-height:{{ btnH }};font-weight:500;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat"></span>What's new in {{ s.upd.notes.ver }}</span><sc-if value="{{ s.upd.notes.open }}"><div style="display:flex;flex-direction:column;gap:8px;padding:0 0 0 20px"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.notes.date }}</span><sc-for list="{{ s.upd.notes.items }}" as="it" hint-placeholder-count="3"><div style="display:flex;gap:8px"><span aria-hidden="true" style="flex-shrink:0;width:4px;height:4px;margin-top:8px;background:var(--ui-text-muted)"></span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ it.t }}</span></div></sc-for><span style="align-self:flex-start;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">Read the full release notes</span></div></sc-if></div></sc-if> <sc-if value="{{ s.upd.notes }}"><div style="display:flex;flex-direction:column;gap:10px"><span role="button" aria-expanded="{{ s.upd.notes.open }}" style="align-self:flex-start;display:flex;align-items:center;gap:6px;min-height:{{ btnH }};font-weight:500;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat"></span>What's new in {{ s.upd.notes.ver }}</span><sc-if value="{{ s.upd.notes.open }}"><div style="display:flex;flex-direction:column;gap:8px;padding:0 0 0 20px"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.notes.date }}</span><sc-for list="{{ s.upd.notes.items }}" as="it" hint-placeholder-count="3"><div style="display:flex;gap:8px"><span aria-hidden="true" style="flex-shrink:0;width:4px;height:4px;margin-top:8px;background:var(--ui-text-muted)"></span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ it.t }}</span></div></sc-for><span style="align-self:flex-start;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">Read the full release notes</span></div></sc-if></div></sc-if>
<sc-if value="{{ s.upd.cmd }}"><div style="display:flex;flex-direction:column;gap:8px"><span style="font-weight:500;color:var(--ui-text-highlighted)">For whoever manages the server</span><div style="display:flex;flex-direction:{{ upCmdDir }};align-items:{{ upBtnAlign }};gap:8px"><code style="flex:1;min-width:0;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:22px">{{ s.upd.cmd.line }}</code><span role="button" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ s.upd.cmd.fg }};font-weight:500"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat"></span>{{ s.upd.cmd.t }}</span></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.cmd.note }}</span></div></sc-if> <sc-if value="{{ s.upd.cmd }}"><div style="display:flex;flex-direction:column;gap:8px"><span style="font-weight:500;color:var(--ui-text-highlighted)">For whoever manages the server</span><div style="display:flex;flex-direction:{{ upCmdDir }};align-items:{{ upBtnAlign }};gap:8px"><code style="flex:1;min-width:0;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:22px">{{ s.upd.cmd.line }}</code><span role="button" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ s.upd.cmd.fg }};font-weight:500"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat"></span>{{ s.upd.cmd.t }}</span></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.cmd.note }}</span></div></sc-if>
<sc-if value="{{ s.upd.btns }}"><div style="display:flex;flex-direction:{{ upBtnDir }};align-items:{{ upBtnAlign }};gap:8px"><sc-for list="{{ s.upd.btns }}" as="b" hint-placeholder-count="1"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ b.bg }};color:{{ b.fg }};border:1px solid {{ b.bd }};opacity:{{ b.op }};font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat"></span>{{ b.t }}</span></sc-for></div></sc-if> <sc-if value="{{ s.upd.btns }}"><div style="display:flex;flex-direction:{{ upBtnDir }};align-items:{{ upBtnAlign }};gap:8px"><sc-for list="{{ s.upd.btns }}" as="b" hint-placeholder-count="1"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ b.bg }};color:{{ b.fg }};border:1px solid {{ b.bd }};opacity:{{ b.op }};font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat"></span>{{ b.t }}</span></sc-for></div></sc-if>
@@ -280,7 +280,7 @@
</div> </div>
</div> </div>
<sc-if value="{{ mobMenu }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="menu" aria-label="Account" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);padding:8px 0 24px"><div style="margin:0 auto 8px;width:36px;height:4px;background:var(--ui-bg-accented)"></div><div style="padding:8px 16px 14px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="width:36px;height:36px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 13px 'IBM Plex Sans',sans-serif">{{ ini }}</span><div style="display:flex;flex-direction:column;min-width:0"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ who }}</span><span style="color:var(--ui-text-muted)">marta@renew.example</span></div></div><div data-act="nav-profile" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></div></sc-if> <sc-if value="{{ mobMenu }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="menu" aria-label="Account" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);padding:8px 0 24px"><div style="margin:0 auto 8px;width:36px;height:4px;background:var(--ui-bg-accented)"></div><div style="padding:8px 16px 14px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="width:36px;height:36px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 13px 'IBM Plex Sans',sans-serif">{{ ini }}</span><div style="display:flex;flex-direction:column;min-width:0"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ who }}</span><span style="color:var(--ui-text-muted)">marta@renew.example</span></div></div><div data-act="nav-profile" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:8px 16px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Theme</div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:8px 16px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Language</div><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></div></sc-if>
<sc-if value="{{ s.overlay }}"><div role="status" aria-live="polite" style="position:absolute;inset:0;z-index:70;background:var(--ui-bg-muted);display:flex;align-items:center;justify-content:center;padding:24px"><div style="width:100%;max-width:440px;display:flex;flex-direction:column;gap:16px;align-items:flex-start"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><div style="display:flex;flex-direction:column;gap:6px"><span style="font:600 22px/30px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.overlay.title }}</span><span style="font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">{{ s.overlay.text }}</span></div><div role="progressbar" aria-label="Restarting" style="width:100%;height:4px;background:var(--ui-bg-accented)"><div style="width:35%;height:4px;margin-left:{{ s.overlay.barL }};background:var(--ui-primary)"></div></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.overlay.meta }}</span><sc-if value="{{ s.overlay.btn }}"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500">{{ s.overlay.btn }}</span></sc-if></div></div></sc-if> <sc-if value="{{ s.overlay }}"><div role="status" aria-live="polite" style="position:absolute;inset:0;z-index:70;background:var(--ui-bg-muted);display:flex;align-items:center;justify-content:center;padding:24px"><div style="width:100%;max-width:440px;display:flex;flex-direction:column;gap:16px;align-items:flex-start"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><div style="display:flex;flex-direction:column;gap:6px"><span style="font:600 22px/30px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.overlay.title }}</span><span style="font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">{{ s.overlay.text }}</span></div><div role="progressbar" aria-label="Restarting" style="width:100%;height:4px;background:var(--ui-bg-accented)"><div style="width:35%;height:4px;margin-left:{{ s.overlay.barL }};background:var(--ui-primary)"></div></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.overlay.meta }}</span><sc-if value="{{ s.overlay.btn }}"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500">{{ s.overlay.btn }}</span></sc-if></div></div></sc-if>
@@ -435,7 +435,7 @@ class Component extends DCLogic {
const edTitle = e.title || 'How we price a bathroom renovation' const edTitle = e.title || 'How we price a bathroom renovation'
return { return {
cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn, cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)', langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? '#111111' : 'var(--ui-text-highlighted)', chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? '#111111' : 'var(--ui-text-highlighted)',
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading, isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: m ? '16px' : '20px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar, upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: m ? '16px' : '20px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
+6 -3
View File
@@ -54,16 +54,17 @@
<sc-if value="{{ hasFooter }}"> <sc-if value="{{ hasFooter }}">
<div style="width:{{ w }};max-width:100%;padding:{{ footPad }};display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:13px;color:var(--ui-text-muted)"> <div style="width:{{ w }};max-width:100%;padding:{{ footPad }};display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:13px;color:var(--ui-text-muted)">
<span style="display:flex;gap:6px;flex-wrap:wrap;align-items:center"><sc-if value="{{ s.footer }}"><span>{{ s.footer.pre }}</span><span data-act="footer" style="color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px">{{ s.footer.link }}</span></sc-if><sc-if value="{{ s.pending }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">API pending</span></sc-if></span> <span style="display:flex;gap:6px;flex-wrap:wrap;align-items:center"><sc-if value="{{ s.footer }}"><span>{{ s.footer.pre }}</span><span data-act="footer" style="color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px">{{ s.footer.link }}</span></sc-if><sc-if value="{{ s.pending }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">API pending</span></sc-if></span>
<span style="height:28px;padding:0 8px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat"></span>System</span> <span style="position:relative;display:flex;align-items:center;gap:8px"><sc-if value="{{ langOpen }}"><div role="menu" aria-label="Language" style="position:absolute;right:0;bottom:36px;z-index:10;width:168px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:{{ optH }};padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for></div></sc-if><span data-act="langmenu" role="button" aria-haspopup="menu" aria-expanded="{{ langOpen }}" aria-label="Language: {{ langName }}" style="height:{{ chipH }};padding:0 8px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);background:{{ langBg }}"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/languages.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/languages.svg') center / contain no-repeat"></span>{{ langName }}</span><span style="height:{{ chipH }};padding:0 8px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat"></span>System</span></span>
</div> </div>
</sc-if> </sc-if>
</div> </div>
</x-dc> </x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:480,&quot;height&quot;:620},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;mobile&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;}}"> <script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:480,&quot;height&quot;:620},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;lang&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;en&quot;,&quot;fr&quot;],&quot;default&quot;:&quot;en&quot;,&quot;tsType&quot;:&quot;string&quot;},&quot;langOpen&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;},&quot;mobile&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic { class Component extends DCLogic {
renderVals() { renderVals() {
const s = this.props.s || {} const s = this.props.s || {}
const m = !!this.props.mobile const m = !!this.props.mobile
const lang = this.props.lang || s.lang || 'en'
const tone = t => ({ bg: 'var(--rc-' + t + '-soft)', fg: 'var(--ui-' + t + ')' }) const tone = t => ({ bg: 'var(--rc-' + t + '-soft)', fg: 'var(--ui-' + t + ')' })
const notice = s.notice ? { ...s.notice, ...tone(s.notice.tone) } : null const notice = s.notice ? { ...s.notice, ...tone(s.notice.tone) } : null
const result = s.result ? { ...s.result, ...tone(s.result.tone), actions: (s.result.actions || []).map((a, i) => i === 0 && !a.secondary ? { act: 'result-' + i, t: a.t, bg: 'var(--ui-primary)', fg: 'var(--ui-bg)', bd: '1px solid var(--ui-primary)' } : { act: 'result-' + i, t: a.t, bg: 'var(--ui-bg)', fg: 'var(--ui-text-highlighted)', bd: '1px solid var(--ui-border-accented)' }) } : null const result = s.result ? { ...s.result, ...tone(s.result.tone), actions: (s.result.actions || []).map((a, i) => i === 0 && !a.secondary ? { act: 'result-' + i, t: a.t, bg: 'var(--ui-primary)', fg: 'var(--ui-bg)', bd: '1px solid var(--ui-primary)' } : { act: 'result-' + i, t: a.t, bg: 'var(--ui-bg)', fg: 'var(--ui-text-highlighted)', bd: '1px solid var(--ui-border-accented)' }) } : null
@@ -87,7 +88,9 @@ class Component extends DCLogic {
return { return {
s, notice, result, fields, s, notice, result, fields,
hasForm: fields.length > 0 && !!s.submit, hasForm: fields.length > 0 && !!s.submit,
hasFooter: !!(s.footer || s.pending || !m), hasFooter: true,
langOpen: !!(this.props.langOpen ?? s.langOpen), langName: lang === 'fr' ? 'Français' : 'English', langBg: this.props.langOpen ?? s.langOpen ? 'var(--ui-bg-elevated)' : 'transparent', chipH: m ? '44px' : '28px', optH: m ? '44px' : '32px',
langSeg: [['en','English'],['fr','Français']].map(([k,t]) => ({ act: 'lang-' + k, code: k, t, on: lang === k, op: lang === k ? 1 : 0 })),
submitOpacity: s.submit && s.submit.disabled && !s.submit.loading ? '0.4' : '1', submitOpacity: s.submit && s.submit.disabled && !s.submit.loading ? '0.4' : '1',
w: m ? '100%' : '400px', w: m ? '100%' : '400px',
border: m ? '0' : '1px solid var(--ui-border)', border: m ? '0' : '1px solid var(--ui-border)',
@@ -46,7 +46,7 @@
<main ref="{{ stageRef }}" style="flex:1;min-width:0;height:100vh;overflow:auto;display:flex;align-items:center;justify-content:center;padding:24px"> <main ref="{{ stageRef }}" style="flex:1;min-width:0;height:100vh;overflow:auto;display:flex;align-items:center;justify-content:center;padding:24px">
<div style="flex-shrink:0;width:{{ boxW }};height:{{ boxH }};box-shadow:0 8px 40px rgb(0 0 0 / 0.15);overflow:hidden"> <div style="flex-shrink:0;width:{{ boxW }};height:{{ boxH }};box-shadow:0 8px 40px rgb(0 0 0 / 0.15);overflow:hidden">
<div onClick="{{ onStage }}" class="{{ hsClass }}" style="width:{{ devW }};height:{{ devH }};zoom:{{ zoom }};position:relative;overflow:hidden;background:var(--ui-bg-muted)"> <div onClick="{{ onStage }}" class="{{ hsClass }}" style="width:{{ devW }};height:{{ devH }};zoom:{{ zoom }};position:relative;overflow:hidden;background:var(--ui-bg-muted)">
<sc-if value="{{ isAuth }}"><div style="width:100%;height:100%;overflow:hidden;background:{{ authBg }};padding-top:{{ authTop }};display:flex;flex-direction:column;align-items:center"><dc-import name="AuthCard" s="{{ authS }}" mobile="{{ mobile }}" hint-size="400px,520px"></dc-import></div></sc-if> <sc-if value="{{ isAuth }}"><div style="width:100%;height:100%;overflow:hidden;background:{{ authBg }};padding-top:{{ authTop }};display:flex;flex-direction:column;align-items:center"><dc-import name="AuthCard" lang="{{ lang }}" lang-open="{{ langOpen }}" s="{{ authS }}" mobile="{{ mobile }}" hint-size="400px,520px"></dc-import></div></sc-if>
<sc-if value="{{ isApp }}"><dc-import name="AppFrame" s="{{ appS }}" mobile="{{ mobile }}" hint-size="100%,100%"></dc-import></sc-if> <sc-if value="{{ isApp }}"><dc-import name="AppFrame" s="{{ appS }}" mobile="{{ mobile }}" hint-size="100%,100%"></dc-import></sc-if>
<sc-if value="{{ isEmail }}"><div style="width:100%;height:100%;display:flex;flex-direction:column;background:var(--ui-bg)"><div style="flex-shrink:0;padding:14px 20px;border-bottom:1px solid var(--ui-border);display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ emailSubject }}</span><span style="color:var(--ui-text-muted)">From RenewCMS · to marta@renew.example</span></div><iframe title="Email" srcDoc="{{ emailHtml }}" style="flex:1;width:100%;border:0"></iframe></div></sc-if> <sc-if value="{{ isEmail }}"><div style="width:100%;height:100%;display:flex;flex-direction:column;background:var(--ui-bg)"><div style="flex-shrink:0;padding:14px 20px;border-bottom:1px solid var(--ui-border);display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ emailSubject }}</span><span style="color:var(--ui-text-muted)">From RenewCMS · to marta@renew.example</span></div><iframe title="Email" srcDoc="{{ emailHtml }}" style="flex:1;width:100%;border:0"></iframe></div></sc-if>
<sc-if value="{{ mnav }}"> <sc-if value="{{ mnav }}">
@@ -187,6 +187,9 @@ class Component extends DCLogic {
act(a) { act(a) {
const s = this.state const s = this.state
if (a === 'usermenu') return this.setState({ userMenu: !s.userMenu }) if (a === 'usermenu') return this.setState({ userMenu: !s.userMenu })
if (a === 'langmenu') return this.setState({ langOpen: !s.langOpen })
if (a && a.startsWith('lang-')) return this.setState({ lang: a.slice(5), langOpen: false })
if (s.langOpen) this.setState({ langOpen: false })
if (a && a.startsWith('theme-')) return this.setState({ mode: a.slice(6) }) if (a && a.startsWith('theme-')) return this.setState({ mode: a.slice(6) })
if (s.userMenu) this.setState({ userMenu: false }) if (s.userMenu) this.setState({ userMenu: false })
if (a === 'pf-reset') return this.setState({ pfReset: 'sent' }) if (a === 'pf-reset') return this.setState({ pfReset: 'sent' })
@@ -428,8 +431,8 @@ class Component extends DCLogic {
rootRef: this.rootRef, stageRef: this.stageRef, rootRef: this.rootRef, stageRef: this.stageRef,
devices: devs, modes, flows, hint, tools, devices: devs, modes, flows, hint, tools,
hs: s.hs, hsClass: s.hs ? 'hs' : '', hsJust: s.hs ? 'flex-end' : 'flex-start', hsBg: s.hs ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', toggleHs: () => this.setState({ hs: !s.hs }), hs: s.hs, hsClass: s.hs ? 'hs' : '', hsJust: s.hs ? 'flex-end' : 'flex-start', hsBg: s.hs ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', toggleHs: () => this.setState({ hs: !s.hs }),
mobile, isAuth, isApp, isEmail, mobile, isAuth, isApp, isEmail, lang: s.lang || 'en', langOpen: !!s.langOpen,
authS: isAuth ? this.authS() : {}, appS: isApp ? { ...this.appS(), userMenu: s.userMenu, mode: s.mode } : {}, authS: isAuth ? this.authS() : {}, appS: isApp ? { ...this.appS(), userMenu: s.userMenu, mode: s.mode, lang: s.lang || 'en' } : {},
authBg: mobile ? 'var(--ui-bg)' : 'var(--ui-bg-muted)', authTop: mobile ? '40px' : '108px', authBg: mobile ? 'var(--ui-bg)' : 'var(--ui-bg-muted)', authTop: mobile ? '40px' : '108px',
emailHtml: isEmail ? this.emailHtml() : '', emailSubject: s.email === 'reset' ? 'Reset your RenewCMS password' : 'Verify your RenewCMS account', emailHtml: isEmail ? this.emailHtml() : '', emailSubject: s.email === 'reset' ? 'Reset your RenewCMS password' : 'Verify your RenewCMS account',
devW: dw + 'px', devH: dh + 'px', zoom: s.zoom, boxW: Math.round(dw * s.zoom) + 'px', boxH: Math.round(dh * s.zoom) + 'px', devW: dw + 'px', devH: dh + 'px', zoom: s.zoom, boxW: Math.round(dw * s.zoom) + 'px', boxH: Math.round(dh * s.zoom) + 'px',
@@ -120,7 +120,7 @@ class Component extends DCLogic {
const pk = (o = {}, x = {}) => MS({ kind: 'picker', picker: true, pk: { tab: 'Library', ...o }, ...x }) const pk = (o = {}, x = {}) => MS({ kind: 'picker', picker: true, pk: { tab: 'Library', ...o }, ...x })
const TB = { neutral: 'var(--ui-bg-muted)', success: 'var(--rc-success-soft)', info: 'var(--rc-info-soft)', warning: 'var(--rc-warning-soft)', error: 'var(--rc-error-soft)' } const TB = { neutral: 'var(--ui-bg-muted)', success: 'var(--rc-success-soft)', info: 'var(--rc-info-soft)', warning: 'var(--rc-warning-soft)', error: 'var(--rc-error-soft)' }
const B = (t, kind, icon, dis) => ({ t, icon, bg: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-bg)', fg: kind === 'primary' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', bd: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-border-accented)', op: dis ? '0.4' : '1' }) const B = (t, kind, icon, dis) => ({ t, icon, bg: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-bg)', fg: kind === 'primary' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', bd: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-border-accented)', op: dis ? '0.4' : '1' })
const UPD = (o) => { const tone = o.tone || 'neutral'; return { v: 'v2.2.0-beta', focus: 'none', ...o, fg: tone === 'neutral' ? 'var(--ui-text-toned)' : 'var(--ui-' + tone + ')', bg: TB[tone], bar: o.pct !== undefined || !!o.indet, barW: o.indet ? '35%' : (o.pct || 0) + '%', barL: o.indet ? '30%' : '0', pct: o.indet ? undefined : o.pct } } const UPD = (o) => { const rdy = o.title === 'Version 2.3.0 is ready to install' && o.btns && o.btns.length; if (rdy) o = { ...o, tone: 'warning', icon: 'clock', pendingTag: true, inBtn: o.btns[0], btns: undefined }; const tone = o.tone || 'neutral'; return { v: 'v2.2.0-beta', focus: 'none', ...o, fg: tone === 'neutral' ? 'var(--ui-text-toned)' : 'var(--ui-' + tone + ')', bg: TB[tone], bar: o.pct !== undefined || !!o.indet, barW: o.indet ? '35%' : (o.pct || 0) + '%', barL: o.indet ? '30%' : '0', pct: o.indet ? undefined : o.pct } }
const NOTES = (open) => ({ open, chev: open ? 'chevron-down' : 'chevron-right', ver: '2.3.0', date: 'Released 24 Sep 2026', items: [{ t: 'Images upload faster, and large images no longer stall.' }, { t: 'Fixed a problem where a draft could show an old title after saving.' }, { t: 'Small improvements to the Users page.' }] }) const NOTES = (open) => ({ open, chev: open ? 'chevron-down' : 'chevron-right', ver: '2.3.0', date: 'Released 24 Sep 2026', items: [{ t: 'Images upload faster, and large images no longer stall.' }, { t: 'Fixed a problem where a draft could show an old title after saving.' }, { t: 'Small improvements to the Users page.' }] })
const SU = (o, extra) => withM({ kind: 'settings', nav: 'settings', crumbs: ['Settings'], mTitle: 'Settings', ...extra, upd: UPD(o) }) const SU = (o, extra) => withM({ kind: 'settings', nav: 'settings', crumbs: ['Settings'], mTitle: 'Settings', ...extra, upd: UPD(o) })
const LAST = 'Last checked today at 09:41' const LAST = 'Last checked today at 09:41'
+2
View File
@@ -28,3 +28,5 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
| `v2.4.0-beta` | v2.4.0-beta - Owner role, invite management & Settings page | | `v2.4.0-beta` | v2.4.0-beta - Owner role, invite management & Settings page |
| `v2.5.0-beta` | v2.5.0-beta - Media library & image picker | | `v2.5.0-beta` | v2.5.0-beta - Media library & image picker |
| `v2.6.0-beta` | v2.6.0-beta - Signed updates and browser restart | | `v2.6.0-beta` | v2.6.0-beta - Signed updates and browser restart |
| `v2.6.2-beta` | v2.6.2-beta - English and French interface |
| `v2.7.0-beta` | v2.7.0-beta - Markdown and block article bodies |
+29
View File
@@ -0,0 +1,29 @@
This release adds English and French throughout the SPA, with an instant language switch and a preference remembered on this browser.
## New
**English and French interface**
- Choose English or Français from the Language control in the desktop or mobile profile submenu.
- Switch interface language immediately while keeping your current page and unsaved work.
- Keep your selected language after navigation, reload and logout, including on authentication screens.
- See translated forms, validation, notifications, dialogs and accessibility labels, with localized dates, numbers and counts.
## Improvements and fixes
- Keep partial bulk-delete failure notifications available after retrying, including when switching languages.
## Under the hood
- Add translation catalog parity, fallback, persistence and SPA interaction tests.
## Upgrading
- Install the signed update through Settings on a prepared Linux/systemd installation, or replace the binary and restart the service.
- No database migration or new environment variable is required.
- English remains the default until a language is selected; the preference is stored locally in the browser.
- User content, API data and transactional emails retain their existing language.
## Known limitations
- Visual parity checks for this feature were skipped under the accepted slice waiver.
+35
View File
@@ -0,0 +1,35 @@
This release lets your website read article bodies as markdown, HTML or structured blocks, and fixes text alignment in the editor.
## New
**Article body formats**
- Read an article body as markdown, HTML or a typed block array with the `format` query parameter, so a headless frontend can render it with markdown-it, comark or its own components.
- Keep text alignment in every format: markdown wraps centered, right-aligned and justified blocks in a `div` with an inline style, and the block array carries an `align` field.
- Keep plain text plain: text that merely looks like markdown, such as `# not a heading` or `- not a list`, is escaped in the markdown output.
- See the Integration page snippets and [the article API reference](../api/articles.md) for the full contract.
## Improvements and fixes
- Fix the text alignment buttons in the article editor, which were disabled and showed a blocked cursor. Paragraphs and headings can be aligned left, center, right or justified again, and unaligned text stays free of inline styles.
- The web interface requests HTML explicitly when it loads, saves, publishes, unpublishes or creates an article, so the editor and the image counts behave exactly as before.
## API
- Changed: `GET /v1/article/`, `GET /v1/article/{id}`, `POST /v1/article/`, `PATCH /v1/article/{id}`, `PATCH /v1/article/{id}/publish` and `PATCH /v1/article/{id}/unpublish` now return `body` as markdown by default. Pass `?format=html` to keep the previous HTML body.
- Every article object now includes `body_format` (`markdown`, `html` or `json`), which names the format of `body`.
- `?format=json` on the same routes returns `body` as a JSON array of typed blocks instead of a string.
- An unsupported `format` answers `400` before anything is read or written.
- Storage and the request bodies of `POST` and `PATCH` stay HTML.
## Under the hood
- Add the article body renderer port in the domain, with a markdown and JSON block adapter, and tests for every supported element.
- Update `golang.org/x/crypto` to v0.57.0 and add `golang.org/x/net` as a direct dependency for HTML parsing.
- Add `@tiptap/extension-text-align` to the web interface.
## Upgrading
- Replace the binary. No database migration or new environment variable is required.
- Clients that read an article `body` as HTML must add `?format=html` to their article requests, because the default is now markdown.
- A markdown renderer needs raw HTML enabled for text alignment to show, for example `html: true` in markdown-it.
+229
View File
@@ -0,0 +1,229 @@
Feature: Article body content negotiation on read
As a headless frontend developer
I want article routes to return the body as markdown (default), html or a typed json block array
So that my client can render it without handling editor HTML
Background:
Given article bodies are stored as Tiptap HTML
And storage, the POST /v1/article body and the PATCH /v1/article/{id} body stay HTML and are unchanged
And internal/domain/article defines BodyFormat with the values "markdown", "html" and "json"
And internal/domain/article defines the port BodyRenderer and the sentinel ErrUnsupportedBodyFormat
And ParseBodyFormat matches case-insensitively, treats an empty value as "markdown", and returns ErrUnsupportedBodyFormat otherwise
And the use cases receive the BodyRenderer through their constructors, the handler only reads the "format" query value, and only api/dependecyInjection.go builds the converter
And every article object in a response has the snake_case fields id, title, body, body_format, is_online, created_at, updated_at
And body_format is "markdown", "html" or "json" and always names the format of body
And for markdown and html, body is a JSON string; for json, body is a JSON array of blocks (not an escaped string)
And the covered routes are GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish and PATCH /v1/article/{id}/unpublish
And visibility is unchanged: anonymous and unverified callers see only online articles, and an offline article reads as 404 "Article not found"
And an unsupported format answers 400 with apperr.ArticleBodyFormatErrorMessage as plain text, before any article lookup or write
And an invalid id answers apperr.IdUint32ErrorMessage first, ahead of the format check
And a repeated "format" parameter uses its first value
And a renderer failure answers 500 "Internal server error"
And article 1 is online, titled "Backyard Renovation Update", with body "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>"
Scenario Outline: Single read negotiates the body format
When an anonymous client sends GET /v1/article/1<query>
Then the response status is 200
And "body" is <body>
And "body_format" is "<body_format>"
Examples:
| query | body | body_format |
| | "## Plan\n\nReplace the **old** deck." | markdown |
| ?format= | "## Plan\n\nReplace the **old** deck." | markdown |
| ?format=markdown | "## Plan\n\nReplace the **old** deck." | markdown |
| ?format=MarkDown | "## Plan\n\nReplace the **old** deck." | markdown |
| ?format=html | "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>" | html |
| ?format=HTML | "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>" | html |
| ?format=json | [{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}] | json |
| ?format=JSON | the same array as ?format=json | json |
Scenario: List read negotiates every element
Given the repository also holds online article 3 with body "<p>Second</p>"
When an anonymous client sends GET /v1/article/
Then every element has body_format "markdown" and article 3 has body "Second"
When an anonymous client sends GET /v1/article/?format=html
Then every element has body_format "html" and its stored HTML as body
When an anonymous client sends GET /v1/article/?format=json
Then every element has body_format "json" and article 3 has body [{"type":"paragraph","children":[{"type":"text","text":"Second","marks":[]}]}]
Scenario Outline: An unsupported format is rejected
When an anonymous client sends GET <path>
Then the response status is 400
And the response body is apperr.ArticleBodyFormatErrorMessage followed by a newline
And no use case reads or renders an article
Examples:
| path |
| /v1/article/1?format=pdf |
| /v1/article/?format=xml |
| /v1/article/9999?format=pdf |
| /v1/article/1?format=md |
Scenario: An invalid id is reported before an invalid format
When an anonymous client sends GET /v1/article/abc?format=pdf
Then the response status is 400
And the response body is apperr.IdUint32ErrorMessage followed by a newline
Scenario: The first repeated format parameter wins
When an anonymous client sends GET /v1/article/1?format=html&format=json
Then "body_format" is "html"
Scenario: Offline articles stay hidden whatever the format
Given article 2 is offline
When an anonymous client sends GET /v1/article/2?format=json
Then the response status is 404 and the body is "Article not found"
Scenario Outline: Authenticated routes negotiate the response body
Given a valid jwt cookie for a verified user with id 1
When the client sends <method> <path> with <request_body>
Then the response status is <status>
And "body_format" is "<body_format>"
And the stored body is the HTML that was sent, never markdown or json
Examples:
| method | path | request_body | status | body_format |
| POST | /v1/article/ | {"title":"Deck","body":"<p>A</p>"} | 201 | markdown |
| POST | /v1/article/?format=html | {"title":"Deck","body":"<p>A</p>"} | 201 | html |
| POST | /v1/article/?format=json | {"title":"Deck","body":"<p>A</p>"} | 201 | json |
| PATCH | /v1/article/1 | {"body":"<p>B</p>"} | 200 | markdown |
| PATCH | /v1/article/1?format=json | {"body":"<p>B</p>"} | 200 | json |
| PATCH | /v1/article/1/publish | | 200 | markdown |
| PATCH | /v1/article/1/unpublish?format=html | | 200 | html |
Scenario: An unsupported format on a write route changes nothing
Given a valid jwt cookie for a verified user with id 1
When the client sends PATCH /v1/article/1?format=pdf with {"body":"<p>B</p>"}
Then the response status is 400
And the stored body of article 1 is unchanged
When the client sends POST /v1/article/?format=pdf with {"title":"Deck","body":"<p>A</p>"}
Then the response status is 400 and no article is created
Scenario: The authenticated list includes offline articles in the negotiated format
Given a valid jwt cookie for a verified user and offline article 2
When the client sends GET /v1/article/?format=json
Then the array contains articles 1 and 2 with body_format "json"
Scenario: A converter failure answers 500
Given the BodyRenderer fails on its next call
When an anonymous client sends GET /v1/article/1
Then the response status is 500 and the body is "Internal server error"
Scenario Outline: Markdown fidelity for Tiptap output
Given the stored body is <html>
When converted with the format "markdown"
Then the body is <markdown>
Examples:
| html | markdown |
| <h1>T</h1><h3>S</h3> | # T\n\n### S |
| <p><strong>b</strong> <em>i</em></p> | **b** *i* |
| <p><a href="https://example.com/x">link</a></p> | [link](https://example.com/x) |
| <ul><li><p>a</p></li><li><p>b</p></li></ul> | - a\n- b |
| <ol><li><p>a</p></li><li><p>b</p></li></ol> | 1. a\n2. b |
| <ul><li data-indent="2"><p>a</p></li></ul> | - a |
| <blockquote><p>q</p></blockquote> | > q |
| <pre><code class="language-go">x := 1</code></pre> | ```go\nx := 1\n``` |
| <hr> | --- |
| <img src="/static/uploadedImages/a.png" alt="A"> | ![A](/static/uploadedImages/a.png) |
| <p>ok</p><script>alert(1)</script><style>p{}</style> | ok |
| <p>x<span style="color:red">y</span></p> | xy |
| <p>use <code>go test</code></p> | use `go test` |
Scenario Outline: Json block shapes, one row per block and mark type
Given the stored body is <html>
When converted with the format "json"
Then the body is <json>
Examples:
| html | json |
| <h1>T</h1> | [{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}] |
| <h6>S</h6> | [{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}] |
| <p>plain</p> | [{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}] |
| <p></p> | [{"type":"paragraph","children":[]}] |
| <p><strong>b</strong></p> | [{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}] |
| <p><em>i</em></p> | [{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}] |
| <p><a href="https://example.com/x">l</a></p> | [{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}] |
| <p><a href="/a"><strong><em>x</em></strong></a></p> | [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}] |
| <p>a<br>b</p> | [{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}] |
| <ul><li><p>a</p></li><li><p>b</p></li></ul> | [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}] |
| <ol><li><p>a</p></li></ol> | [{"type":"list","ordered":true,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}]]}] |
| <ul><li>bare</li></ul> | [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"bare","marks":[]}]}]]}] |
| <ul><li><p>a</p><ul><li><p>n</p></li></ul></li></ul> | a list item whose blocks are the paragraph "a" followed by a nested list block with ordered false and the item "n" |
| <ul><li data-indent="2"><p>a</p></li></ul> | the same output as without data-indent |
| <blockquote><p>q</p></blockquote> | [{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}] |
| <pre><code class="language-go">x := 1</code></pre> | [{"type":"code","language":"go","text":"x := 1"}] |
| <pre><code>raw</code></pre> | [{"type":"code","language":"","text":"raw"}] |
| <hr> | [{"type":"hr"}] |
| <img src="/static/uploadedImages/a.png" alt="A"> | [{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}] |
| <img src="/static/uploadedImages/a.png"> | [{"type":"image","src":"/static/uploadedImages/a.png","alt":""}] |
| <p>x<img src="/i.png" alt="I">y</p> | [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","children":[{"type":"text","text":"y","marks":[]}]}] |
| <p>x<span style="color:red">y</span></p> | [{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}] |
| <p>use <code>go test</code></p> | [{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}] |
| <p>ok</p><script>alert(1)</script><style>p{}</style> | [{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}] |
Scenario Outline: Text alignment survives the conversion
Given the stored body is <html>
When converted with the format "markdown"
Then the body is <markdown>
When converted with the format "json"
Then the body is <json>
Examples:
| html | markdown | json |
| <p style="text-align: center">a</p> | <div style="text-align: center">
a
</div> | [{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}] |
| <h2 style="text-align:right">T</h2> | <div style="text-align: right">
## T
</div> | [{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}] |
| <p style="text-align: JUSTIFY">a</p> | <div style="text-align: justify">
a
</div> | [{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}] |
| <p style="text-align: left">a</p> | a | [{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}] |
| <p style="text-align: start">a</p> | a | the same output as the left row |
| <p style="text-align: center"></p> | an empty string | [{"type":"paragraph","align":"center","children":[]}] |
Scenario Outline: Plain text that looks like markdown stays plain text
Given the stored body is <html>
When converted with the format "markdown"
Then the body is <markdown>
Examples:
| html | markdown |
| <p># not a heading</p> | \# not a heading |
| <p>#tag</p> | #tag |
| <p>- not a list</p> | \- not a list |
| <p>+ not a list</p> | \+ not a list |
| <p>&gt; not a quote</p> | \> not a quote |
| <p>1. not a list</p> | 1\. not a list |
| <p>2) not a list</p> | 2\) not a list |
| <p>---</p> | \--- |
| <p>a - b</p> | a - b |
| <p>a * b _c_</p> | a \* b \_c\_ |
| <p>a<br>- b</p> | a\n\- b |
Scenario: The editor lets the author align text
Given the article editor registers the TextAlign extension for paragraphs and headings
Then the left, center, right and justify toolbar buttons are enabled
And clicking center writes style="text-align: center;" on the current paragraph or heading
And paragraphs and headings that were never aligned carry no style attribute
Scenario: The SPA asks for html explicitly
Given no visual change is expected, so docs/design is not consulted
When EditorView loads an article, or ArticleView or HomeView load the list
Then each request is /v1/article/{id}?format=html or /v1/article/?format=html
And the draft save PATCH, the publish and unpublish PATCH and the create POST also carry ?format=html, so a converter failure cannot fail a save
And the editor and the image counts receive HTML exactly as before
Scenario: The documentation reflects the contract
Then docs/api has an article read page covering format, the markdown default, body_format, the json block schema, the 400 message and the breaking default change
And web/src/views/IntegrationView.vue snippets show ?format
And docs/releases has a note stating the default flip and that the SPA sends format=html
+46
View File
@@ -0,0 +1,46 @@
# QA procedure: article_body_format
Package: `test` (black-box). New files: `test/article_body_format_test.go`, `test/markdown_converter_test.go`, `test/json_blocks_converter_test.go`. No live server, no real SQLite file, no real SMTP.
## Setup
1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` and `mockUserRepo`. Add a fake `fakeBodyRenderer` with a unique type name. It records each call's format and the html it received. For markdown it returns "MD:"+html. For html it returns the html unchanged. For json it returns `[{"type":"paragraph","children":[{"type":"text","text":"FAKE","marks":[]}]}]`. It has a failure switch.
3. Wire the real use cases over the fakes, passing the renderer. Update every existing test that builds these use cases for the new constructor signatures. Build `article.NewHandler(...)`.
4. Mount a fresh `chi.NewRouter()` mirroring `api/router.go` for `/v1/article`: `jwtauth.Verifier` on the group, GET `/` and `/{id}` public, and `Authenticator` + `RequireCurrentToken` + `RequireVerifiedUser` for POST, DELETE and PATCH.
5. Mint a jwt cookie for user_id 1 with `token.Encode(map[string]any{"user_id": uint32(1)})`. Seed a verified user. Seed articles 1 (online) and 2 (offline).
## HTTP checks (fake renderer)
6. Single read matrix: for each of none, `?format=`, markdown, MarkDown, html, HTML, json, JSON, assert status 200, `body_format`, the renderer's recorded format and the stored HTML passed in. For json, decode `body` and assert it is a JSON array (not a string).
7. List with none, `?format=html` and `?format=json`: every element has the matching `body_format`.
8. `format=pdf`, `xml` and `md` on single, list and unknown-id paths return 400 with `apperr.ArticleBodyFormatErrorMessage + "\n"`. The renderer and the repo are not called.
9. `GET /v1/article/abc?format=pdf` returns 400 with `apperr.IdUint32ErrorMessage + "\n"`.
10. `?format=html&format=json` returns html.
11. `GET /v1/article/2?format=json` anonymous returns 404 `"Article not found\n"`.
12. Write-route matrix with a cookie (POST, PATCH, publish, unpublish, each with none, html and json where listed): assert status, `body_format`, and that the repo holds the HTML that was sent.
13. `PATCH /v1/article/1?format=pdf` and `POST /v1/article/?format=pdf` with a cookie return 400. The repo is unchanged and no article is created.
14. Authenticated `GET /v1/article/?format=json` includes articles 1 and 2.
15. Renderer failure switch, then `GET /v1/article/1` returns 500 `"Internal server error\n"`.
## Domain
16. `ParseBodyFormat`: "", markdown, MARKDOWN, html, Html, json, JSON are accepted (empty returns markdown). pdf, md and " html" return `errors.Is(err, ErrUnsupportedBodyFormat)`.
## Converter tests (real library, real Tiptap HTML samples)
17. `test/markdown_converter_test.go`: every row of the markdown fidelity table, plus a combined document (heading, paragraph with marks, list, image, code block, quote, hr). Assert exact output. For format html, assert the input comes back byte-identical.
17b. Alignment and escaping: every row of the alignment and plain-text tables, for markdown and json, in `test/markdown_converter_test.go` and `test/json_blocks_converter_test.go`. `test/body_converter_documents_test.go` covers the combined hostile document (no raw script, style, iframe or span leaks in markdown or json), its block types, and code block text extraction.
18. `test/json_blocks_converter_test.go`: every row of the json shapes table. Assert by unmarshalling both sides and comparing with deep equality. This also asserts `marks` is `[]` rather than null or absent, `ordered` is a bool, and code `language` is "" when absent. Also include a combined document with nested list, blockquote and an image inside a paragraph. Assert that script and style content never appears, and that no raw HTML survives in any output for any format except html.
19. `test/json_blocks_converter_test.go` also asserts that html "" produces `[]`, and that the output is a non-null JSON array.
## SPA (Vitest, `task test:web`)
20. Mock `/v1/article/12?format=html` and assert `EditorView` requests that URL and loads the HTML body into the draft.
21. Assert `useArticles.listArticles` requests `/v1/article/?format=html`.
22. Assert the `ArticleView` image count is computed from the HTML.
23. Assert `useArticleDraft.save` sends PATCH `/v1/article/{id}?format=html` with the JSON body.
24. Reported as NOT RUN if there is no runner. No visual check applies, because no visual change is expected.
## Docs
25. Check that a docs/api article read page, the IntegrationView snippets and a docs/releases note exist as described.
## Manual verification
- In the embedded SPA, edit and save an article against a real SQLite file and confirm the stored body is still HTML.
- `curl` the public list and a single article with no format, `?format=html` and `?format=json` against a built binary.
- No SMTP involvement.
@@ -0,0 +1,172 @@
Feature: Language switcher conforms to the official design
Background:
Given the design in docs/design is the visual spec of record for LanguageSwitcher and UserMenu
And the interface supports exactly two languages, English and Français
And each language is always written in its own name, whatever the interface language
And the language rules of features/spa_internationalization.feature still apply
And the language control no longer uses a form select anywhere
And no language preference is sent to the server
Scenario Outline: Account menu lists Profile, Theme, Language and Log out in design order
Given a signed-in user opens the account menu in the <surface>
Then the entries appear in this order
| Profile |
| separator |
| "Theme" section label |
| System, Light, Dark |
| separator |
| "Language" section label |
| English, Français |
| separator |
| Log out |
And the "Theme" and "Language" labels are 11 px, weight 500, uppercase, letter-spacing 0.04em and --ui-text-muted
And the label padding is <label_padding>
And the menu is <size>
And every item row is <row_height> high
Examples:
| surface | label_padding | size | row_height |
| desktop UserMenu popover | 6px 8px 2px | a 224 px popover above the chip | 32 px |
| mobile AccountMenuSheet | 8px 16px 2px | a bottom sheet with grab handle | 48 px |
Scenario Outline: Language rows are a radio group naming each language in its own name
Given the interface language is <locale>
When the account menu is open in the <surface>
Then the "Language" label names a group of two menuitemradio rows
And the rows read "English" with lang "en" and "Français" with lang "fr"
And the row for <locale_name> has aria-checked "true" and a check icon at the right edge
And the other row has aria-checked "false" and no check icon
And no Nuxt UI select, combobox or languages icon row is present in the menu
Examples:
| surface | locale | locale_name |
| desktop UserMenu popover | en | English |
| desktop UserMenu popover | fr | Français |
| mobile AccountMenuSheet | en | English |
| mobile AccountMenuSheet | fr | Français |
Scenario Outline: Theme rows are radio items that keep the menu open
Given the theme preference is <preference>
When the account menu is open in the <surface>
Then System, Light and Dark are menuitemradio rows in a group named "Theme"
And only the <preference> row has aria-checked "true" and a check icon
When the user chooses another theme row
Then the theme applies at once and the menu stays open
Examples:
| surface | preference |
| desktop UserMenu popover | light |
| mobile AccountMenuSheet | dark |
Scenario Outline: Choosing a language in the account menu applies it at once and keeps the menu open
Given the interface language is en and the user is on /profile with an unsaved display name "Alice B."
And the account menu is open in the <surface>
When the user chooses "Français"
Then all interface text, including the menu labels "Thème" and "Langue", is French
And the document lang is "fr" and localStorage "renewcms-language" is "fr"
And the Français row is checked and the menu stays open
And the route, session, theme and unsaved display name are unchanged
And no reload, navigation or /v1 request occurs
When the user chooses "English"
Then the interface is English, the document lang is "en" and the menu stays open
Examples:
| surface |
| desktop UserMenu popover |
| mobile AccountMenuSheet |
Scenario Outline: Account menu keyboard and dismissal
Given the account menu is open in the <surface>
When the user moves with ArrowDown and ArrowUp
Then focus visits Profile, the theme rows, the language rows and Log out in visual order and wraps at the ends
When the user presses Enter or Space on a language row
Then that language is applied and the menu stays open
When the user presses Escape or clicks outside
Then the menu closes without changing the language
Examples:
| surface |
| desktop UserMenu popover |
| mobile AccountMenuSheet |
Scenario Outline: Auth footer shows the language chip beside the theme chip
Given the user is signed out on <route>
And the interface language is en
Then the footer outside the card contains the language chip and the System theme chip aligned right with an 8 px gap
And any existing footer slot content stays to the left of the chips
And the chip shows the lucide languages icon at 14 px followed by "English"
And the chip has a 1 px --ui-border-accented border, --ui-text-toned text, aria-haspopup "menu", aria-expanded "false" and aria-label "Language: English"
And no language select is rendered in the card or the footer
Examples:
| route |
| /login |
| /register |
| /register/pending |
| /register/validate |
| /register/reset/request |
| /register/reset/validate |
| /invite/accept |
Scenario Outline: Auth footer is always rendered, on mobile too
Given the viewport is <viewport>
When an auth screen with no footer slot content renders
Then the footer with the language chip and the theme chip is visible
And the chip is <chip_height> high
Examples:
| viewport | chip_height |
| 1440 x 900 | 28 px |
| 390 x 844 | 44 px |
Scenario: Chip reflects the current language
Given the stored language is fr
When /login renders
Then the chip reads "Français" and its aria-label is "Langue : Français"
Scenario Outline: Auth language popover
Given the user is signed out on /login in <locale_name>
When the user activates the language chip
Then the chip has aria-expanded "true" and --ui-bg-elevated
And a popover with role "menu" named "Language" opens above the chip, right-aligned and 36 px above its bottom edge, 168 px wide, --ui-bg, 1 px --ui-border, --rc-shadow-pop and 4 px padding
And it holds menuitemradio rows "English" (lang "en") and "Français" (lang "fr") that are 32 px high on desktop and 44 px on mobile
And the current language row has aria-checked "true" and a 16 px check icon at the right edge
Examples:
| locale_name |
| English |
| Français |
Scenario: Choosing a language in the auth popover closes it
Given the auth language popover is open on /login with English current
When the user chooses "Français"
Then the whole screen is French at once, including the card, the footer and the document title
And the document lang is "fr" and localStorage "renewcms-language" is "fr"
And the popover closes and focus returns to the language chip
And the chip reads "Français"
And the form values already typed, the route and the theme are unchanged
And no /v1 request occurs
Scenario: Dismiss the auth popover without changing language
Given the auth language popover is open
When the user presses Escape or clicks outside it
Then it closes, aria-expanded is "false" and the language is unchanged
And focus returns to the chip on Escape
Scenario: Auth popover keyboard
Given the language chip has focus
When the user presses Enter, Space or ArrowDown
Then the popover opens with focus on the current language row
And ArrowUp and ArrowDown move between the two rows and Enter or Space chooses one
Scenario: Language persists across public and signed-in screens
Given the user chose Français on /login
When they sign in, open the account menu and log out
Then Français is checked in the account menu and the /login chip reads "Français" again
And a storage failure still lets the choice apply for the current session, as in spa_internationalization
Scenario: Catalog parity for the new strings
Then the en and fr catalogs contain "Theme" and "Language: {0}"
And "Language: {0}" keeps its {0} parameter in French
And the strings "English" and "Français" are not translated
@@ -0,0 +1,35 @@
# Language switcher conformance QA
Accepted by the user on 2026-10-04 together with the slice specification. Decisions folded in: spa_internationalization picker scenarios are amended; theme and language rows are menuitemradio and the account menu and sheet stay open after a choice; the auth popover is specified from docs/design/components.md because the boards draw only the chip.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `/v1` per case and assert that a language change makes no `/v1` request. Reset the stored `renewcms-language` between tests. Do not freeze the system date.
1. UserMenu (desktop) and AccountMenuSheet (mobile): open the menu and assert the entry order Profile, separator, "Theme" label, System/Light/Dark, separator, "Language" label, English/Français, separator, Log out. Assert the label classes. LanguagePicker and any combobox must be absent.
2. Roles and ARIA: theme rows and language rows are menuitemradio with aria-checked on the current one and a check icon. Language rows carry lang "en"/"fr" and native names. With locale fr the names stay "English"/"Français" while "Theme"/"Language" become "Thème"/"Langue".
3. Account menu selection: choosing Français in each surface switches the visible copy, sets document lang and storage, keeps the menu open, and leaves route, theme and an unsaved display-name draft on /profile unchanged. Choosing English restores it. Choosing a theme keeps the mobile sheet open.
4. Account menu keyboard: arrow order and wrap across all entries, Enter/Space applies and keeps open, Escape and outside click close with no language change.
5. AuthShell: the footer is rendered with the language chip next to UColorModeSelect when belowSm is true and false, with no footer slot content, and slot content stays to the left. Assert aria-haspopup, aria-expanded, aria-label "Language: English" / "Langue : Français", the icon, the native name and the absence of a select.
6. Auth popover: opens via click, Enter, Space and ArrowDown; role menu named "Language", two menuitemradio rows with lang and checked state; selection applies the language, closes it and returns focus to the chip; Escape and outside click dismiss with no change. Typed form values on /login survive the switch.
7. Each auth screen (login, register, pending, verify, forgot-password, reset-password, accept-invite) mounts with the chip present.
8. Persistence: stored fr gives chip "Français"; the choice survives log out and re-render of /login; a localStorage write failure still switches for the session.
9. Catalog parity: en/fr keys include "Theme" and "Language: {0}" with matching parameters.
10. Update web/src/i18n/spa.spec.ts, web/src/i18n/i18n.spec.ts and any AuthShell, UserMenu or AccountMenuSheet spec asserting menuitemcheckbox or the old picker. Remove LanguagePicker and its tests if the component is removed.
11. Run task test:web and the type check; inspect the diff for introduced comments.
## Visual (`task visual:check -- <screen>`, hard gate at 10 %)
Desktop and mobile, light and dark, reusing the boards' sample data.
- profile, state "Account menu": board `deskMenu` and `mobMenu` with the Theme and Language labels, Light and English checked.
- Every auth screen and state (log-in, register, pending, verify, forgot-password, reset-password, accept-invite): all captures change because of the language chip (`hasFooter: true`), including mobile where the footer is new. Clock-sensitive states stay `skip: 'clock-sensitive'`.
- The boards draw no open auth language popover, so it has no visual gate. It is covered by the automated and manual checks.
## Manual (screenshots cannot cover)
- Keyboard focus order and visible focus ring in the account menu, the chip and the popover; focus return to the chip.
- Hover, press and motion on the rows and the chip.
- Popover placement above the chip and containment at 390 px width, including the 36 px offset next to the 44 px mobile chip. Touch targets at 44 px on mobile.
- French readability of "Thème", "Langue" and "Langue : Français" in the labels, chip and popover.
- Language retained after reload and logout.
+75
View File
@@ -0,0 +1,75 @@
Feature: English and French throughout the SPA
Background:
Given all existing SPA interface text supports English and French
And English preserves existing copy and French provides equivalent meaning
And user content, API data and transactional emails remain unchanged
And the language control follows features/language_switcher_conformance.feature
Scenario Outline: Resolve the language before rendering
Given the browser-local stored language is <stored>
When the SPA starts
Then the interface language and document lang are <locale>
And browser language does not override the choice
Examples:
| stored | locale |
| absent | en |
| en | en |
| fr | fr |
| unsupported | en |
| unreadable | en |
Scenario Outline: Offer the language choice in the account menu
Given the user opens the <menu>
Then a localized Language section offers English and Français as radio items
And the selected language is visibly and accessibly identified
Examples:
| menu |
| desktop UserMenu |
| mobile AccountMenuSheet |
Scenario: Switch language without losing work
Given the user is editing an unsaved display name on /profile
When they select Français
Then all SPA interface text immediately uses French
And the document lang is fr
And the route, session, theme and unsaved work remain unchanged
And no reload, navigation or API request is caused by switching
When they select English
Then the interface immediately restores English with the same preserved state
Scenario: Remember language on this browser
Given the user selected Français
When they navigate, reload, log out or return on this browser
Then French remains selected including on public authentication screens
And no language preference is sent to the server
Scenario: Storage failure does not prevent switching
Given preference storage cannot be written
When the user selects Français
Then French is used for the current session without an error
And a fresh load without a readable preference defaults to English
Scenario: Translate the entire interface
Given French is selected
When an existing SPA screen or shared component renders
Then navigation, forms, validation, notifications, dialogs and accessibility labels use French
And counts, human-readable dates and numbers use the selected locale
And Nuxt UI controls use the selected locale
And English and French catalogs have matching keys and interpolation parameters
And an unavailable French message falls back to English
And user content, account details, filenames, URLs, API data and emails remain unchanged
Scenario Outline: Operate the language choice accessibly
Given the <menu> is open
When the user navigates the language items with the keyboard and confirms a choice
Then the language changes and the menu stays open
When they press Escape or click outside
Then the menu closes without changing language
Examples:
| menu |
| desktop UserMenu |
| mobile AccountMenuSheet |
+19
View File
@@ -0,0 +1,19 @@
# SPA internationalization QA
Accepted by the user on 2026-10-03 together with the slice specification.
## Automated
1. Verify absent, English, French, unsupported and unreadable stored preferences; English defaults independently of browser language and document lang follows selection.
2. Verify desktop UserMenu and mobile AccountMenuSheet offer a localized Language section with English and Français radio items, selected-state semantics, selection with the menu staying open, and dismissal. Preserve profile, theme and logout behavior. Details live in features/language_switcher_conformance.qa.md.
3. Switch both ways while editing an unsaved profile display name. Verify translated UI and unchanged draft, route, session and theme without additional API requests.
4. Verify browser-local persistence across reload initialization, navigation and logout/public authentication. Verify storage write failure allows switching for the current session.
5. Preserve existing English tests and add French checks for login, validation, profile save failure, integration copy feedback, not-found, representative dialogs and notifications.
6. Audit every existing view, shared component, composable and library producing interface text, including editor, media, users, invitations, settings and updates. Verify Nuxt UI controls use the active locale.
7. Assert catalog key/interpolation parity, nonempty translations, pluralization, English fallback and locale-sensitive formatting. Use fixed input dates without freezing system time. Verify displayed alerts and dialogs react to language changes.
8. Verify user content, account details, filenames, URLs, API fields, integration protocol examples, server release notes and transactional emails remain unchanged.
9. Run task test:web, frontend type checks, applicable lint and final build checks. Inspect introduced comments and ensure application changes are SPA-only.
## Visual and manual
The language control now has an official design. Its visual and manual checks live in features/language_switcher_conformance.qa.md. For the rest of this slice, confirm French text readability and language retention after reload and logout. Do not freeze dates or investigate clock-sensitive states.
+1 -1
View File
@@ -254,7 +254,7 @@ Feature: Updates part 2: the Update button (download, verify, stage)
Scenario: Settings, owner, Ready to install Scenario: Settings, owner, Ready to install
Given the poll answers state "ready" Given the poll answers state "ready"
Then polling stops Then polling stops
And the block shows icon circle-check (success), "Version 2.3.0-beta is ready to install" / And the block shows icon clock (warning tone) with a "Pending" tag after the title, "Version 2.3.0-beta is ready to install" /
"The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." / "The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." /
"Nothing has changed yet." "Nothing has changed yet."
And the notes button is shown collapsed And the notes button is shown collapsed
+2 -1
View File
@@ -11,8 +11,9 @@ require (
github.com/go-playground/validator/v10 v10.28.0 github.com/go-playground/validator/v10 v10.28.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.44.0 golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
golang.org/x/net v0.59.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
+4 -2
View File
@@ -78,14 +78,16 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+32
View File
@@ -0,0 +1,32 @@
package article
import (
"errors"
"strings"
)
var ErrUnsupportedBodyFormat = errors.New("unsupported body format")
type BodyFormat string
const (
BodyFormatMarkdown BodyFormat = "markdown"
BodyFormatHTML BodyFormat = "html"
BodyFormatJSON BodyFormat = "json"
)
func ParseBodyFormat(value string) (BodyFormat, error) {
switch strings.ToLower(value) {
case "", string(BodyFormatMarkdown):
return BodyFormatMarkdown, nil
case string(BodyFormatHTML):
return BodyFormatHTML, nil
case string(BodyFormatJSON):
return BodyFormatJSON, nil
}
return "", ErrUnsupportedBodyFormat
}
type BodyRenderer interface {
Render(html string, format BodyFormat) (string, error)
}
+365
View File
@@ -0,0 +1,365 @@
package markdown
import (
"net/url"
"strconv"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
const (
markBold = "bold"
markItalic = "italic"
markLink = "link"
markCode = "code"
codeLanguagePrefix = "language-"
)
type mark struct {
kind string
href string
}
type inline struct {
text string
marks []mark
}
type blockKind int
const (
kindHeading blockKind = iota
kindParagraph
kindList
kindBlockquote
kindCode
kindRule
kindImage
)
type block struct {
kind blockKind
level int
inlines []inline
ordered bool
items [][]block
children []block
language string
text string
src string
alt string
align string
}
var transparentBlocks = map[string]bool{
"div": true, "section": true, "article": true, "header": true, "footer": true,
"main": true, "aside": true, "nav": true, "figure": true, "figcaption": true,
"table": true, "thead": true, "tbody": true, "tfoot": true, "tr": true,
"td": true, "th": true, "dl": true, "dt": true, "dd": true, "details": true,
"summary": true, "li": true,
}
var skippedElements = map[string]bool{
"script": true, "style": true, "template": true, "head": true,
"noscript": true, "iframe": true, "object": true, "embed": true,
}
var alignments = map[string]bool{"center": true, "right": true, "justify": true}
var allowedLinkSchemes = map[string]bool{
"": true, "http": true, "https": true, "mailto": true, "tel": true,
}
func parseBlocks(source string) ([]block, error) {
context := &html.Node{Type: html.ElementNode, DataAtom: atom.Body, Data: "body"}
nodes, err := html.ParseFragment(strings.NewReader(source), context)
if err != nil {
return nil, err
}
collector := newCollector(false, paragraphBlock)
collector.walkAll(nodes, nil)
return collector.finish(false), nil
}
func paragraphBlock(inlines []inline) block {
return block{kind: kindParagraph, inlines: inlines}
}
func headingBlock(level int) func([]inline) block {
return func(inlines []inline) block {
return block{kind: kindHeading, level: level, inlines: inlines}
}
}
type collector struct {
out []block
current []inline
keepBlank bool
build func([]inline) block
}
func newCollector(keepBlank bool, build func([]inline) block) *collector {
return &collector{keepBlank: keepBlank, build: build}
}
func (c *collector) walkAll(nodes []*html.Node, marks []mark) {
for _, node := range nodes {
c.walk(node, marks)
}
}
func (c *collector) walkChildren(parent *html.Node, marks []mark) {
for child := parent.FirstChild; child != nil; child = child.NextSibling {
c.walk(child, marks)
}
}
func (c *collector) addText(text string, marks []mark) {
if text == "" {
return
}
c.current = appendInline(c.current, inline{text: text, marks: marks})
}
func (c *collector) flush() {
if len(c.current) == 0 {
return
}
if c.keepBlank || hasVisibleText(c.current) {
c.out = append(c.out, c.build(c.current))
}
c.current = nil
}
func (c *collector) finish(emitEmpty bool) []block {
hadBlocks := len(c.out) > 0
c.flush()
if emitEmpty && !hadBlocks && len(c.out) == 0 {
c.out = append(c.out, c.build(nil))
}
return c.out
}
func (c *collector) walk(node *html.Node, marks []mark) {
switch node.Type {
case html.TextNode:
c.addText(node.Data, marks)
return
case html.ElementNode:
default:
return
}
tag := node.Data
if skippedElements[tag] {
return
}
switch tag {
case "br":
c.addText("\n", marks)
case "strong", "b":
c.walkChildren(node, withMark(marks, mark{kind: markBold}))
case "em", "i":
c.walkChildren(node, withMark(marks, mark{kind: markItalic}))
case "code":
c.walkChildren(node, withMark(marks, mark{kind: markCode}))
case "a":
href := safeHref(attr(node, "href"))
if href == "" {
c.walkChildren(node, marks)
return
}
c.walkChildren(node, withMark(marks, mark{kind: markLink, href: href}))
case "img":
c.flush()
if src := attr(node, "src"); src != "" {
c.out = append(c.out, block{kind: kindImage, src: src, alt: attr(node, "alt")})
}
case "hr":
c.flush()
c.out = append(c.out, block{kind: kindRule})
case "p":
c.flush()
c.out = append(c.out, parseTextBlock(node, paragraphBlock)...)
case "h1", "h2", "h3", "h4", "h5", "h6":
c.flush()
level, _ := strconv.Atoi(tag[1:])
c.out = append(c.out, parseTextBlock(node, headingBlock(level))...)
case "ul", "ol":
c.flush()
c.out = append(c.out, parseList(node))
case "blockquote":
c.flush()
c.out = append(c.out, block{kind: kindBlockquote, children: parseContainer(node)})
case "pre":
c.flush()
c.out = append(c.out, parseCodeBlock(node))
default:
if transparentBlocks[tag] {
c.flush()
c.walkChildren(node, marks)
c.flush()
return
}
c.walkChildren(node, marks)
}
}
func parseTextBlock(node *html.Node, build func([]inline) block) []block {
inner := newCollector(true, build)
inner.walkChildren(node, nil)
blocks := inner.finish(true)
if align := alignFromStyle(attr(node, "style")); align != "" {
for index := range blocks {
if blocks[index].kind == kindParagraph || blocks[index].kind == kindHeading {
blocks[index].align = align
}
}
}
return blocks
}
func alignFromStyle(style string) string {
for _, declaration := range strings.Split(style, ";") {
name, value, found := strings.Cut(declaration, ":")
if !found || strings.ToLower(strings.TrimSpace(name)) != "text-align" {
continue
}
if align := strings.ToLower(strings.TrimSpace(value)); alignments[align] {
return align
}
}
return ""
}
func parseContainer(node *html.Node) []block {
inner := newCollector(false, paragraphBlock)
inner.walkChildren(node, nil)
return inner.finish(false)
}
func parseList(node *html.Node) block {
list := block{kind: kindList, ordered: node.Data == "ol"}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode && child.Data == "li" {
list.items = append(list.items, parseContainer(child))
}
}
return list
}
func parseCodeBlock(node *html.Node) block {
code := block{kind: kindCode, text: strings.TrimSuffix(textContent(node), "\n")}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode && child.Data == "code" {
code.language = languageFromClass(attr(child, "class"))
break
}
}
return code
}
func languageFromClass(class string) string {
for _, name := range strings.Fields(class) {
if strings.HasPrefix(name, codeLanguagePrefix) {
return strings.TrimPrefix(name, codeLanguagePrefix)
}
}
return ""
}
func textContent(node *html.Node) string {
var builder strings.Builder
var visit func(*html.Node)
visit = func(n *html.Node) {
switch {
case n.Type == html.TextNode:
builder.WriteString(n.Data)
case n.Type == html.ElementNode && skippedElements[n.Data]:
return
case n.Type == html.ElementNode && n.Data == "br":
builder.WriteString("\n")
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
visit(child)
}
}
visit(node)
return builder.String()
}
func attr(node *html.Node, name string) string {
for _, a := range node.Attr {
if a.Key == name {
return a.Val
}
}
return ""
}
func safeHref(href string) string {
trimmed := strings.TrimSpace(href)
parsed, err := url.Parse(trimmed)
if err != nil || !allowedLinkSchemes[strings.ToLower(parsed.Scheme)] {
return ""
}
return trimmed
}
func withMark(marks []mark, added mark) []mark {
for _, existing := range marks {
if existing.kind == added.kind {
return marks
}
}
next := make([]mark, 0, len(marks)+1)
next = append(next, marks...)
return append(next, added)
}
func hasVisibleText(inlines []inline) bool {
for _, item := range inlines {
if strings.TrimSpace(item.text) != "" {
return true
}
}
return false
}
func sameMarks(a, b []mark) bool {
if len(a) != len(b) {
return false
}
for index := range a {
if a[index] != b[index] {
return false
}
}
return true
}
func appendInline(inlines []inline, next inline) []inline {
if last := len(inlines) - 1; last >= 0 && sameMarks(inlines[last].marks, next.marks) {
inlines[last].text += next.text
return inlines
}
return append(inlines, next)
}
func withoutCodeMarks(inlines []inline) []inline {
merged := make([]inline, 0, len(inlines))
for _, item := range inlines {
kept := make([]mark, 0, len(item.marks))
for _, m := range item.marks {
if m.kind != markCode {
kept = append(kept, m)
}
}
merged = appendInline(merged, inline{text: item.text, marks: kept})
}
return merged
}
@@ -0,0 +1,33 @@
package markdown
import (
domain "RenewCMS/internal/domain/article"
)
type Converter struct{}
var _ domain.BodyRenderer = &Converter{}
func NewConverter() *Converter {
return &Converter{}
}
func (c *Converter) Render(html string, format domain.BodyFormat) (string, error) {
switch format {
case domain.BodyFormatHTML:
return html, nil
case domain.BodyFormatMarkdown:
blocks, err := parseBlocks(html)
if err != nil {
return "", err
}
return renderMarkdown(blocks), nil
case domain.BodyFormatJSON:
blocks, err := parseBlocks(html)
if err != nil {
return "", err
}
return renderJSON(blocks)
}
return "", domain.ErrUnsupportedBodyFormat
}
@@ -0,0 +1,111 @@
package markdown
import (
"bytes"
"encoding/json"
"strings"
)
type jsonMark struct {
Type string `json:"type"`
Href string `json:"href,omitempty"`
}
type jsonText struct {
Type string `json:"type"`
Text string `json:"text"`
Marks []jsonMark `json:"marks"`
}
type jsonHeading struct {
Type string `json:"type"`
Level int `json:"level"`
Align string `json:"align,omitempty"`
Children []jsonText `json:"children"`
}
type jsonParagraph struct {
Type string `json:"type"`
Align string `json:"align,omitempty"`
Children []jsonText `json:"children"`
}
type jsonList struct {
Type string `json:"type"`
Ordered bool `json:"ordered"`
Items [][]any `json:"items"`
}
type jsonBlockquote struct {
Type string `json:"type"`
Children []any `json:"children"`
}
type jsonCode struct {
Type string `json:"type"`
Language string `json:"language"`
Text string `json:"text"`
}
type jsonRule struct {
Type string `json:"type"`
}
type jsonImage struct {
Type string `json:"type"`
Src string `json:"src"`
Alt string `json:"alt"`
}
func renderJSON(blocks []block) (string, error) {
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(toJSONBlocks(blocks)); err != nil {
return "", err
}
return strings.TrimSuffix(buffer.String(), "\n"), nil
}
func toJSONBlocks(blocks []block) []any {
result := make([]any, 0, len(blocks))
for _, b := range blocks {
result = append(result, toJSONBlock(b))
}
return result
}
func toJSONBlock(b block) any {
switch b.kind {
case kindHeading:
return jsonHeading{Type: "heading", Level: b.level, Align: b.align, Children: toJSONTexts(b.inlines)}
case kindParagraph:
return jsonParagraph{Type: "paragraph", Align: b.align, Children: toJSONTexts(b.inlines)}
case kindList:
items := make([][]any, 0, len(b.items))
for _, item := range b.items {
items = append(items, toJSONBlocks(item))
}
return jsonList{Type: "list", Ordered: b.ordered, Items: items}
case kindBlockquote:
return jsonBlockquote{Type: "blockquote", Children: toJSONBlocks(b.children)}
case kindCode:
return jsonCode{Type: "code", Language: b.language, Text: b.text}
case kindImage:
return jsonImage{Type: "image", Src: b.src, Alt: b.alt}
}
return jsonRule{Type: "hr"}
}
func toJSONTexts(inlines []inline) []jsonText {
plain := withoutCodeMarks(inlines)
texts := make([]jsonText, 0, len(plain))
for _, item := range plain {
marks := make([]jsonMark, 0, len(item.marks))
for _, m := range item.marks {
marks = append(marks, jsonMark{Type: m.kind, Href: m.href})
}
texts = append(texts, jsonText{Type: "text", Text: item.text, Marks: marks})
}
return texts
}
@@ -0,0 +1,213 @@
package markdown
import (
"regexp"
"strconv"
"strings"
)
const (
blockSeparator = "\n\n"
hardBreak = "\\\n"
bulletMarker = "- "
codeFence = "```"
alignOpen = `<div style="text-align: `
alignOpenEnd = `">`
alignClose = "</div>"
)
var textEscaper = strings.NewReplacer(
"\\", "\\\\", "*", "\\*", "_", "\\_", "`", "\\`", "[", "\\[", "]", "\\]", "<", "\\<",
)
var (
numberedLineStart = regexp.MustCompile(`^(\d+)([.)])(\s|$)`)
markerLineStart = regexp.MustCompile(`^(>|#{1,6}(\s|$)|[+-](\s|$|-)|=+\s*$)`)
)
var hrefEscaper = strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")
func renderMarkdown(blocks []block) string {
return renderBlocks(blocks, blockSeparator)
}
func renderBlocks(blocks []block, separator string) string {
parts := make([]string, 0, len(blocks))
for _, b := range blocks {
if rendered := renderBlock(b); rendered != "" {
parts = append(parts, rendered)
}
}
return strings.Join(parts, separator)
}
func renderBlock(b block) string {
rendered := renderPlainBlock(b)
if b.align == "" || rendered == "" {
return rendered
}
return alignOpen + b.align + alignOpenEnd + blockSeparator + rendered + blockSeparator + alignClose
}
func renderPlainBlock(b block) string {
switch b.kind {
case kindHeading:
return strings.Repeat("#", b.level) + " " + renderInlines(b.inlines)
case kindParagraph:
return escapeLineStarts(renderInlines(b.inlines))
case kindList:
return renderList(b)
case kindBlockquote:
return prefixLines(renderBlocks(b.children, blockSeparator), "> ", ">")
case kindCode:
return renderCode(b)
case kindImage:
return "![" + escapeAlt(b.alt) + "](" + hrefEscaper.Replace(b.src) + ")"
}
return "---"
}
func escapeLineStarts(text string) string {
lines := strings.Split(text, "\n")
for index, line := range lines {
line = numberedLineStart.ReplaceAllString(line, `$1\$2$3`)
if markerLineStart.MatchString(line) {
line = `\` + line
}
lines[index] = line
}
return strings.Join(lines, "\n")
}
func renderCode(b block) string {
fence := codeFence
for strings.Contains(b.text, fence) {
fence += "`"
}
return fence + b.language + "\n" + b.text + "\n" + fence
}
func escapeAlt(alt string) string {
return strings.NewReplacer("[", "\\[", "]", "\\]", "\n", " ").Replace(alt)
}
func renderList(b block) string {
lines := make([]string, 0, len(b.items))
for index, item := range b.items {
marker := bulletMarker
if b.ordered {
marker = strconv.Itoa(index+1) + ". "
}
content := renderBlocks(item, itemSeparator(item))
lines = append(lines, marker+indentContinuation(content, len(marker)))
}
return strings.Join(lines, "\n")
}
func itemSeparator(item []block) string {
textBlocks := 0
for _, b := range item {
if b.kind != kindList {
textBlocks++
}
}
if textBlocks > 1 {
return blockSeparator
}
return "\n"
}
func indentContinuation(content string, width int) string {
indent := strings.Repeat(" ", width)
lines := strings.Split(content, "\n")
for index := 1; index < len(lines); index++ {
if lines[index] != "" {
lines[index] = indent + lines[index]
}
}
return strings.Join(lines, "\n")
}
func prefixLines(content, prefix, emptyPrefix string) string {
lines := strings.Split(content, "\n")
for index, line := range lines {
if line == "" {
lines[index] = emptyPrefix
continue
}
lines[index] = prefix + line
}
return strings.Join(lines, "\n")
}
func renderInlines(inlines []inline) string {
var builder strings.Builder
for _, item := range inlines {
builder.WriteString(renderInline(item))
}
return builder.String()
}
func renderInline(item inline) string {
isCode := hasMarkKind(item.marks, markCode)
lines := strings.Split(item.text, "\n")
for index, line := range lines {
lines[index] = wrapMarks(item.marks, line, isCode)
}
return strings.Join(lines, hardBreak)
}
func hasMarkKind(marks []mark, kind string) bool {
for _, m := range marks {
if m.kind == kind {
return true
}
}
return false
}
func wrapMarks(marks []mark, text string, isCode bool) string {
core := strings.TrimSpace(text)
if core == "" {
return text
}
start := strings.Index(text, core)
leading, trailing := text[:start], text[start+len(core):]
var wrapped string
if isCode {
wrapped = inlineCode(core)
} else {
wrapped = escapeText(core)
}
for index := len(marks) - 1; index >= 0; index-- {
wrapped = applyMark(marks[index], wrapped)
}
return leading + wrapped + trailing
}
func inlineCode(text string) string {
fence := "`"
for strings.Contains(text, fence) {
fence += "`"
}
if strings.HasPrefix(text, "`") || strings.HasSuffix(text, "`") {
return fence + " " + text + " " + fence
}
return fence + text + fence
}
func applyMark(m mark, text string) string {
switch m.kind {
case markBold:
return "**" + text + "**"
case markItalic:
return "*" + text + "*"
case markLink:
return "[" + text + "](" + hrefEscaper.Replace(m.href) + ")"
}
return text
}
func escapeText(text string) string {
return textEscaper.Replace(text)
}
@@ -6,15 +6,17 @@ import (
type CreateArticleUseCase struct { type CreateArticleUseCase struct {
articleRepository article.Repository articleRepository article.Repository
bodyRenderer article.BodyRenderer
} }
type CreateArticleCommand struct { type CreateArticleCommand struct {
Title string Title string
Body string Body string
Format article.BodyFormat
} }
func NewCreateArticleUseCase(articleRepository article.Repository) *CreateArticleUseCase { func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *CreateArticleUseCase {
return &CreateArticleUseCase{articleRepository} return &CreateArticleUseCase{articleRepository, bodyRenderer}
} }
func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand) (article.Article, error) { func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand) (article.Article, error) {
@@ -22,9 +24,13 @@ func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand)
return article.Article{}, err return article.Article{}, err
} }
return g.articleRepository.Create(article.Article{ created, err := g.articleRepository.Create(article.Article{
Title: createArticle.Title, Title: createArticle.Title,
Body: createArticle.Body, Body: createArticle.Body,
IsOnline: false, IsOnline: false,
}) })
if err != nil {
return article.Article{}, err
}
return renderArticle(g.bodyRenderer, created, createArticle.Format)
} }
+11 -6
View File
@@ -4,13 +4,14 @@ import "RenewCMS/internal/domain/article"
type GetArticleUseCase struct { type GetArticleUseCase struct {
articleRepository article.Repository articleRepository article.Repository
bodyRenderer article.BodyRenderer
} }
func NewGetArticleUseCase(articleRepository article.Repository) *GetArticleUseCase { func NewGetArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *GetArticleUseCase {
return &GetArticleUseCase{articleRepository} return &GetArticleUseCase{articleRepository, bodyRenderer}
} }
func (u *GetArticleUseCase) GetOnlineArticle(id uint32) (article.Article, error) { func (u *GetArticleUseCase) GetOnlineArticle(id uint32, format article.BodyFormat) (article.Article, error) {
art, err := u.articleRepository.Get(id) art, err := u.articleRepository.Get(id)
if err != nil { if err != nil {
return article.Article{}, err return article.Article{}, err
@@ -20,11 +21,15 @@ func (u *GetArticleUseCase) GetOnlineArticle(id uint32) (article.Article, error)
return article.Article{}, article.ErrArticleNotFound return article.Article{}, article.ErrArticleNotFound
} }
return art, nil return renderArticle(u.bodyRenderer, art, format)
} }
func (u *GetArticleUseCase) GetArticle(id uint32) (article.Article, error) { func (u *GetArticleUseCase) GetArticle(id uint32, format article.BodyFormat) (article.Article, error) {
return u.articleRepository.Get(id) art, err := u.articleRepository.Get(id)
if err != nil {
return article.Article{}, err
}
return renderArticle(u.bodyRenderer, art, format)
} }
func (g *GetArticleUseCase) GetArticleByName(name string) (article.Article, error) { func (g *GetArticleUseCase) GetArticleByName(name string) (article.Article, error) {
@@ -4,19 +4,28 @@ import "RenewCMS/internal/domain/article"
type ListArticlesUseCase struct { type ListArticlesUseCase struct {
articleRepository article.Repository articleRepository article.Repository
bodyRenderer article.BodyRenderer
} }
func NewListArticlesUseCase(articleRepository article.Repository) *ListArticlesUseCase { func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *ListArticlesUseCase {
return &ListArticlesUseCase{articleRepository} return &ListArticlesUseCase{articleRepository, bodyRenderer}
} }
func (u *ListArticlesUseCase) ListOnlineArticles() ([]article.Article, error) { func (u *ListArticlesUseCase) ListOnlineArticles(format article.BodyFormat) ([]article.Article, error) {
online := true online := true
return u.articleRepository.FindByFilters(article.Filters{ articles, err := u.articleRepository.FindByFilters(article.Filters{
IsOnline: &online, IsOnline: &online,
}) })
if err != nil {
return nil, err
}
return renderArticles(u.bodyRenderer, articles, format)
} }
func (u *ListArticlesUseCase) ListAllArticles() ([]article.Article, error) { func (u *ListArticlesUseCase) ListAllArticles(format article.BodyFormat) ([]article.Article, error) {
return u.articleRepository.FindByFilters(article.Filters{}) articles, err := u.articleRepository.FindByFilters(article.Filters{})
if err != nil {
return nil, err
}
return renderArticles(u.bodyRenderer, articles, format)
} }
@@ -4,16 +4,25 @@ import "RenewCMS/internal/domain/article"
type UpdateArticleUseCase struct { type UpdateArticleUseCase struct {
articleRepository article.Repository articleRepository article.Repository
bodyRenderer article.BodyRenderer
} }
func NewUpdateArticleUseCase(articleRepository article.Repository) *UpdateArticleUseCase { func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository} return &UpdateArticleUseCase{articleRepository, bodyRenderer}
} }
func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string) (article.Article, error) { func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string, format article.BodyFormat) (article.Article, error) {
return g.articleRepository.UpdateBody(id, body) updated, err := g.articleRepository.UpdateBody(id, body)
if err != nil {
return article.Article{}, err
}
return renderArticle(g.bodyRenderer, updated, format)
} }
func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) { func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool, format article.BodyFormat) (article.Article, error) {
return g.articleRepository.UpdateIsOnline(id, isOnline) updated, err := g.articleRepository.UpdateIsOnline(id, isOnline)
if err != nil {
return article.Article{}, err
}
return renderArticle(g.bodyRenderer, updated, format)
} }
@@ -0,0 +1,24 @@
package useCases
import "RenewCMS/internal/domain/article"
func renderArticle(renderer article.BodyRenderer, art article.Article, format article.BodyFormat) (article.Article, error) {
body, err := renderer.Render(art.Body, format)
if err != nil {
return article.Article{}, err
}
art.Body = body
return art, nil
}
func renderArticles(renderer article.BodyRenderer, articles []article.Article, format article.BodyFormat) ([]article.Article, error) {
rendered := make([]article.Article, 0, len(articles))
for _, art := range articles {
renderedArticle, err := renderArticle(renderer, art, format)
if err != nil {
return nil, err
}
rendered = append(rendered, renderedArticle)
}
return rendered, nil
}
+361
View File
@@ -0,0 +1,361 @@
package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
type fakeBodyRenderer struct {
fail bool
calls int
}
var _ domainArticle.BodyRenderer = &fakeBodyRenderer{}
func (f *fakeBodyRenderer) Render(html string, format domainArticle.BodyFormat) (string, error) {
f.calls++
if f.fail {
return "", errors.New("render failed")
}
return html, nil
}
const (
bodyFormatArticleOneHTML = "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>"
bodyFormatArticleOneMD = "## Plan\n\nReplace the **old** deck."
bodyFormatArticleOneJSON = `[{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}]`
)
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer),
useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, renderer),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle)
r.Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
})
})
})
return r
}
func seedBodyFormatRepo() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", Body: bodyFormatArticleOneHTML, IsOnline: true}
repo.articles[2] = domainArticle.Article{ID: 2, Title: "Draft: Kitchen Remodel", Body: "<p>Draft</p>", IsOnline: false}
repo.articles[3] = domainArticle.Article{ID: 3, Title: "Second article", Body: "<p>Second</p>", IsOnline: true}
repo.nextID = 3
return repo
}
type bodyFormatResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body json.RawMessage `json:"body"`
BodyFormat string `json:"body_format"`
IsOnline bool `json:"is_online"`
}
func serveBodyFormat(router http.Handler, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func decodeBodyFormatArticle(t *testing.T, rec *httptest.ResponseRecorder, expectedStatus int) bodyFormatResponse {
t.Helper()
if rec.Code != expectedStatus {
t.Fatalf("expected status %d, got %d, body %q", expectedStatus, rec.Code, rec.Body.String())
}
var got bodyFormatResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
return got
}
func assertBodyFormat(t *testing.T, got bodyFormatResponse, format, expectedBody string) {
t.Helper()
if got.BodyFormat != format {
t.Errorf("expected body_format %q, got %q", format, got.BodyFormat)
}
if format == "json" {
assertSameJSON(t, string(got.Body), expectedBody)
return
}
var text string
if err := json.Unmarshal(got.Body, &text); err != nil {
t.Fatalf("body is not a JSON string: %s", got.Body)
}
if text != expectedBody {
t.Errorf("expected body %q, got %q", expectedBody, text)
}
}
func assertSameJSON(t *testing.T, actual, expected string) {
t.Helper()
var actualValue, expectedValue any
if err := json.Unmarshal([]byte(actual), &actualValue); err != nil {
t.Fatalf("actual is not JSON: %s", actual)
}
if err := json.Unmarshal([]byte(expected), &expectedValue); err != nil {
t.Fatalf("expected is not JSON: %s", expected)
}
actualBytes, _ := json.Marshal(actualValue)
expectedBytes, _ := json.Marshal(expectedValue)
if string(actualBytes) != string(expectedBytes) {
t.Errorf("expected %s, got %s", expectedBytes, actualBytes)
}
}
func TestArticleBodyFormatHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
newRouter := func(repo *mockArticleRepo, users *mockUserRepo, renderer domainArticle.BodyRenderer) *chi.Mux {
return newBodyFormatRouter(repo, users, renderer, token)
}
realRouter := func() (*chi.Mux, *mockArticleRepo) {
repo := seedBodyFormatRepo()
return newRouter(repo, newPublicArticleReadUserRepo(true), markdown.NewConverter()), repo
}
verifiedCookie := mintPublicArticleReadCookie(t, token, 1)
t.Run("Single read negotiates the body format", func(t *testing.T) {
examples := []struct {
query, format, body string
}{
{"", "markdown", bodyFormatArticleOneMD},
{"?format=", "markdown", bodyFormatArticleOneMD},
{"?format=markdown", "markdown", bodyFormatArticleOneMD},
{"?format=MarkDown", "markdown", bodyFormatArticleOneMD},
{"?format=html", "html", bodyFormatArticleOneHTML},
{"?format=HTML", "html", bodyFormatArticleOneHTML},
{"?format=json", "json", bodyFormatArticleOneJSON},
{"?format=JSON", "json", bodyFormatArticleOneJSON},
}
for _, example := range examples {
router, _ := realRouter()
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1"+example.query, "", nil)
got := decodeBodyFormatArticle(t, rec, http.StatusOK)
assertBodyFormat(t, got, example.format, example.body)
}
})
t.Run("List read negotiates every element", func(t *testing.T) {
router, _ := realRouter()
expectations := []struct {
query, format, secondBody string
}{
{"", "markdown", "Second"},
{"?format=html", "html", "<p>Second</p>"},
{"?format=json", "json", `[{"type":"paragraph","children":[{"type":"text","text":"Second","marks":[]}]}]`},
}
for _, expectation := range expectations {
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/"+expectation.query, "", nil)
var items []bodyFormatResponse
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("failed to decode list: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected 2 online articles, got %d", len(items))
}
for _, item := range items {
if item.BodyFormat != expectation.format {
t.Errorf("expected body_format %q, got %q", expectation.format, item.BodyFormat)
}
if item.ID == 3 {
assertBodyFormat(t, item, expectation.format, expectation.secondBody)
}
}
}
})
t.Run("An unsupported format is rejected", func(t *testing.T) {
for _, path := range []string{"/v1/article/1?format=pdf", "/v1/article/?format=xml", "/v1/article/9999?format=pdf", "/v1/article/1?format=md"} {
renderer := &fakeBodyRenderer{}
router := newRouter(seedBodyFormatRepo(), newMockUserRepo(), renderer)
rec := serveBodyFormat(router, http.MethodGet, path, "", nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s: expected status 400, got %d", path, rec.Code)
}
if rec.Body.String() != apperr.ArticleBodyFormatErrorMessage+"\n" {
t.Errorf("%s: unexpected body %q", path, rec.Body.String())
}
if renderer.calls != 0 {
t.Errorf("%s: renderer must not be called", path)
}
}
})
t.Run("An invalid id is reported before an invalid format", func(t *testing.T) {
router, _ := realRouter()
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/abc?format=pdf", "", nil)
if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" {
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
}
})
t.Run("The first repeated format parameter wins", func(t *testing.T) {
router, _ := realRouter()
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1?format=html&format=json", "", nil)
got := decodeBodyFormatArticle(t, rec, http.StatusOK)
assertBodyFormat(t, got, "html", bodyFormatArticleOneHTML)
})
t.Run("Offline articles stay hidden whatever the format", func(t *testing.T) {
unverified := newRouter(seedBodyFormatRepo(), newMockUserRepo(), markdown.NewConverter())
rec := serveBodyFormat(unverified, http.MethodGet, "/v1/article/2?format=json", "", nil)
if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
}
})
t.Run("Authenticated routes negotiate the response body", func(t *testing.T) {
paragraph := func(text string) string {
return `[{"type":"paragraph","children":[{"type":"text","text":"` + text + `","marks":[]}]}]`
}
type expectedBodies map[string]string
bodiesA := expectedBodies{"markdown": "A", "html": "<p>A</p>", "json": paragraph("A")}
bodiesB := expectedBodies{"markdown": "B", "html": "<p>B</p>", "json": paragraph("B")}
bodiesOne := expectedBodies{"markdown": bodyFormatArticleOneMD, "html": bodyFormatArticleOneHTML, "json": bodyFormatArticleOneJSON}
examples := []struct {
method, path, body string
status int
format string
bodies expectedBodies
}{
{http.MethodPost, "/v1/article/", `{"title":"Deck","body":"<p>A</p>"}`, 201, "markdown", bodiesA},
{http.MethodPost, "/v1/article/?format=html", `{"title":"Deck","body":"<p>A</p>"}`, 201, "html", bodiesA},
{http.MethodPost, "/v1/article/?format=json", `{"title":"Deck","body":"<p>A</p>"}`, 201, "json", bodiesA},
{http.MethodPatch, "/v1/article/1", `{"body":"<p>B</p>"}`, 200, "markdown", bodiesB},
{http.MethodPatch, "/v1/article/1?format=json", `{"body":"<p>B</p>"}`, 200, "json", bodiesB},
{http.MethodPatch, "/v1/article/1/publish", "", 200, "markdown", bodiesOne},
{http.MethodPatch, "/v1/article/1/unpublish?format=html", "", 200, "html", bodiesOne},
}
for _, example := range examples {
router, repo := realRouter()
rec := serveBodyFormat(router, example.method, example.path, example.body, verifiedCookie)
got := decodeBodyFormatArticle(t, rec, example.status)
assertBodyFormat(t, got, example.format, example.bodies[example.format])
stored := repo.articles[got.ID].Body
if example.bodies["html"] != stored {
t.Errorf("%s %s: stored body must stay HTML %q, got %q", example.method, example.path, example.bodies["html"], stored)
}
}
})
t.Run("An unsupported format on a write route changes nothing", func(t *testing.T) {
requests := []struct {
method, path, body string
}{
{http.MethodPatch, "/v1/article/1?format=pdf", `{"body":"<p>B</p>"}`},
{http.MethodPost, "/v1/article/?format=pdf", `{"title":"Deck","body":"<p>A</p>"}`},
{http.MethodPatch, "/v1/article/1/publish?format=pdf", ""},
{http.MethodPatch, "/v1/article/1/unpublish?format=pdf", ""},
}
for _, request := range requests {
repo := seedBodyFormatRepo()
renderer := &fakeBodyRenderer{}
router := newRouter(repo, newPublicArticleReadUserRepo(true), renderer)
rec := serveBodyFormat(router, request.method, request.path, request.body, verifiedCookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s %s: expected 400, got %d", request.method, request.path, rec.Code)
}
if rec.Body.String() != apperr.ArticleBodyFormatErrorMessage+"\n" {
t.Errorf("%s %s: unexpected body %q", request.method, request.path, rec.Body.String())
}
if renderer.calls != 0 {
t.Errorf("%s %s: renderer must not be called", request.method, request.path)
}
if repo.articles[1].Body != bodyFormatArticleOneHTML {
t.Errorf("%s %s: stored body changed to %q", request.method, request.path, repo.articles[1].Body)
}
if !repo.articles[1].IsOnline {
t.Errorf("%s %s: is_online changed", request.method, request.path)
}
if repo.createCalled {
t.Errorf("%s %s: no article may be created", request.method, request.path)
}
}
})
t.Run("The authenticated list includes offline articles in the negotiated format", func(t *testing.T) {
router, _ := realRouter()
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/?format=json", "", verifiedCookie)
var items []bodyFormatResponse
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("failed to decode list: %v", err)
}
seen := map[uint32]bool{}
for _, item := range items {
seen[item.ID] = true
if item.BodyFormat != "json" {
t.Errorf("expected json, got %q", item.BodyFormat)
}
}
if !seen[1] || !seen[2] {
t.Errorf("expected articles 1 and 2, got %v", seen)
}
})
t.Run("A converter failure answers 500", func(t *testing.T) {
router := newRouter(seedBodyFormatRepo(), newMockUserRepo(), &fakeBodyRenderer{fail: true})
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1", "", nil)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
}
})
}
func TestParseBodyFormat(t *testing.T) {
valid := map[string]domainArticle.BodyFormat{
"": domainArticle.BodyFormatMarkdown, "markdown": domainArticle.BodyFormatMarkdown, "MARKDOWN": domainArticle.BodyFormatMarkdown,
"html": domainArticle.BodyFormatHTML, "Html": domainArticle.BodyFormatHTML, "json": domainArticle.BodyFormatJSON,
}
for input, expected := range valid {
got, err := domainArticle.ParseBodyFormat(input)
if err != nil || got != expected {
t.Errorf("ParseBodyFormat(%q) = %q, %v", input, got, err)
}
}
for _, input := range []string{"pdf", "md", " html"} {
if _, err := domainArticle.ParseBodyFormat(input); !errors.Is(err, domainArticle.ErrUnsupportedBodyFormat) {
t.Errorf("ParseBodyFormat(%q) should be unsupported, got %v", input, err)
}
}
}
+4 -4
View File
@@ -23,11 +23,11 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+4 -4
View File
@@ -148,7 +148,7 @@ var _ article.Repository = &mockArticleRepo{}
func TestCreateArticle(t *testing.T) { func TestCreateArticle(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo) uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
t.Run("Valid Title", func(t *testing.T) { t.Run("Valid Title", func(t *testing.T) {
cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"} cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"}
@@ -174,17 +174,17 @@ func TestGetOnlineArticle(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, IsOnline: true} repo.articles[1] = article.Article{ID: 1, IsOnline: true}
repo.articles[2] = article.Article{ID: 2, IsOnline: false} repo.articles[2] = article.Article{ID: 2, IsOnline: false}
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
t.Run("Article Online", func(t *testing.T) { t.Run("Article Online", func(t *testing.T) {
_, err := uc.GetOnlineArticle(1) _, err := uc.GetOnlineArticle(1, article.BodyFormatHTML)
if err != nil { if err != nil {
t.Errorf("Expected article to be found") t.Errorf("Expected article to be found")
} }
}) })
t.Run("Article Offline", func(t *testing.T) { t.Run("Article Offline", func(t *testing.T) {
_, err := uc.GetOnlineArticle(2) _, err := uc.GetOnlineArticle(2, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("Offline article should return not found") t.Errorf("Offline article should return not found")
} }
+5 -5
View File
@@ -26,11 +26,11 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(repo), useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(repo), useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(repo), useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(repo), useCases.NewDeleteArticleUseCase(repo),
useCases.NewUpdateArticleUseCase(repo), useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
@@ -87,7 +87,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) { t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo) uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
title := strings.Repeat("\U0001F600", tc.runeCount) title := strings.Repeat("\U0001F600", tc.runeCount)
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"}) result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
+4 -4
View File
@@ -20,11 +20,11 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+78
View File
@@ -0,0 +1,78 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/markdown"
"encoding/json"
"reflect"
"strings"
"testing"
)
const hostileBody = `<h1>Title</h1><p>Intro <strong>bold</strong><script>alert(1)</script></p><style>p{}</style><iframe src="x"></iframe><ul><li><p>one</p><ul><li><p>deep</p></li></ul></li></ul><div><span style="color:red">kept</span></div><img src="/i.png" alt="I"><pre><code>a<script>z</script></code></pre>`
func TestConverterNeverLeaksRawHtml(t *testing.T) {
converter := markdown.NewConverter()
for _, format := range []domainArticle.BodyFormat{domainArticle.BodyFormatMarkdown, domainArticle.BodyFormatJSON} {
got, err := converter.Render(hostileBody, format)
if err != nil {
t.Fatalf("%s: unexpected error: %v", format, err)
}
for _, forbidden := range []string{"<script", "<style", "<iframe", "<span", "alert(1)", "p{}"} {
if strings.Contains(got, forbidden) {
t.Errorf("%s output leaks %q: %s", format, forbidden, got)
}
}
}
}
func TestJsonConverterCombinedDocument(t *testing.T) {
converter := markdown.NewConverter()
got, err := converter.Render(hostileBody, domainArticle.BodyFormatJSON)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var blocks []map[string]any
if err := json.Unmarshal([]byte(got), &blocks); err != nil {
t.Fatalf("body is not a JSON array: %v", err)
}
var types []string
for _, b := range blocks {
types = append(types, b["type"].(string))
}
expected := []string{"heading", "paragraph", "list", "paragraph", "image", "code"}
if !reflect.DeepEqual(types, expected) {
t.Errorf("expected block types %v, got %v", expected, types)
}
}
func TestJsonConverterEmptyBodyIsArray(t *testing.T) {
converter := markdown.NewConverter()
for _, source := range []string{"", " ", "<script>x</script>"} {
got, err := converter.Render(source, domainArticle.BodyFormatJSON)
if err != nil || got != "[]" {
t.Errorf("%q: expected [] got %q, %v", source, got, err)
}
}
}
func TestMarkdownConverterCodeBlockText(t *testing.T) {
converter := markdown.NewConverter()
examples := []struct{ name, html, markdown string }{
{"text node kept", "<pre><code>x</code></pre>", "```\nx\n```"},
{"skipped element dropped", "<pre><code>a<script>z</script>b</code></pre>", "```\nab\n```"},
{"style dropped", "<pre><code>a<style>z</style></code></pre>", "```\na\n```"},
{"break becomes newline", "<pre><code>a<br>b</code></pre>", "```\na\nb\n```"},
{"nested element text kept", "<pre><code>a<span>b</span>c</code></pre>", "```\nabc\n```"},
{"trailing newline trimmed", "<pre><code>a\n</code></pre>", "```\na\n```"},
{"fence grows around backticks", "<pre><code>```\nx</code></pre>", "````\n```\nx\n````"},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
got, err := converter.Render(example.html, domainArticle.BodyFormatMarkdown)
if err != nil || got != example.markdown {
t.Errorf("expected %q, got %q, %v", example.markdown, got, err)
}
})
}
}
+4 -4
View File
@@ -24,11 +24,11 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+58
View File
@@ -0,0 +1,58 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/markdown"
"testing"
)
func TestJsonBlocksConverter(t *testing.T) {
converter := markdown.NewConverter()
paragraphA := `{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}`
listOfA := `[{"type":"list","ordered":false,"items":[[` + paragraphA + `]]}]`
examples := []struct {
name, html, json string
}{
{"h1", "<h1>T</h1>", `[{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}]`},
{"h6", "<h6>S</h6>", `[{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}]`},
{"paragraph", "<p>plain</p>", `[{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}]`},
{"empty paragraph", "<p></p>", `[{"type":"paragraph","children":[]}]`},
{"bold", "<p><strong>b</strong></p>", `[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}]`},
{"italic", "<p><em>i</em></p>", `[{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}]`},
{"link", `<p><a href="https://example.com/x">l</a></p>`, `[{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}]`},
{"nested marks outermost first", `<p><a href="/a"><strong><em>x</em></strong></a></p>`, `[{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}]`},
{"line break", "<p>a<br>b</p>", `[{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}]`},
{"bullet list", "<ul><li><p>a</p></li><li><p>b</p></li></ul>", `[{"type":"list","ordered":false,"items":[[` + paragraphA + `],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}]`},
{"ordered list", "<ol><li><p>a</p></li></ol>", `[{"type":"list","ordered":true,"items":[[` + paragraphA + `]]}]`},
{"bare list item", "<ul><li>a</li></ul>", listOfA},
{"nested list", "<ul><li><p>a</p><ul><li><p>n</p></li></ul></li></ul>", `[{"type":"list","ordered":false,"items":[[` + paragraphA + `,{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"n","marks":[]}]}]]}]]}]`},
{"data-indent ignored", `<ul><li data-indent="2"><p>a</p></li></ul>`, listOfA},
{"blockquote", "<blockquote><p>q</p></blockquote>", `[{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}]`},
{"code with language", `<pre><code class="language-go">x := 1</code></pre>`, `[{"type":"code","language":"go","text":"x := 1"}]`},
{"code without language", "<pre><code>raw</code></pre>", `[{"type":"code","language":"","text":"raw"}]`},
{"rule", "<hr>", `[{"type":"hr"}]`},
{"image with alt", `<img src="/static/uploadedImages/a.png" alt="A">`, `[{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}]`},
{"image without alt", `<img src="/static/uploadedImages/a.png">`, `[{"type":"image","src":"/static/uploadedImages/a.png","alt":""}]`},
{"image splits paragraph", `<p>x<img src="/i.png" alt="I">y</p>`, `[{"type":"paragraph","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","children":[{"type":"text","text":"y","marks":[]}]}]`},
{"span stripped", `<p>x<span style="color:red">y</span></p>`, `[{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}]`},
{"inline code degrades", "<p>use <code>go test</code></p>", `[{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}]`},
{"script and style removed", "<p>ok</p><script>alert(1)</script><style>p{}</style>", `[{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}]`},
{"empty html", "", `[]`},
{"centered paragraph", `<p style="text-align: center">a</p>`, `[{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}]`},
{"right aligned heading", `<h2 style="text-align:right">T</h2>`, `[{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}]`},
{"justified paragraph", `<p style="color:red; text-align: JUSTIFY">a</p>`, `[{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}]`},
{"left alignment omitted", `<p style="text-align: left">a</p>`, `[` + paragraphA + `]`},
{"alignment skips the image inside a paragraph", `<p style="text-align: center">x<img src="/i.png" alt="I">y</p>`, `[{"type":"paragraph","align":"center","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","align":"center","children":[{"type":"text","text":"y","marks":[]}]}]`},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
got, err := converter.Render(example.html, domainArticle.BodyFormatJSON)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != example.json {
t.Errorf("expected %s, got %s", example.json, got)
}
})
}
}
+89
View File
@@ -0,0 +1,89 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/markdown"
"errors"
"testing"
)
func TestMarkdownConverterFidelity(t *testing.T) {
converter := markdown.NewConverter()
examples := []struct {
name, html, markdown string
}{
{"headings", "<h1>T</h1><h3>S</h3>", "# T\n\n### S"},
{"bold and italic", "<p><strong>b</strong> <em>i</em></p>", "**b** *i*"},
{"link", `<p><a href="https://example.com/x">link</a></p>`, "[link](https://example.com/x)"},
{"bullet list", "<ul><li><p>a</p></li><li><p>b</p></li></ul>", "- a\n- b"},
{"ordered list", "<ol><li><p>a</p></li><li><p>b</p></li></ol>", "1. a\n2. b"},
{"data-indent dropped", `<ul><li data-indent="2"><p>a</p></li></ul>`, "- a"},
{"nested list", "<ul><li><p>a</p><ul><li><p>n</p></li></ul></li></ul>", "- a\n - n"},
{"blockquote", "<blockquote><p>q</p></blockquote>", "> q"},
{"code block", `<pre><code class="language-go">x := 1</code></pre>`, "```go\nx := 1\n```"},
{"rule", "<hr>", "---"},
{"image", `<img src="/static/uploadedImages/a.png" alt="A">`, "![A](/static/uploadedImages/a.png)"},
{"script and style removed", "<p>ok</p><script>alert(1)</script><style>p{}</style>", "ok"},
{"unsupported span stripped", `<p>x<span style="color:red">y</span></p>`, "xy"},
{"inline code", "<p>use <code>go test</code></p>", "use `go test`"},
{"empty body", "", ""},
{"line break", "<p>a<br>b</p>", "a\\\nb"},
{"literal angle bracket is escaped", "<p>&lt;script&gt;</p>", "\\<script>"},
{"javascript link dropped", `<p><a href="javascript:alert(1)">x</a></p>`, "x"},
{"bold keeps outer spaces", "<p>a<strong> b </strong>c</p>", "a **b** c"},
{"loose list item", "<ul><li><p>a</p><p>b</p></li></ul>", "- a\n\n b"},
{"inline code with edge backtick", "<p><code>`x</code></p>", "`` `x ``"},
{"code block keeps br and drops script", "<pre><code>a<br>b<script>z</script></code></pre>", "```\na\nb\n```"},
{"adjacent same marks merge", "<p><strong>a</strong><strong>b</strong></p>", "**ab**"},
{"adjacent different marks stay apart", "<p><strong>a</strong><em>b</em></p>", "**a***b*"},
{"two paragraphs", "<p>a</p><p>b</p>", "a\n\nb"},
{"centered paragraph", `<p style="text-align: center">hi <strong>b</strong></p>`, "<div style=\"text-align: center\">\n\nhi **b**\n\n</div>"},
{"right aligned heading", `<h2 style="text-align:right">T</h2>`, "<div style=\"text-align: right\">\n\n## T\n\n</div>"},
{"justified among other styles", `<p style="color:red; TEXT-ALIGN: Justify">a</p>`, "<div style=\"text-align: justify\">\n\na\n\n</div>"},
{"tiptap style with trailing semicolon", `<p style="text-align: center;">a</p>`, "<div style=\"text-align: center\">\n\na\n\n</div>"},
{"tiptap explicit left", `<p style="text-align: left;">a</p>`, "a"},
{"left alignment is the default", `<p style="text-align: left">a</p>`, "a"},
{"unknown alignment ignored", `<p style="text-align: start">a</p>`, "a"},
{"style without value ignored", `<p style="text-align">a</p>`, "a"},
{"empty aligned paragraph", `<p style="text-align: center"></p>`, ""},
{"aligned paragraph in list item", `<ul><li><p style="text-align: center">a</p></li></ul>`, "- <div style=\"text-align: center\">\n\n a\n\n </div>"},
{"aligned paragraph in blockquote", `<blockquote><p style="text-align: center">q</p></blockquote>`, "> <div style=\"text-align: center\">\n>\n> q\n>\n> </div>"},
{"alignment skips the image inside a paragraph", `<p style="text-align: center">x<img src="/i.png" alt="I">y</p>`, "<div style=\"text-align: center\">\n\nx\n\n</div>\n\n![I](/i.png)\n\n<div style=\"text-align: center\">\n\ny\n\n</div>"},
{"text characters are escaped", "<p>a * b _c_ `d` [e]</p>", "a \\* b \\_c\\_ \\`d\\` \\[e\\]"},
{"leading hash is escaped", "<p># not a heading</p>", "\\# not a heading"},
{"hash without space is kept", "<p>#tag</p>", "#tag"},
{"leading dash is escaped", "<p>- not a list</p>", "\\- not a list"},
{"leading plus is escaped", "<p>+ not a list</p>", "\\+ not a list"},
{"leading quote marker is escaped", "<p>&gt; not a quote</p>", "\\> not a quote"},
{"leading number is escaped", "<p>1. not a list</p>", "1\\. not a list"},
{"leading number with parenthesis is escaped", "<p>2) not a list</p>", "2\\) not a list"},
{"leading rule is escaped", "<p>---</p>", "\\---"},
{"leading equals is escaped", "<p>==</p>", "\\=="},
{"inner dash is kept", "<p>a - b</p>", "a - b"},
{"marker after a line break is escaped", "<p>a<br>- b</p>", "a\\\n\\- b"},
{"combined document", `<h1>Title</h1><p style="text-align: center">Intro <strong>bold</strong> and <em>it</em></p><ul><li><p>one</p></li><li><p>two</p></li></ul><img src="/static/uploadedImages/a.png" alt="A"><pre><code class="language-go">x := 1</code></pre><blockquote><p>quote</p></blockquote><hr>`, "# Title\n\n<div style=\"text-align: center\">\n\nIntro **bold** and *it*\n\n</div>\n\n- one\n- two\n\n![A](/static/uploadedImages/a.png)\n\n```go\nx := 1\n```\n\n> quote\n\n---"},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
got, err := converter.Render(example.html, domainArticle.BodyFormatMarkdown)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != example.markdown {
t.Errorf("expected %q, got %q", example.markdown, got)
}
})
}
}
func TestMarkdownConverterHtmlAndUnknownFormat(t *testing.T) {
converter := markdown.NewConverter()
html := "<p>a</p><script>x</script>"
got, err := converter.Render(html, domainArticle.BodyFormatHTML)
if err != nil || got != html {
t.Errorf("html must pass through unchanged, got %q, %v", got, err)
}
if _, err := converter.Render(html, domainArticle.BodyFormat("pdf")); !errors.Is(err, domainArticle.ErrUnsupportedBodyFormat) {
t.Errorf("expected ErrUnsupportedBodyFormat, got %v", err)
}
}
+4 -4
View File
@@ -52,10 +52,10 @@ func newMediaEnv(t *testing.T) *mediaEnv {
container := &api.UseCases{ container := &api.UseCases{
Token: env.token, Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles), CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles), GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles), ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
GetUserUseCase: useCases.NewGetUserUseCase(env.users), GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images), CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
+4 -4
View File
@@ -126,11 +126,11 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
+4 -4
View File
@@ -42,11 +42,11 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+4 -4
View File
@@ -24,11 +24,11 @@ import (
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+4 -4
View File
@@ -132,10 +132,10 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
return &api.UseCases{ return &api.UseCases{
Token: token, Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo), CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo), GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"), CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
+4 -4
View File
@@ -23,11 +23,11 @@ import (
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux { func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
+30 -30
View File
@@ -29,7 +29,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) { t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo) uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
title := strings.Repeat("a", tc.length) title := strings.Repeat("a", tc.length)
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"}) result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
@@ -62,7 +62,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) { func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.fail.failNext("Create") repo.fail.failNext("Create")
uc := useCases.NewCreateArticleUseCase(repo) uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"}) _, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"})
@@ -84,8 +84,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetArticle_1", func(t *testing.T) { t.Run("GetArticle_1", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticle(1) result, err := uc.GetArticle(1, article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -96,8 +96,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetArticle_2", func(t *testing.T) { t.Run("GetArticle_2", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticle(2) result, err := uc.GetArticle(2, article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -108,8 +108,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetArticle_9", func(t *testing.T) { t.Run("GetArticle_9", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetArticle(9) _, err := uc.GetArticle(9, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
} }
@@ -117,8 +117,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetOnlineArticle_1", func(t *testing.T) { t.Run("GetOnlineArticle_1", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetOnlineArticle(1) result, err := uc.GetOnlineArticle(1, article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -129,8 +129,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetOnlineArticle_2", func(t *testing.T) { t.Run("GetOnlineArticle_2", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetOnlineArticle(2) _, err := uc.GetOnlineArticle(2, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
} }
@@ -138,8 +138,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetOnlineArticle_9", func(t *testing.T) { t.Run("GetOnlineArticle_9", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetOnlineArticle(9) _, err := uc.GetOnlineArticle(9, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
} }
@@ -155,7 +155,7 @@ func TestGetArticleByNameDelegatesVerbatim(t *testing.T) {
t.Run("Backyard_Renovation_Update", func(t *testing.T) { t.Run("Backyard_Renovation_Update", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticleByName("Backyard Renovation Update") result, err := uc.GetArticleByName("Backyard Renovation Update")
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
@@ -167,7 +167,7 @@ func TestGetArticleByNameDelegatesVerbatim(t *testing.T) {
t.Run("Unknown_Title", func(t *testing.T) { t.Run("Unknown_Title", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetArticleByName("Unknown Title") _, err := uc.GetArticleByName("Unknown Title")
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
@@ -198,8 +198,8 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
t.Run("ListOnlineArticles", func(t *testing.T) { t.Run("ListOnlineArticles", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo) uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListOnlineArticles() result, err := uc.ListOnlineArticles(article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -208,8 +208,8 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
t.Run("ListAllArticles", func(t *testing.T) { t.Run("ListAllArticles", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo) uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListAllArticles() result, err := uc.ListAllArticles(article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -219,9 +219,9 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
func TestListOnlineArticlesEmptyRepositoryReturnsEmptySlice(t *testing.T) { func TestListOnlineArticlesEmptyRepositoryReturnsEmptySlice(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
uc := useCases.NewListArticlesUseCase(repo) uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListOnlineArticles() result, err := uc.ListOnlineArticles(article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -242,8 +242,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id1", func(t *testing.T) { t.Run("UpdateBody_id1", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.UpdateBody(1, "New body") result, err := uc.UpdateBody(1, "New body", article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -254,8 +254,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id9", func(t *testing.T) { t.Run("UpdateBody_id9", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.UpdateBody(9, "New body") _, err := uc.UpdateBody(9, "New body", article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
} }
@@ -263,8 +263,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id1", func(t *testing.T) { t.Run("UpdateIsOnline_id1", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.UpdateIsOnline(1, true) result, err := uc.UpdateIsOnline(1, true, article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -275,8 +275,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id9", func(t *testing.T) { t.Run("UpdateIsOnline_id9", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.UpdateIsOnline(9, true) _, err := uc.UpdateIsOnline(9, true, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
} }
+4 -4
View File
@@ -21,11 +21,11 @@ import (
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+4 -4
View File
@@ -213,11 +213,11 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+3
View File
@@ -10,6 +10,7 @@
"@tiptap/core": "3.31.3", "@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3", "@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3", "@tiptap/extension-image": "3.31.3",
"@tiptap/extension-text-align": "3.31.3",
"@tiptap/pm": "3.31.3", "@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3", "@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0", "@vueuse/core": "14.4.0",
@@ -425,6 +426,8 @@
"@tiptap/extension-text": ["@tiptap/extension-text@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg=="], "@tiptap/extension-text": ["@tiptap/extension-text@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg=="],
"@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA=="],
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw=="], "@tiptap/extension-underline": ["@tiptap/extension-underline@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw=="],
"@tiptap/extensions": ["@tiptap/extensions@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA=="], "@tiptap/extensions": ["@tiptap/extensions@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA=="],
+1
View File
@@ -21,6 +21,7 @@
"@tiptap/core": "3.31.3", "@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3", "@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3", "@tiptap/extension-image": "3.31.3",
"@tiptap/extension-text-align": "3.31.3",
"@tiptap/pm": "3.31.3", "@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3", "@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0", "@vueuse/core": "14.4.0",
+3 -1
View File
@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { en, fr } from '@nuxt/ui/locale'
import { locale } from '@/i18n'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { toastsAboveBulkBar } from '@/composables/useToastOffset' import { toastsAboveBulkBar } from '@/composables/useToastOffset'
@@ -11,7 +13,7 @@ const toaster = computed(() => ({
</script> </script>
<template> <template>
<UApp class="isolate" :toaster="toaster"> <UApp class="isolate" :toaster="toaster" :locale="locale === 'fr' ? fr : en">
<RouterView /> <RouterView />
</UApp> </UApp>
</template> </template>
+66 -27
View File
@@ -1,10 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { locale, t } from '@/i18n'
import { ref, useId } from 'vue'
import type { ColorModePreference } from '@/composables/useColorMode' import type { ColorModePreference } from '@/composables/useColorMode'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu' import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
import { chooseLanguage, languageOptions } from '@/composables/useLanguage'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"]' const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemradio"]'
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] text-highlighted' const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] font-normal text-highlighted'
const SECTION_LABEL_CLASS =
'px-4 pt-2 pb-0.5 text-[0.6875rem] font-medium uppercase tracking-[0.04em] text-muted'
const props = defineProps<{ const props = defineProps<{
username: string username: string
@@ -26,6 +31,8 @@ const {
goToProfile: navigateToProfile, goToProfile: navigateToProfile,
} = useAccountMenu(() => props) } = useAccountMenu(() => props)
const menu = ref<HTMLElement | null>(null) const menu = ref<HTMLElement | null>(null)
const themeLabelId = useId()
const languageLabelId = useId()
function goToProfile() { function goToProfile() {
open.value = false open.value = false
@@ -34,7 +41,6 @@ function goToProfile() {
function chooseColorMode(value: ColorModePreference) { function chooseColorMode(value: ColorModePreference) {
colorMode.preference = value colorMode.preference = value
open.value = false
} }
function logOut() { function logOut() {
@@ -68,8 +74,8 @@ function moveFocus(event: KeyboardEvent) {
<USlideover <USlideover
v-model:open="open" v-model:open="open"
side="bottom" side="bottom"
title="Account" :title="t('Account')"
description="Your account menu" :description="t('Your account menu')"
:close="false" :close="false"
:ui="{ :ui="{
content: 'max-h-none', content: 'max-h-none',
@@ -78,7 +84,13 @@ function moveFocus(event: KeyboardEvent) {
}" }"
> >
<template #body> <template #body>
<div ref="menu" role="menu" aria-label="Account" class="pt-2 pb-6" @keydown="moveFocus"> <div
ref="menu"
role="menu"
:aria-label="t('Account')"
class="pt-2 pb-6"
@keydown="moveFocus"
>
<div class="mx-auto mb-2 h-1 w-9 bg-accented" aria-hidden="true" /> <div class="mx-auto mb-2 h-1 w-9 bg-accented" aria-hidden="true" />
<div class="mb-1 flex items-center gap-3 border-b border-default px-4 pt-2 pb-3.5"> <div class="mb-1 flex items-center gap-3 border-b border-default px-4 pt-2 pb-3.5">
<UAvatar <UAvatar
@@ -86,7 +98,7 @@ function moveFocus(event: KeyboardEvent) {
:text="initials" :text="initials"
:ui="{ :ui="{
root: 'size-9 bg-secondary-300', root: 'size-9 bg-secondary-300',
fallback: 'text-[0.8125rem] font-semibold text-primary-900', fallback: 'text-[0.8125rem] pt-0.5 font-semibold text-primary-900',
}" }"
/> />
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
@@ -107,28 +119,55 @@ function moveFocus(event: KeyboardEvent) {
:ui="{ leadingIcon: 'size-4.5' }" :ui="{ leadingIcon: 'size-4.5' }"
@click="goToProfile" @click="goToProfile"
> >
Profile {{ t('Profile') }}
</UButton> </UButton>
<USeparator class="my-1" /> <USeparator class="my-1" />
<UButton <div role="group" :aria-labelledby="themeLabelId">
v-for="option in colorModeOptions" <div :id="themeLabelId" :class="SECTION_LABEL_CLASS">{{ t('Theme') }}</div>
:key="option.value" <UButton
role="menuitemcheckbox" v-for="option in colorModeOptions"
color="neutral" :key="option.value"
variant="ghost" role="menuitemradio"
block color="neutral"
:aria-checked="colorMode.preference === option.value" variant="ghost"
:trailing-icon=" block
colorMode.preference === option.value ? 'i-lucide-check' : undefined :aria-checked="colorMode.preference === option.value"
" :trailing-icon="
:class="ITEM_CLASS" colorMode.preference === option.value ? 'i-lucide-check' : undefined
:ui="{ trailingIcon: 'ms-auto size-4.5' }" "
@click="chooseColorMode(option.value)" :class="ITEM_CLASS"
> :ui="{ trailingIcon: 'ms-auto size-4.5' }"
{{ option.label }} @click="chooseColorMode(option.value)"
</UButton> >
{{ option.label }}
</UButton>
</div>
<USeparator class="my-1" />
<div role="group" :aria-labelledby="languageLabelId">
<div :id="languageLabelId" :class="SECTION_LABEL_CLASS">
{{ t('Language') }}
</div>
<UButton
v-for="option in languageOptions"
:key="option.value"
role="menuitemradio"
color="neutral"
variant="ghost"
block
:lang="option.value"
:aria-checked="locale === option.value"
:trailing-icon="locale === option.value ? 'i-lucide-check' : undefined"
:class="ITEM_CLASS"
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
@click="chooseLanguage(option.value)"
>
{{ option.label }}
</UButton>
</div>
<USeparator class="my-1" /> <USeparator class="my-1" />
@@ -142,7 +181,7 @@ function moveFocus(event: KeyboardEvent) {
:ui="{ leadingIcon: 'size-4.5' }" :ui="{ leadingIcon: 'size-4.5' }"
@click="logOut" @click="logOut"
> >
Log out {{ t('Log out') }}
</UButton> </UButton>
</div> </div>
</template> </template>
+4 -2
View File
@@ -1,7 +1,9 @@
<script setup lang="ts"></script> <script setup lang="ts">
import { t } from '@/i18n'
</script>
<template> <template>
<div class="space-y-4 p-6" role="status" aria-label="Loading"> <div class="space-y-4 p-6" role="status" :aria-label="t('Loading')">
<USkeleton class="h-6 w-40" /> <USkeleton class="h-6 w-40" />
<USkeleton class="h-32 w-full" /> <USkeleton class="h-32 w-full" />
<USkeleton class="h-32 w-full" /> <USkeleton class="h-32 w-full" />
+17 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue' import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatRelativeDate } from '@/lib/relativeDate' import { formatRelativeDate } from '@/lib/relativeDate'
@@ -39,12 +41,16 @@ function actionsFor(article: Article) {
return [ return [
[ [
{ {
label: 'Edit', get label() {
return t('Edit')
},
icon: 'i-lucide-pencil', icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)), onSelect: () => router.push(editPath(article)),
}, },
{ {
label: article.is_online ? 'Unpublish' : 'Publish', get label() {
return article.is_online ? t('Unpublish') : t('Publish')
},
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe', icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article), disabled: isBusy(article),
onSelect: () => { onSelect: () => {
@@ -56,14 +62,18 @@ function actionsFor(article: Article) {
}, },
}, },
{ {
label: 'Copy API URL', get label() {
return t('Copy API URL')
},
icon: 'i-lucide-link', icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id), onSelect: () => emit('copy-url', article.id),
}, },
], ],
[ [
{ {
label: 'Delete…', get label() {
return t('Delete…')
},
icon: 'i-lucide-trash-2', icon: 'i-lucide-trash-2',
color: 'error' as const, color: 'error' as const,
onSelect: () => emit('delete', article), onSelect: () => emit('delete', article),
@@ -122,7 +132,8 @@ function onRowClick(article: Article, event: MouseEvent) {
:pending="pendingFor(article)" :pending="pendingFor(article)"
/> />
<span :title="article.updated_at" <span :title="article.updated_at"
>Updated {{ formatRelativeDate(article.updated_at, now) }}</span >{{ t('Updated') }}
{{ formatRelativeDate(article.updated_at, now) }}</span
> >
</div> </div>
</div> </div>
@@ -135,7 +146,7 @@ function onRowClick(article: Article, event: MouseEvent) {
icon="i-lucide-ellipsis-vertical" icon="i-lucide-ellipsis-vertical"
color="neutral" color="neutral"
variant="ghost" variant="ghost"
:aria-label="`Actions for ${article.title}`" :aria-label="t('Actions for {0}', [article.title])"
class="size-11 justify-center text-toned" class="size-11 justify-center text-toned"
@click.stop @click.stop
/> />
+50 -31
View File
@@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { t, locale, formatNumber } from '@/i18n'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { computed } from 'vue' import { computed } from 'vue'
import { useToast } from '@nuxt/ui/composables' import { useLocalizedToast } from '@/i18n/presentation'
import type { Article } from '@/composables/useArticles' import type { Article } from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds' import { bodyImageCount } from '@/lib/bodyImageIds'
@@ -8,7 +11,7 @@ const props = defineProps<{ article: Article }>()
const emit = defineEmits<{ 'copy-url': [] }>() const emit = defineEmits<{ 'copy-url': [] }>()
const toast = useToast() const toast = useLocalizedToast()
const open = defineModel<boolean>('open', { default: false }) const open = defineModel<boolean>('open', { default: false })
@@ -43,9 +46,11 @@ function formatDetailDate(iso: string, relative = false) {
const hours = date.getHours().toString().padStart(2, '0') const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0') const minutes = date.getMinutes().toString().padStart(2, '0')
if (relative && isSameDay(date, new Date())) { if (relative && isSameDay(date, new Date())) {
return `Today, ${hours}:${minutes}` return t('Today, {0}:{1}', [hours, minutes])
} }
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${hours}:${minutes}` return locale.value === 'fr'
? `${formatAbsoluteDateFromIso(iso)}, ${hours}:${minutes}`
: `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${hours}:${minutes}`
} }
const createdLabel = computed(() => formatDetailDate(props.article.created_at)) const createdLabel = computed(() => formatDetailDate(props.article.created_at))
@@ -54,29 +59,37 @@ const publicUrl = computed(() => `/v1/article/${props.article.id}`)
const imagesCount = computed(() => bodyImageCount(props.article.body)) const imagesCount = computed(() => bodyImageCount(props.article.body))
const apiNote = computed(() => const apiNote = computed(() =>
props.article.is_online props.article.is_online
? 'Live. Your website can read this article.' ? t('Live. Your website can read this article.')
: 'Returns 404 until the article is online.', : t('Returns 404 until the article is online.'),
) )
async function copyUrl() { async function copyUrl() {
const url = `${window.location.origin}${publicUrl.value}` const url = `${window.location.origin}${publicUrl.value}`
try { try {
await navigator.clipboard.writeText(url) await navigator.clipboard.writeText(url)
toast.add({ toast.add(() => ({
id: `article-link-copied-${props.article.id}`, id: `article-link-copied-${props.article.id}`,
color: 'success', color: 'success',
icon: 'i-lucide-circle-check', icon: 'i-lucide-circle-check',
title: 'Link copied', get title() {
description: `The API URL for "${props.article.title}" is on your clipboard.`, return t('Link copied')
}) },
get description() {
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [
props.article.title,
])
},
}))
emit('copy-url') emit('copy-url')
} catch { } catch {
toast.add({ toast.add(() => ({
id: `article-link-copy-failed-${props.article.id}`, id: `article-link-copy-failed-${props.article.id}`,
color: 'error', color: 'error',
icon: 'i-lucide-circle-x', icon: 'i-lucide-circle-x',
title: "Couldn't copy the link", get title() {
}) return t("Couldn't copy the link")
},
}))
} }
} }
</script> </script>
@@ -93,20 +106,24 @@ async function copyUrl() {
}" }"
> >
<template #header> <template #header>
<h2 class="text-base font-semibold text-highlighted">Details</h2> <h2 class="text-base font-semibold text-highlighted">
{{ t('Details') }}
</h2>
</template> </template>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm"> <dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted">Created</dt> <dt class="text-muted">{{ t('Created') }}</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd> <dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt> <dt class="text-muted">{{ t('Updated') }}</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd> <dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt> <dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted">{{ article.id }}</dd> <dd class="font-mono text-highlighted">{{ article.id }}</dd>
<dt class="text-muted">Images</dt> <dt class="text-muted">{{ t('Images') }}</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd> <dd class="text-highlighted">
{{ formatNumber(imagesCount) }}
</dd>
</dl> </dl>
<div class="flex flex-col gap-1.5 text-sm"> <div class="flex flex-col gap-1.5 text-sm">
<p class="text-muted">Public API URL</p> <p class="text-muted">{{ t('Public API URL') }}</p>
<div class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"> <div class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs">
<span class="flex-1">{{ publicUrl }}</span> <span class="flex-1">{{ publicUrl }}</span>
<UButton <UButton
@@ -114,7 +131,7 @@ async function copyUrl() {
color="neutral" color="neutral"
variant="ghost" variant="ghost"
size="sm" size="sm"
aria-label="Copy the public API URL" :aria-label="t('Copy the public API URL')"
class="size-5 p-0" class="size-5 p-0"
@click="copyUrl" @click="copyUrl"
/> />
@@ -122,28 +139,30 @@ async function copyUrl() {
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p> <p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div> </div>
<div class="flex flex-col gap-1 border-t border-default pt-3 text-sm"> <div class="flex flex-col gap-1 border-t border-default pt-3 text-sm">
<p class="text-muted">Title</p> <p class="text-muted">{{ t('Title') }}</p>
<p class="text-xs/[18px] text-toned">{{ TITLE_NOTE }}</p> <p class="text-xs/[18px] text-toned">{{ t(TITLE_NOTE) }}</p>
</div> </div>
</UCard> </UCard>
<USlideover v-model:open="open" side="right" title="Details" class="lg:hidden"> <USlideover v-model:open="open" side="right" :title="t('Details')" class="lg:hidden">
<template #body> <template #body>
<div class="flex flex-col gap-4 text-sm"> <div class="flex flex-col gap-4 text-sm">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2"> <dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
<dt class="text-muted">Created</dt> <dt class="text-muted">{{ t('Created') }}</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd> <dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt> <dt class="text-muted">{{ t('Updated') }}</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd> <dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt> <dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted"> <dd class="font-mono text-highlighted">
{{ article.id }} {{ article.id }}
</dd> </dd>
<dt class="text-muted">Images</dt> <dt class="text-muted">{{ t('Images') }}</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd> <dd class="text-highlighted">
{{ formatNumber(imagesCount) }}
</dd>
</dl> </dl>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<p class="text-muted">Public API URL</p> <p class="text-muted">{{ t('Public API URL') }}</p>
<div <div
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs" class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
> >
@@ -153,7 +172,7 @@ async function copyUrl() {
color="neutral" color="neutral"
variant="ghost" variant="ghost"
size="sm" size="sm"
aria-label="Copy the public API URL" :aria-label="t('Copy the public API URL')"
class="size-5 p-0" class="size-5 p-0"
@click="copyUrl" @click="copyUrl"
/> />
@@ -161,9 +180,9 @@ async function copyUrl() {
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p> <p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div> </div>
<div class="flex flex-col gap-1 border-t border-default pt-3"> <div class="flex flex-col gap-1 border-t border-default pt-3">
<p class="text-muted">Title</p> <p class="text-muted">{{ t('Title') }}</p>
<p class="text-xs/[18px] text-toned"> <p class="text-xs/[18px] text-toned">
{{ TITLE_NOTE }} {{ t(TITLE_NOTE) }}
</p> </p>
</div> </div>
</div> </div>
+129 -29
View File
@@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue' import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui' import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { ArticleIndent } from '@/lib/articleIndent' import { ArticleIndent } from '@/lib/articleIndent'
import { ArticleTextAlign } from '@/lib/articleAlign'
import { ArticleImage, findArticleImagePos } from '@/lib/articleImage' import { ArticleImage, findArticleImagePos } from '@/lib/articleImage'
const props = withDefaults( const props = withDefaults(
@@ -25,6 +28,7 @@ const emit = defineEmits<{
const editorInstance = ref<Editor | null>(null) const editorInstance = ref<Editor | null>(null)
const extensions = [ const extensions = [
ArticleIndent, ArticleIndent,
ArticleTextAlign,
ArticleImage.configure({ ArticleImage.configure({
onRequestRemove: (imageId: number) => emit('request-remove-image', imageId), onRequestRemove: (imageId: number) => emit('request-remove-image', imageId),
}), }),
@@ -42,10 +46,10 @@ function onUpdate(value: string) {
function currentBlockLabel(editor: Editor) { function currentBlockLabel(editor: Editor) {
for (let level = 1; level <= 4; level += 1) { for (let level = 1; level <= 4; level += 1) {
if (editor.isActive('heading', { level })) { if (editor.isActive('heading', { level })) {
return `Heading ${level}` return t('Heading {0}', [level])
} }
} }
return 'Paragraph' return t('Paragraph')
} }
function isDefaultAlignment(editor: Editor) { function isDefaultAlignment(editor: Editor) {
@@ -58,12 +62,20 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
{ {
kind: 'undo', kind: 'undo',
icon: 'i-lucide-undo-2', icon: 'i-lucide-undo-2',
tooltip: { text: 'Undo' }, tooltip: {
get text() {
return t('Undo')
},
},
}, },
{ {
kind: 'redo', kind: 'redo',
icon: 'i-lucide-redo-2', icon: 'i-lucide-redo-2',
tooltip: { text: 'Redo' }, tooltip: {
get text() {
return t('Redo')
},
},
}, },
], ],
[ [
@@ -72,40 +84,70 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
label: currentBlockLabel(editor), label: currentBlockLabel(editor),
trailingIcon: 'i-lucide-chevron-down', trailingIcon: 'i-lucide-chevron-down',
ui: { trailingIcon: 'size-3' }, ui: { trailingIcon: 'size-3' },
tooltip: { text: 'Paragraph style' }, tooltip: {
get text() {
return t('Paragraph style')
},
},
items: [ items: [
{ {
icon: 'i-lucide-pilcrow', icon: 'i-lucide-pilcrow',
label: 'Paragraph', get label() {
return t('Paragraph')
},
onSelect: () => editor.chain().focus().setParagraph().run(), onSelect: () => editor.chain().focus().setParagraph().run(),
}, },
{ {
kind: 'heading', kind: 'heading',
level: 1, level: 1,
icon: 'i-lucide-heading-1', icon: 'i-lucide-heading-1',
label: 'Heading 1', get label() {
tooltip: { text: 'Heading 1' }, return t('Heading 1')
},
tooltip: {
get text() {
return t('Heading 1')
},
},
}, },
{ {
kind: 'heading', kind: 'heading',
level: 2, level: 2,
icon: 'i-lucide-heading-2', icon: 'i-lucide-heading-2',
label: 'Heading 2', get label() {
tooltip: { text: 'Heading 2' }, return t('Heading 2')
},
tooltip: {
get text() {
return t('Heading 2')
},
},
}, },
{ {
kind: 'heading', kind: 'heading',
level: 3, level: 3,
icon: 'i-lucide-heading-3', icon: 'i-lucide-heading-3',
label: 'Heading 3', get label() {
tooltip: { text: 'Heading 3' }, return t('Heading 3')
},
tooltip: {
get text() {
return t('Heading 3')
},
},
}, },
{ {
kind: 'heading', kind: 'heading',
level: 4, level: 4,
icon: 'i-lucide-heading-4', icon: 'i-lucide-heading-4',
label: 'Heading 4', get label() {
tooltip: { text: 'Heading 4' }, return t('Heading 4')
},
tooltip: {
get text() {
return t('Heading 4')
},
},
}, },
], ],
}, },
@@ -115,25 +157,41 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
kind: 'mark', kind: 'mark',
mark: 'bold', mark: 'bold',
icon: 'i-lucide-bold', icon: 'i-lucide-bold',
tooltip: { text: 'Bold' }, tooltip: {
get text() {
return t('Bold')
},
},
}, },
{ {
kind: 'mark', kind: 'mark',
mark: 'italic', mark: 'italic',
icon: 'i-lucide-italic', icon: 'i-lucide-italic',
tooltip: { text: 'Italic' }, tooltip: {
get text() {
return t('Italic')
},
},
}, },
{ {
kind: 'mark', kind: 'mark',
mark: 'underline', mark: 'underline',
icon: 'i-lucide-underline', icon: 'i-lucide-underline',
tooltip: { text: 'Underline' }, tooltip: {
get text() {
return t('Underline')
},
},
}, },
{ {
kind: 'mark', kind: 'mark',
mark: 'strike', mark: 'strike',
icon: 'i-lucide-strikethrough', icon: 'i-lucide-strikethrough',
tooltip: { text: 'Strikethrough' }, tooltip: {
get text() {
return t('Strikethrough')
},
},
}, },
], ],
[ [
@@ -142,46 +200,78 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
align: 'left', align: 'left',
active: isDefaultAlignment(editor), active: isDefaultAlignment(editor),
icon: 'i-lucide-align-left', icon: 'i-lucide-align-left',
tooltip: { text: 'Align left' }, tooltip: {
get text() {
return t('Align left')
},
},
}, },
{ {
kind: 'textAlign', kind: 'textAlign',
align: 'center', align: 'center',
icon: 'i-lucide-align-center', icon: 'i-lucide-align-center',
tooltip: { text: 'Align center' }, tooltip: {
get text() {
return t('Align center')
},
},
}, },
{ {
kind: 'textAlign', kind: 'textAlign',
align: 'right', align: 'right',
icon: 'i-lucide-align-right', icon: 'i-lucide-align-right',
tooltip: { text: 'Align right' }, tooltip: {
get text() {
return t('Align right')
},
},
}, },
{ {
kind: 'textAlign', kind: 'textAlign',
align: 'justify', align: 'justify',
icon: 'i-lucide-align-justify', icon: 'i-lucide-align-justify',
tooltip: { text: 'Justify' }, tooltip: {
get text() {
return t('Justify')
},
},
}, },
], ],
[ [
{ {
kind: 'bulletList', kind: 'bulletList',
icon: 'i-lucide-list', icon: 'i-lucide-list',
tooltip: { text: 'Bullet list' }, tooltip: {
get text() {
return t('Bullet list')
},
},
}, },
{ {
kind: 'orderedList', kind: 'orderedList',
icon: 'i-lucide-list-ordered', icon: 'i-lucide-list-ordered',
tooltip: { text: 'Numbered list' }, tooltip: {
get text() {
return t('Numbered list')
},
},
}, },
{ {
icon: 'i-lucide-indent-decrease', icon: 'i-lucide-indent-decrease',
tooltip: { text: 'Outdent' }, tooltip: {
get text() {
return t('Outdent')
},
},
onClick: () => editor.chain().focus().outdent().run(), onClick: () => editor.chain().focus().outdent().run(),
}, },
{ {
icon: 'i-lucide-indent-increase', icon: 'i-lucide-indent-increase',
tooltip: { text: 'Indent' }, tooltip: {
get text() {
return t('Indent')
},
},
onClick: () => editor.chain().focus().indent().run(), onClick: () => editor.chain().focus().indent().run(),
}, },
], ],
@@ -189,12 +279,22 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
{ {
kind: 'clearFormatting', kind: 'clearFormatting',
icon: 'i-lucide-remove-formatting', icon: 'i-lucide-remove-formatting',
tooltip: { text: 'Clear formatting' }, tooltip: {
get text() {
return t('Clear formatting')
},
},
}, },
{ {
icon: 'i-lucide-image-plus', icon: 'i-lucide-image-plus',
label: 'Image', get label() {
tooltip: { text: 'Add an image' }, return t('Image')
},
tooltip: {
get text() {
return t('Add an image')
},
},
onClick: () => emit('request-image'), onClick: () => emit('request-image'),
}, },
], ],
+15 -4
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3' import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { articleImageId } from '@/lib/articleImage' import { articleImageId } from '@/lib/articleImage'
@@ -18,7 +20,14 @@ interface ImageNodeAttrs {
const imageAttrs = computed(() => { const imageAttrs = computed(() => {
const { src, alt, title, width, height } = props.node.attrs as ImageNodeAttrs const { src, alt, title, width, height } = props.node.attrs as ImageNodeAttrs
return { src, alt, title, width, height, 'data-image-id': props.node.attrs['data-image-id'] } return {
src,
alt,
title,
width,
height,
'data-image-id': props.node.attrs['data-image-id'],
}
}) })
const failed = ref(false) const failed = ref(false)
@@ -56,8 +65,10 @@ function onDeleteClick() {
class="flex min-h-40 flex-col items-center justify-center gap-2 border border-dashed border-accented bg-elevated p-6 text-center font-sans text-sm text-muted" class="flex min-h-40 flex-col items-center justify-center gap-2 border border-dashed border-accented bg-elevated p-6 text-center font-sans text-sm text-muted"
> >
<UIcon name="i-lucide-image-off" class="size-6" /> <UIcon name="i-lucide-image-off" class="size-6" />
<span class="font-medium text-highlighted">This image is no longer available</span> <span class="font-medium text-highlighted">{{
<span>It was deleted from the library. Remove it from the article.</span> t('This image is no longer available')
}}</span>
<span>{{ t('It was deleted from the library. Remove it from the article.') }}</span>
</div> </div>
<img <img
v-else v-else
@@ -77,7 +88,7 @@ function onDeleteClick() {
@mousedown.prevent @mousedown.prevent
@click="onDeleteClick" @click="onDeleteClick"
> >
Remove image {{ t('Remove image') }}
</UButton> </UButton>
</NodeViewWrapper> </NodeViewWrapper>
</template> </template>
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
withDefaults( withDefaults(
defineProps<{ defineProps<{
online: boolean online: boolean
@@ -21,10 +23,10 @@ withDefaults(
:class="size === 'lg' ? undefined : 'px-2'" :class="size === 'lg' ? undefined : 'px-2'"
:ui="{ leadingIcon: 'animate-spin' }" :ui="{ leadingIcon: 'animate-spin' }"
> >
{{ pending === 'publish' ? 'Publishing…' : 'Unpublishing…' }} {{ pending === 'publish' ? t('Publishing…') : t('Unpublishing…') }}
</UBadge> </UBadge>
<UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size"> <UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
Online {{ t('Online') }}
</UBadge> </UBadge>
<UBadge <UBadge
v-else v-else
@@ -34,6 +36,6 @@ withDefaults(
:size="size" :size="size"
:class="size === 'lg' ? undefined : 'px-2'" :class="size === 'lg' ? undefined : 'px-2'"
> >
Draft {{ t('Draft') }}
</UBadge> </UBadge>
</template> </template>
+54 -15
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue' import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
@@ -27,10 +29,37 @@ const columnWidths =
const sorting = ref([{ id: 'updated_at', desc: true }]) const sorting = ref([{ id: 'updated_at', desc: true }])
const columns = [ const columns = [
{ accessorKey: 'title', header: 'Title', enableSorting: true }, {
{ accessorKey: 'is_online', header: 'Status', size: 110, enableSorting: true }, accessorKey: 'title',
{ accessorKey: 'created_at', header: 'Created', size: 130, enableSorting: true }, get header() {
{ accessorKey: 'updated_at', header: 'Updated', size: 140, enableSorting: true }, return t('Title')
},
enableSorting: true,
},
{
accessorKey: 'is_online',
get header() {
return t('Status')
},
size: 110,
enableSorting: true,
},
{
accessorKey: 'created_at',
get header() {
return t('Created')
},
size: 130,
enableSorting: true,
},
{
accessorKey: 'updated_at',
get header() {
return t('Updated')
},
size: 140,
enableSorting: true,
},
{ id: 'actions', header: '', size: 44 }, { id: 'actions', header: '', size: 44 },
] ]
@@ -64,12 +93,16 @@ function actionsFor(article: Article) {
return [ return [
[ [
{ {
label: 'Edit', get label() {
return t('Edit')
},
icon: 'i-lucide-pencil', icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)), onSelect: () => router.push(editPath(article)),
}, },
{ {
label: article.is_online ? 'Unpublish' : 'Publish', get label() {
return article.is_online ? t('Unpublish') : t('Publish')
},
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe', icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article), disabled: isBusy(article),
onSelect: () => { onSelect: () => {
@@ -81,14 +114,18 @@ function actionsFor(article: Article) {
}, },
}, },
{ {
label: 'Copy API URL', get label() {
return t('Copy API URL')
},
icon: 'i-lucide-link', icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id), onSelect: () => emit('copy-url', article.id),
}, },
], ],
[ [
{ {
label: 'Delete…', get label() {
return t('Delete…')
},
icon: 'i-lucide-trash-2', icon: 'i-lucide-trash-2',
color: 'error' as const, color: 'error' as const,
onSelect: () => emit('delete', article), onSelect: () => emit('delete', article),
@@ -104,7 +141,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<template> <template>
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block"> <div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
<span role="status" class="sr-only">Loading articles</span> <span role="status" class="sr-only">{{ t('Loading articles') }}</span>
<div <div
v-for="n in 5" v-for="n in 5"
:key="n" :key="n"
@@ -131,7 +168,9 @@ function onSelect(_event: Event, row: { original: Article }) {
> >
<template #caption> <template #caption>
<caption class="sr-only"> <caption class="sr-only">
Articles, sorted by last updated, newest first {{
t('Articles, sorted by last updated, newest first')
}}
</caption> </caption>
</template> </template>
@@ -141,7 +180,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted" class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()" @click.stop="column.toggleSorting()"
> >
Title {{ t('Title') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" /> <UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button> </button>
</template> </template>
@@ -152,7 +191,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted" class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()" @click.stop="column.toggleSorting()"
> >
Status {{ t('Status') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" /> <UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button> </button>
</template> </template>
@@ -163,7 +202,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted" class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()" @click.stop="column.toggleSorting()"
> >
Created {{ t('Created') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" /> <UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button> </button>
</template> </template>
@@ -174,7 +213,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted" class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()" @click.stop="column.toggleSorting()"
> >
Updated {{ t('Updated') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" /> <UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button> </button>
</template> </template>
@@ -217,7 +256,7 @@ function onSelect(_event: Event, row: { original: Article }) {
icon="i-lucide-ellipsis" icon="i-lucide-ellipsis"
color="neutral" color="neutral"
variant="ghost" variant="ghost"
:aria-label="`Actions for ${row.original.title}`" :aria-label="t('Actions for {0}', [row.original.title])"
class="size-8 justify-center text-toned" class="size-8 justify-center text-toned"
@click.stop @click.stop
/> />
+13 -6
View File
@@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
import AppLogo from '@/components/AppLogo.vue' import AppLogo from '@/components/AppLogo.vue'
import LanguageChip from '@/components/LanguageChip.vue'
import { isInAppNavigation } from '@/composables/useAuthShellNavigation' import { isInAppNavigation } from '@/composables/useAuthShellNavigation'
const props = defineProps<{ const props = defineProps<{
@@ -59,12 +62,13 @@ const footerClass = computed(() => [
cardClass.value, cardClass.value,
]) ])
const colorModeSelectUi = { const colorModeSelectUi = computed(() => ({
base: 'h-7 ps-7 pe-2 text-sm text-toned', base: belowSm.value ? 'h-11 ps-7 pe-2 text-sm text-toned' : 'h-7 ps-7 pe-2 text-sm text-toned',
leading: 'ps-2', leading: 'ps-2',
leadingIcon: 'size-3.5 text-toned', leadingIcon: 'size-3.5 text-toned',
trailing: 'hidden', trailing: 'hidden',
} content: 'w-auto min-w-36',
}))
watch( watch(
() => props.title, () => props.title,
@@ -72,7 +76,7 @@ watch(
if (!title) { if (!title) {
return return
} }
document.title = `${title} · RenewCMS` document.title = t('{0} · RenewCMS', [title])
}, },
{ immediate: true }, { immediate: true },
) )
@@ -116,11 +120,14 @@ watch(
/> />
</UPageCard> </UPageCard>
<footer v-if="!belowSm || $slots.footer" :class="footerClass"> <footer :class="footerClass">
<span class="flex flex-wrap items-center gap-1.5"> <span class="flex flex-wrap items-center gap-1.5">
<slot name="footer" /> <slot name="footer" />
</span> </span>
<UColorModeSelect size="sm" :ui="colorModeSelectUi" /> <span class="flex items-center gap-2">
<LanguageChip :mobile="belowSm" />
<UColorModeSelect size="sm" :ui="colorModeSelectUi" />
</span>
</footer> </footer>
</main> </main>
</template> </template>
+22 -14
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaBadge from '@/components/MediaBadge.vue' import MediaBadge from '@/components/MediaBadge.vue'
@@ -16,7 +18,7 @@ const props = defineProps<{
const emit = defineEmits<{ close: [confirmed: boolean] }>() const emit = defineEmits<{ close: [confirmed: boolean] }>()
const total = computed(() => props.items.length) const total = computed(() => props.items.length)
const imageLabel = computed(() => `${total.value} ${total.value === 1 ? 'image' : 'images'}`) const imageLabel = computed(() => t(total.value === 1 ? '{0} image' : '{0} images', [total.value]))
const inUse = computed(() => props.items.filter((item) => item.used_in > 0)) const inUse = computed(() => props.items.filter((item) => item.used_in > 0))
const articleCount = computed(() => inUse.value.reduce((sum, item) => sum + item.used_in, 0)) const articleCount = computed(() => inUse.value.reduce((sum, item) => sum + item.used_in, 0))
const description = computed(() => { const description = computed(() => {
@@ -25,28 +27,34 @@ const description = computed(() => {
const articles = pluralArticles(articleCount.value) const articles = pluralArticles(articleCount.value)
if (usedCount === 0) { if (usedCount === 0) {
return total.value === 1 return total.value === 1
? `It isn't used in any article. ${FINAL_WARNING}` ? t("It isn't used in any article. {0}", [t(FINAL_WARNING)])
: `None of them are used in any article. ${FINAL_WARNING}` : t('None of them are used in any article. {0}', [t(FINAL_WARNING)])
} }
if (unusedCount === 0) { if (unusedCount === 0) {
const subject = total.value === 1 ? "It's" : `All ${total.value} are` const subject = total.value === 1 ? t("It's") : t('All {0} are', [total.value])
return `${subject} used in ${articles}, ${BROKEN_IMAGE} ${FINAL_WARNING}` return t('{0} used in {1}, {2} {3}', [subject, articles, t(BROKEN_IMAGE), t(FINAL_WARNING)])
} }
const rest = const rest =
unusedCount === 1 unusedCount === 1
? "The other one isn't used anywhere." ? t("The other one isn't used anywhere.")
: `The other ${unusedCount} aren't used anywhere.` : t("The other {0} aren't used anywhere.", [unusedCount])
return `${usedCount} of them are used in ${articles}, ${BROKEN_IMAGE} ${rest} ${FINAL_WARNING}` return t('{0} of them are used in {1}, {2} {3} {4}', [
usedCount,
articles,
t(BROKEN_IMAGE),
rest,
t(FINAL_WARNING),
])
}) })
</script> </script>
<template> <template>
<ConfirmDialog <ConfirmDialog
:title="`Delete ${imageLabel}?`" :title="t('Delete {0}?', [imageLabel])"
:description="description" :description="description"
:confirm-label="`Delete ${imageLabel}`" :confirm-label="t('Delete {0}', [imageLabel])"
cancel-label="Cancel" :cancel-label="t('Cancel')"
pending-label="Deleting…" :pending-label="t('Deleting…')"
tone="danger" tone="danger"
:action="action" :action="action"
@close="emit('close', $event)" @close="emit('close', $event)"
@@ -66,9 +74,9 @@ const description = computed(() => {
<MediaBadge <MediaBadge
v-if="item.used_in > 0" v-if="item.used_in > 0"
kind="used" kind="used"
:label="`Used in ${item.used_in}`" :label="t('Used in {0}', [item.used_in])"
/> />
<MediaBadge v-else kind="unused" label="Unused" /> <MediaBadge v-else kind="unused" :label="t('Unused')" />
</li> </li>
</ul> </ul>
</template> </template>
+11 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t, locale, formatNumber } from '@/i18n'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
const props = withDefaults( const props = withDefaults(
@@ -19,6 +21,7 @@ const props = withDefaults(
) )
function formatThousands(count: number) { function formatThousands(count: number) {
if (locale.value === 'fr') return formatNumber(count)
return count.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ') return count.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
} }
@@ -38,11 +41,11 @@ const status = computed<'normal' | 'warn' | 'over' | 'under'>(() => {
const overText = computed(() => { const overText = computed(() => {
const suffix = props.plainText const suffix = props.plainText
? 'Shorten the text.' ? t('Shorten the text.')
: 'Shorten the text or remove an image. Formatting counts too.' : t('Shorten the text or remove an image. Formatting counts too.')
return overBy.value === 1 return overBy.value === 1
? `1 character over. ${suffix}` ? t('1 character over. {0}', [suffix])
: `${formatThousands(overBy.value)} characters over. ${suffix}` : t('{0} characters over. {1}', [formatThousands(overBy.value), suffix])
}) })
const warnText = 'Close to the limit. Images and formatting count too.' const warnText = 'Close to the limit. Images and formatting count too.'
@@ -52,21 +55,21 @@ const message = computed(() => {
return overText.value return overText.value
} }
if (status.value === 'warn') { if (status.value === 'warn') {
return warnText return t(warnText)
} }
return '' return ''
}) })
const announcement = ref('') const announcement = ref('')
watch(status, (next, previous) => { watch([status, locale], ([next], [previous]) => {
if (next === previous) { if (next === previous && !announcement.value) {
return return
} }
if (next === 'over') { if (next === 'over') {
announcement.value = overText.value announcement.value = overText.value
} else if (next === 'warn') { } else if (next === 'warn') {
announcement.value = 'Almost at the length limit.' announcement.value = t('Almost at the length limit.')
} else { } else {
announcement.value = '' announcement.value = ''
} }
+4 -2
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watchEffect } from 'vue' import { computed, ref, watchEffect } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard' import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { highlightToHtml, useShikiHighlighter } from '@/composables/useShikiHighlighter' import { highlightToHtml, useShikiHighlighter } from '@/composables/useShikiHighlighter'
@@ -11,7 +13,7 @@ const props = defineProps<{
const { copied, copy } = useCopyToClipboard() const { copied, copy } = useCopyToClipboard()
const buttonLabel = computed(() => (copied.value ? 'Copied' : 'Copy')) const buttonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy')) const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
const highlightedHtml = ref<string | null>(null) const highlightedHtml = ref<string | null>(null)
@@ -37,7 +39,7 @@ function onCopy() {
color="neutral" color="neutral"
variant="ghost" variant="ghost"
size="xs" size="xs"
:aria-label="`Copy ${label ?? lang} example`" :aria-label="t('Copy {0} example', [label ?? lang])"
aria-live="polite" aria-live="polite"
class="gap-1.5 px-1.5 font-medium text-toned" class="gap-1.5 px-1.5 font-medium text-toned"
:ui="{ leadingIcon: 'size-3.5' }" :ui="{ leadingIcon: 'size-3.5' }"
+10 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
@@ -28,7 +30,9 @@ const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let settled = false let settled = false
const previewDetail = computed(() => const previewDetail = computed(() =>
props.preview?.size ? `Stays in the library · ${props.preview.size}` : 'Stays in the library', props.preview?.size
? t('Stays in the library · {0}', [props.preview.size])
: t('Stays in the library'),
) )
const previewThumb = computed(() => ({ const previewThumb = computed(() => ({
url: props.preview?.src ?? '', url: props.preview?.src ?? '',
@@ -111,11 +115,14 @@ function onCancel() {
v-if="error" v-if="error"
color="error" color="error"
icon="i-lucide-circle-x" icon="i-lucide-circle-x"
:description="error" :description="translate(error)"
variant="soft" variant="soft"
:close="false" :close="false"
role="alert" role="alert"
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }" :ui="{
root: 'gap-2 px-3 py-2.5',
description: 'text-sm text-toned',
}"
/> />
<div <div
v-if="preview" v-if="preview"
+51 -10
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
@@ -50,19 +52,56 @@ const api = useApi()
const navItems = computed(() => const navItems = computed(() =>
[ [
{ label: 'Home', to: '/home', icon: 'i-lucide-home' }, {
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' }, get label() {
{ label: 'Media', to: '/media', icon: 'i-lucide-images' }, return t('Home')
{ label: 'Users', to: '/users', icon: 'i-lucide-users' }, },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' }, to: '/home',
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings', slot: 'settings' }, icon: 'i-lucide-home',
},
{
get label() {
return t('Articles')
},
to: '/article',
icon: 'i-lucide-file-text',
},
{
get label() {
return t('Media')
},
to: '/media',
icon: 'i-lucide-images',
},
{
get label() {
return t('Users')
},
to: '/users',
icon: 'i-lucide-users',
},
{
get label() {
return t('Integration')
},
to: '/integration',
icon: 'i-lucide-plug',
},
{
get label() {
return t('Settings')
},
to: '/settings',
icon: 'i-lucide-settings',
slot: 'settings',
},
].map((item) => ({ ].map((item) => ({
...item, ...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`), active: route.path === item.to || route.path.startsWith(`${item.to}/`),
})), })),
) )
const routeTitle = computed(() => (route.meta.title as string) ?? '') const routeTitle = computed(() => translate((route.meta.title as string) ?? ''))
const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value) const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value)
const sidebarCollapsed = useSidebarCollapsed() const sidebarCollapsed = useSidebarCollapsed()
@@ -130,7 +169,9 @@ async function onLogout() {
<template> <template>
<UDashboardGroup unit="rem"> <UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a> <a href="#dashboard-content" class="sr-only focus:not-sr-only">{{
t('Skip to content')
}}</a>
<UDashboardSidebar <UDashboardSidebar
v-model:open="sidebarOpen" v-model:open="sidebarOpen"
@@ -155,7 +196,7 @@ async function onLogout() {
orientation="vertical" orientation="vertical"
:items="navItems" :items="navItems"
:collapsed="sidebarCollapsed" :collapsed="sidebarCollapsed"
aria-label="Main" :aria-label="t('Main')"
:ui="navUi" :ui="navUi"
> >
<template #settings-trailing> <template #settings-trailing>
@@ -199,7 +240,7 @@ async function onLogout() {
<span <span
v-if="update.showCue" v-if="update.showCue"
role="img" role="img"
aria-label="Update available" :aria-label="t('Update available')"
class="absolute top-2.25 right-2.25 size-2.5 border-2 border-(--ui-bg) bg-info" class="absolute top-2.25 right-2.25 size-2.5 border-2 border-(--ui-bg) bg-info"
/> />
</span> </span>
+14 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue' import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
@@ -15,12 +17,18 @@ const emit = defineEmits<{ close: [confirmed: boolean] }>()
const inUse = computed(() => props.item.used_in > 0) const inUse = computed(() => props.item.used_in > 0)
const title = computed(() => const title = computed(() =>
inUse.value ? "Delete an image that's in use?" : 'Delete this image?', inUse.value ? t("Delete an image that's in use?") : t('Delete this image?'),
) )
const description = computed(() => const description = computed(() =>
inUse.value inUse.value
? `"${props.item.filename}" is used in ${pluralArticles(props.item.used_in)}. If you delete it, they will show a broken image. This can't be undone.` ? t(
: `"${props.item.filename}" will be deleted from your library. No article uses it. This can't be undone.`, "\u0022{0}\u0022 is used in {1}. If you delete it, they will show a broken image. This can't be undone.",
[props.item.filename, pluralArticles(props.item.used_in)],
)
: t(
"\u0022{0}\u0022 will be deleted from your library. No article uses it. This can't be undone.",
[props.item.filename],
),
) )
</script> </script>
@@ -28,9 +36,9 @@ const description = computed(() =>
<ConfirmDialog <ConfirmDialog
:title="title" :title="title"
:description="description" :description="description"
confirm-label="Delete image" :confirm-label="t('Delete image')"
cancel-label="Cancel" :cancel-label="t('Cancel')"
pending-label="Deleting…" :pending-label="t('Deleting…')"
tone="danger" tone="danger"
:action="action" :action="action"
@close="emit('close', $event)" @close="emit('close', $event)"
+13 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia' import type { MediaItem } from '@/composables/useMedia'
import type { DuplicateGroup } from '@/lib/mediaDuplicates' import type { DuplicateGroup } from '@/lib/mediaDuplicates'
@@ -18,8 +20,8 @@ const emit = defineEmits<{
}>() }>()
function uploaded(copy: MediaItem) { function uploaded(copy: MediaItem) {
const by = copy.uploaded_by ? ` by ${copy.uploaded_by}` : '' const by = copy.uploaded_by ? t(' by {0}', [copy.uploaded_by]) : ''
return `Uploaded ${formatMediaDate(copy.created_at)}${by}` return t('Uploaded {0}{1}', [formatMediaDate(copy.created_at), by])
} }
</script> </script>
@@ -32,12 +34,14 @@ function uploaded(copy: MediaItem) {
class="border border-default bg-default" class="border border-default bg-default"
> >
<header class="flex flex-col gap-0.5 border-b border-default px-3.5 py-3"> <header class="flex flex-col gap-0.5 border-b border-default px-3.5 py-3">
<h3 class="font-semibold text-highlighted">Group {{ index + 1 }}</h3> <h3 class="font-semibold text-highlighted">{{ t('Group') }} {{ index + 1 }}</h3>
<span class="text-muted">{{ group.header }}</span> <span class="text-muted">{{ group.header }}</span>
</header> </header>
<div <div
class="grid md:grid-cols-(--columns) md:gap-4 md:p-4" class="grid md:grid-cols-(--columns) md:gap-4 md:p-4"
:style="{ '--columns': `repeat(${group.copies.length}, minmax(0, 1fr))` }" :style="{
'--columns': `repeat(${group.copies.length}, minmax(0, 1fr))`,
}"
> >
<div <div
v-for="copy in group.copies" v-for="copy in group.copies"
@@ -70,7 +74,7 @@ function uploaded(copy: MediaItem) {
<span <span
v-if="copy.id === group.suggested.id" v-if="copy.id === group.suggested.id"
class="inline-flex h-5 items-center bg-primary px-1.5 text-xs font-medium text-inverted" class="inline-flex h-5 items-center bg-primary px-1.5 text-xs font-medium text-inverted"
>Suggested</span >{{ t('Suggested') }}</span
> >
</div> </div>
<span <span
@@ -88,7 +92,8 @@ function uploaded(copy: MediaItem) {
class="self-start font-medium underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" class="self-start font-medium underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@click="emit('open', copy.id, $event.currentTarget as HTMLElement)" @click="emit('open', copy.id, $event.currentTarget as HTMLElement)"
> >
Used in {{ pluralArticles(copy.used_in) }} {{ t('Used in') }}
{{ pluralArticles(copy.used_in) }}
</button> </button>
</div> </div>
</div> </div>
@@ -105,12 +110,12 @@ function uploaded(copy: MediaItem) {
" "
@click="emit('merge', group, copy)" @click="emit('merge', group, copy)"
> >
Merge into this one {{ t('Merge into this one') }}
</UButton> </UButton>
</div> </div>
</div> </div>
<footer class="border-t border-default px-3.5 py-2.5 text-xs/5 text-muted"> <footer class="border-t border-default px-3.5 py-2.5 text-xs/5 text-muted">
{{ FOOTER }} {{ t(FOOTER) }}
</footer> </footer>
</section> </section>
</div> </div>
+30 -12
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40 const QUERY_MAX_LENGTH = 40
@@ -18,30 +20,46 @@ const truncatedQuery = computed(() => {
const variants = computed(() => ({ const variants = computed(() => ({
'no-articles': { 'no-articles': {
icon: 'i-lucide-file-text', icon: 'i-lucide-file-text',
title: 'No articles yet', get title() {
description: 'Write your first one. It stays a draft until you publish it.', return t('No articles yet')
action: 'New article', },
get description() {
return t('Write your first one. It stays a draft until you publish it.')
},
action: t('New article'),
primary: true, primary: true,
}, },
'no-results': { 'no-results': {
icon: 'i-lucide-search', icon: 'i-lucide-search',
title: `No articles match "${truncatedQuery.value}"`, get title() {
description: 'Check the spelling, or search for a shorter part of the title.', return t('No articles match \u0022{0}\u0022', [truncatedQuery.value])
action: 'Clear search', },
get description() {
return t('Check the spelling, or search for a shorter part of the title.')
},
action: t('Clear search'),
primary: false, primary: false,
}, },
'no-drafts': { 'no-drafts': {
icon: 'i-lucide-pencil-line', icon: 'i-lucide-pencil-line',
title: 'No drafts', get title() {
description: "Everything you've written is online.", return t('No drafts')
action: 'New article', },
get description() {
return t("Everything you've written is online.")
},
action: t('New article'),
primary: false, primary: false,
}, },
'no-online': { 'no-online': {
icon: 'i-lucide-globe', icon: 'i-lucide-globe',
title: 'Nothing is online yet', get title() {
description: 'Publish a draft and it will appear on your website.', return t('Nothing is online yet')
action: 'Show drafts', },
get description() {
return t('Publish a draft and it will appear on your website.')
},
action: t('Show drafts'),
primary: false, primary: false,
}, },
})) }))
+8 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue' import { ref } from 'vue'
import CodeBlock from '@/components/CodeBlock.vue' import CodeBlock from '@/components/CodeBlock.vue'
@@ -14,7 +16,12 @@ const activeTab = ref('fetch')
const tabItems = [ const tabItems = [
{ label: 'fetch', value: 'fetch' }, { label: 'fetch', value: 'fetch' },
{ label: 'curl', value: 'curl' }, { label: 'curl', value: 'curl' },
{ label: 'Response', value: 'response' }, {
get label() {
return t('Response')
},
value: 'response',
},
] ]
</script> </script>
+30 -14
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -8,7 +10,7 @@ const props = withDefaults(
breadcrumb?: string breadcrumb?: string
}>(), }>(),
{ {
breadcrumb: 'Home', breadcrumb: undefined,
}, },
) )
@@ -21,25 +23,39 @@ const router = useRouter()
const copy = computed(() => { const copy = computed(() => {
if (props.kind === 'not-found') { if (props.kind === 'not-found') {
return { return {
title: 'Page not found', get title() {
text: 'The link may be mistyped, or the page may have moved.', return t('Page not found')
primary: 'Go to Home', },
secondary: 'All articles', get text() {
return t('The link may be mistyped, or the page may have moved.')
},
primary: t('Go to Home'),
secondary: t('All articles'),
} }
} }
if (props.kind === 'article-not-found') { if (props.kind === 'article-not-found') {
return { return {
title: "This article doesn't exist.", get title() {
text: 'It may have been deleted, or the link is wrong.', return t("This article doesn't exist.")
primary: 'All articles', },
get text() {
return t('It may have been deleted, or the link is wrong.')
},
primary: t('All articles'),
secondary: '', secondary: '',
} }
} }
return { return {
title: 'Something went wrong', get title() {
text: "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.", return t('Something went wrong')
primary: 'Try again', },
secondary: 'Go to Home', get text() {
return t(
"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
)
},
primary: t('Try again'),
secondary: t('Go to Home'),
} }
}) })
@@ -66,8 +82,8 @@ function onSecondary() {
<template> <template>
<div role="alert" class="flex max-w-140 flex-col gap-3.5 p-8"> <div role="alert" class="flex max-w-140 flex-col gap-3.5 p-8">
<nav v-if="kind !== 'article-not-found'" aria-label="Breadcrumb"> <nav v-if="kind !== 'article-not-found'" :aria-label="t('Breadcrumb')">
<span>{{ breadcrumb }}</span> <span>{{ breadcrumb ?? t('Home') }}</span>
</nav> </nav>
<span <span
v-if="kind === 'article-not-found'" v-if="kind === 'article-not-found'"
+88 -39
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import MediaPicker from '@/components/MediaPicker.vue' import MediaPicker from '@/components/MediaPicker.vue'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
@@ -72,8 +74,20 @@ const DROP_ZONE_UI = {
const FOOTER_BUTTON_CLASS = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3' const FOOTER_BUTTON_CLASS = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
const TAB_ITEMS = [ const TAB_ITEMS = [
{ label: 'Upload', value: 'upload', icon: 'i-lucide-upload' }, {
{ label: 'Library', value: 'library', icon: 'i-lucide-images' }, get label() {
return t('Upload')
},
value: 'upload',
icon: 'i-lucide-upload',
},
{
get label() {
return t('Library')
},
value: 'library',
icon: 'i-lucide-images',
},
] ]
const open = defineModel<boolean>('open', { default: false }) const open = defineModel<boolean>('open', { default: false })
@@ -147,7 +161,7 @@ const duplicateText = computed(() =>
: '', : '',
) )
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel')) const cancelLabel = computed(() => (phase.value === 'uploading' ? t('Cancel upload') : t('Cancel')))
const showCancel = computed( const showCancel = computed(
() => () =>
@@ -163,7 +177,9 @@ const showCropActions = computed(() => !onLibraryTab.value && phase.value === 'c
const wideModal = computed(() => showTabs.value || phase.value === 'crop') const wideModal = computed(() => showTabs.value || phase.value === 'crop')
const modalTitle = computed(() => (phase.value === 'crop' ? 'Crop and resize' : 'Add an image')) const modalTitle = computed(() =>
phase.value === 'crop' ? t('Crop and resize') : t('Add an image'),
)
const modalDescription = computed(() => { const modalDescription = computed(() => {
if (phase.value !== 'crop' || !chosenFile.value) { if (phase.value !== 'crop' || !chosenFile.value) {
@@ -203,9 +219,12 @@ const outputSizeText = computed(() =>
formatEstimatedSize(estimateOutputBytes(outputSize.value.width, outputSize.value.height)), formatEstimatedSize(estimateOutputBytes(outputSize.value.width, outputSize.value.height)),
) )
const monoLine = computed( const monoLine = computed(() =>
() => t('Uploads as {0} × {1} px · about {2}', [
`Uploads as ${outputSize.value.width} × ${outputSize.value.height} px · about ${outputSizeText.value}`, outputSize.value.width,
outputSize.value.height,
outputSizeText.value,
]),
) )
const targetWidthPixels = computed(() => widthPresetPixels(widthLabel.value)) const targetWidthPixels = computed(() => widthPresetPixels(widthLabel.value))
@@ -214,9 +233,11 @@ const showNarrowNote = computed(
() => targetWidthPixels.value !== null && targetWidthPixels.value > cropWidth.value, () => targetWidthPixels.value !== null && targetWidthPixels.value > cropWidth.value,
) )
const narrowNoteText = computed( const narrowNoteText = computed(() =>
() => t(
`The crop is ${cropWidth.value} px wide, so it's uploaded at that size. Enlarging it would make it blurry.`, "The crop is {0} px wide, so it's uploaded at that size. Enlarging it would make it blurry.",
[cropWidth.value],
),
) )
const dimsLabel = computed(() => `${cropWidth.value} × ${cropHeight.value}`) const dimsLabel = computed(() => `${cropWidth.value} × ${cropHeight.value}`)
@@ -505,12 +526,18 @@ function stagePixelScale(): number {
return boundsWidth.value / box.width return boundsWidth.value / box.width
} }
function pointFromEvent(event: MouseEvent | TouchEvent): { x: number; y: number } { function pointFromEvent(event: MouseEvent | TouchEvent): {
x: number
y: number
} {
if ('touches' in event && event.touches.length > 0) { if ('touches' in event && event.touches.length > 0) {
return { x: event.touches[0]!.clientX, y: event.touches[0]!.clientY } return { x: event.touches[0]!.clientX, y: event.touches[0]!.clientY }
} }
if ('changedTouches' in event && event.changedTouches.length > 0) { if ('changedTouches' in event && event.changedTouches.length > 0) {
return { x: event.changedTouches[0]!.clientX, y: event.changedTouches[0]!.clientY } return {
x: event.changedTouches[0]!.clientX,
y: event.changedTouches[0]!.clientY,
}
} }
const mouseEvent = event as MouseEvent const mouseEvent = event as MouseEvent
return { x: mouseEvent.clientX, y: mouseEvent.clientY } return { x: mouseEvent.clientX, y: mouseEvent.clientY }
@@ -548,7 +575,11 @@ function onFrameDragMove(event: MouseEvent | TouchEvent) {
const dx = (point.x - moveOrigin.x) * scale const dx = (point.x - moveOrigin.x) * scale
const dy = (point.y - moveOrigin.y) * scale const dy = (point.y - moveOrigin.y) * scale
rect.value = clampRect( rect.value = clampRect(
{ ...moveOrigin.rect, x: moveOrigin.rect.x + dx, y: moveOrigin.rect.y + dy }, {
...moveOrigin.rect,
x: moveOrigin.rect.x + dx,
y: moveOrigin.rect.y + dy,
},
boundsWidth.value, boundsWidth.value,
boundsHeight.value, boundsHeight.value,
) )
@@ -775,11 +806,13 @@ onUnmounted(() => {
<template #label> <template #label>
{{ {{
phase === 'dragging-over' phase === 'dragging-over'
? 'Drop to upload' ? t('Drop to upload')
: 'Drop an image here, or choose a file' : t('Drop an image here, or choose a file')
}} }}
</template> </template>
<template #description>PNG, JPEG, WEBP, SVG · max 10 MB</template> <template #description>{{
t('PNG, JPEG, WEBP, SVG · max 10 MB')
}}</template>
</UFileUpload> </UFileUpload>
</div> </div>
@@ -795,13 +828,17 @@ onUnmounted(() => {
class="h-13.5 w-18 border border-default" class="h-13.5 w-18 border border-default"
/> />
<div class="flex min-w-0 flex-col gap-0.5"> <div class="flex min-w-0 flex-col gap-0.5">
<p class="font-semibold text-highlighted">Already in your library</p> <p class="font-semibold text-highlighted">
<p class="text-xs text-toned">{{ duplicateText }}</p> {{ t('Already in your library') }}
</p>
<p class="text-xs text-toned">
{{ duplicateText }}
</p>
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 md:flex-row"> <div class="flex flex-col gap-2 md:flex-row">
<UButton class="h-11 justify-center md:h-8" @click="useExisting"> <UButton class="h-11 justify-center md:h-8" @click="useExisting">
Use existing {{ t('Use existing') }}
</UButton> </UButton>
<UButton <UButton
color="neutral" color="neutral"
@@ -809,7 +846,7 @@ onUnmounted(() => {
class="h-11 justify-center border border-accented bg-default text-highlighted ring-0 md:h-8" class="h-11 justify-center border border-accented bg-default text-highlighted ring-0 md:h-8"
@click="uploadAnyway" @click="uploadAnyway"
> >
Upload anyway {{ t('Upload anyway') }}
</UButton> </UButton>
</div> </div>
</div> </div>
@@ -822,7 +859,9 @@ onUnmounted(() => {
<div <div
ref="imageBoxRef" ref="imageBoxRef"
class="relative h-full max-w-full" class="relative h-full max-w-full"
:style="{ aspectRatio: `${boundsWidth} / ${boundsHeight}` }" :style="{
aspectRatio: `${boundsWidth} / ${boundsHeight}`,
}"
:aria-label="`${chosenFile?.name}, ${boundsWidth} × ${boundsHeight} px`" :aria-label="`${chosenFile?.name}, ${boundsWidth} × ${boundsHeight} px`"
role="img" role="img"
> >
@@ -846,7 +885,11 @@ onUnmounted(() => {
data-testid="crop-frame" data-testid="crop-frame"
tabindex="0" tabindex="0"
role="group" role="group"
aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it." :aria-label="
t(
'Crop area. Arrow keys move it, Shift and arrow keys resize it.',
)
"
class="absolute cursor-move touch-none outline outline-white" class="absolute cursor-move touch-none outline outline-white"
:style="{ :style="{
left: `${(rect.x / boundsWidth) * 100}%`, left: `${(rect.x / boundsWidth) * 100}%`,
@@ -900,7 +943,9 @@ onUnmounted(() => {
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="flex-1 font-medium text-highlighted">Aspect ratio</span> <span class="flex-1 font-medium text-highlighted">{{
t('Aspect ratio')
}}</span>
<UButton <UButton
color="neutral" color="neutral"
variant="ghost" variant="ghost"
@@ -908,7 +953,7 @@ onUnmounted(() => {
icon="i-lucide-rotate-ccw-square" icon="i-lucide-rotate-ccw-square"
@click="rotateImage" @click="rotateImage"
> >
Rotate {{ t('Rotate') }}
</UButton> </UButton>
<UButton <UButton
color="neutral" color="neutral"
@@ -917,12 +962,12 @@ onUnmounted(() => {
icon="i-lucide-undo-2" icon="i-lucide-undo-2"
@click="resetCrop" @click="resetCrop"
> >
Reset {{ t('Reset') }}
</UButton> </UButton>
</div> </div>
<div <div
role="radiogroup" role="radiogroup"
aria-label="Aspect ratio" :aria-label="t('Aspect ratio')"
class="flex w-full border border-accented sm:w-max" class="flex w-full border border-accented sm:w-max"
> >
<button <button
@@ -940,16 +985,16 @@ onUnmounted(() => {
" "
@click="selectRatio(ratio)" @click="selectRatio(ratio)"
> >
{{ ratio }} {{ ratio === 'Original' || ratio === 'Free' ? t(ratio) : ratio }}
</button> </button>
</div> </div>
</div> </div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">Width</span> <span class="font-medium text-highlighted">{{ t('Width') }}</span>
<div <div
role="radiogroup" role="radiogroup"
aria-label="Width" :aria-label="t('Width')"
class="flex w-full border border-accented sm:w-max" class="flex w-full border border-accented sm:w-max"
> >
<button <button
@@ -967,7 +1012,7 @@ onUnmounted(() => {
" "
@click="selectWidth(widthOption)" @click="selectWidth(widthOption)"
> >
{{ widthOption }} {{ widthOption === 'Original' ? t(widthOption) : widthOption }}
</button> </button>
</div> </div>
<span class="font-mono text-xs text-muted">{{ monoLine }}</span> <span class="font-mono text-xs text-muted">{{ monoLine }}</span>
@@ -992,7 +1037,7 @@ onUnmounted(() => {
:class="succeeded ? 'size-6 text-success' : 'size-6 text-dimmed'" :class="succeeded ? 'size-6 text-success' : 'size-6 text-dimmed'"
/> />
<p class="text-sm text-highlighted"> <p class="text-sm text-highlighted">
<template v-if="succeeded">Image added</template> <template v-if="succeeded">{{ t('Image added') }}</template>
<template v-else>{{ chosenFile?.name }} {{ uploadPercent }}%</template> <template v-else>{{ chosenFile?.name }} {{ uploadPercent }}%</template>
</p> </p>
<UProgress <UProgress
@@ -1014,15 +1059,19 @@ onUnmounted(() => {
class="flex flex-col items-center gap-3 rounded-md border border-error px-6 py-8 text-center" class="flex flex-col items-center gap-3 rounded-md border border-error px-6 py-8 text-center"
> >
<UIcon name="i-lucide-file-x" class="size-6 text-error" /> <UIcon name="i-lucide-file-x" class="size-6 text-error" />
<p class="text-sm font-semibold text-highlighted">{{ rejectedTitle }}</p> <p class="text-sm font-semibold text-highlighted">
{{ rejectedTitle }}
</p>
<p class="text-sm text-toned">{{ rejectedText }}</p> <p class="text-sm text-toned">{{ rejectedText }}</p>
</div> </div>
</div> </div>
<div v-else-if="phase === 'failed'" class="flex flex-col gap-1"> <div v-else-if="phase === 'failed'" class="flex flex-col gap-1">
<p class="text-sm font-semibold text-highlighted">Upload failed</p> <p class="text-sm font-semibold text-highlighted">
{{ t('Upload failed') }}
</p>
<p class="text-sm text-toned"> <p class="text-sm text-toned">
The server didn't accept the file. Your article hasn't changed. {{ t("The server didn't accept the file. Your article hasn't changed.") }}
</p> </p>
</div> </div>
</div> </div>
@@ -1047,7 +1096,7 @@ onUnmounted(() => {
:disabled="!selected" :disabled="!selected"
@click="insertSelected" @click="insertSelected"
> >
Insert image {{ t('Insert image') }}
</UButton> </UButton>
<UButton <UButton
v-if="showRejectedActions" v-if="showRejectedActions"
@@ -1055,7 +1104,7 @@ onUnmounted(() => {
:class="FOOTER_BUTTON_CLASS" :class="FOOTER_BUTTON_CLASS"
@click="chooseAnotherFile" @click="chooseAnotherFile"
> >
Choose another file {{ t('Choose another file') }}
</UButton> </UButton>
<UButton <UButton
v-if="showFailedActions" v-if="showFailedActions"
@@ -1063,7 +1112,7 @@ onUnmounted(() => {
:class="FOOTER_BUTTON_CLASS" :class="FOOTER_BUTTON_CLASS"
@click="tryAgain" @click="tryAgain"
> >
Try again {{ t('Try again') }}
</UButton> </UButton>
<template v-if="showCropActions"> <template v-if="showCropActions">
<UButton <UButton
@@ -1074,10 +1123,10 @@ onUnmounted(() => {
class="text-highlighted" class="text-highlighted"
@click="chooseAnotherFile" @click="chooseAnotherFile"
> >
Choose another file {{ t('Choose another file') }}
</UButton> </UButton>
<UButton block :class="FOOTER_BUTTON_CLASS" @click="confirmCropUpload"> <UButton block :class="FOOTER_BUTTON_CLASS" @click="confirmCropUpload">
Upload image {{ t('Upload image') }}
</UButton> </UButton>
</template> </template>
</div> </div>
+11 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
const props = withDefaults( const props = withDefaults(
@@ -13,19 +15,25 @@ const props = withDefaults(
const badges = { const badges = {
accepted: { accepted: {
label: 'Accepted', get label() {
return t('Accepted')
},
icon: 'i-lucide-circle-check', icon: 'i-lucide-circle-check',
color: 'success', color: 'success',
class: 'border border-(--rc-success-soft) bg-(--rc-success-soft) text-success', class: 'border border-(--rc-success-soft) bg-(--rc-success-soft) text-success',
}, },
pending: { pending: {
label: 'Pending', get label() {
return t('Pending')
},
icon: 'i-lucide-mail', icon: 'i-lucide-mail',
color: 'warning', color: 'warning',
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning', class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
}, },
expired: { expired: {
label: 'Expired', get label() {
return t('Expired')
},
icon: 'i-lucide-clock-alert', icon: 'i-lucide-clock-alert',
color: 'neutral', color: 'neutral',
class: 'border border-accented bg-transparent text-muted', class: 'border border-accented bg-transparent text-muted',
+20 -12
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { ApiError } from '@/composables/useApi' import { ApiError } from '@/composables/useApi'
import { useUsers, type CreatedInvite } from '@/composables/useUsers' import { useUsers, type CreatedInvite } from '@/composables/useUsers'
@@ -24,18 +26,18 @@ const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed'>('')
const emailValid = computed(() => EMAIL_PATTERN.test(email.value)) const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
const emailError = computed( const emailError = computed(
() => serverError.value || (touched.value && !emailValid.value ? INVALID_EMAIL_MESSAGE : ''), () => serverError.value || (touched.value && !emailValid.value ? t(INVALID_EMAIL_MESSAGE) : ''),
) )
const alertMessage = computed(() => { const alertMessage = computed(() => {
if (formAlert.value === 'invalid') { if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields." return t("Something in the form isn't valid. Check the highlighted fields.")
} }
if (formAlert.value === 'rateLimited') { if (formAlert.value === 'rateLimited') {
return 'Too many invites. Wait a moment, then try again.' return t('Too many invites. Wait a moment, then try again.')
} }
if (formAlert.value === 'failed') { if (formAlert.value === 'failed') {
return "Couldn't send the invite. Try again in a moment." return t("Couldn't send the invite. Try again in a moment.")
} }
return '' return ''
}) })
@@ -82,7 +84,9 @@ function onBlur() {
} }
function messageForConflict(error: ApiError) { function messageForConflict(error: ApiError) {
return error.body === PENDING_INVITE_MESSAGE ? PENDING_INVITE_MESSAGE : HAS_ACCOUNT_MESSAGE return error.body === PENDING_INVITE_MESSAGE
? t(PENDING_INVITE_MESSAGE)
: t(HAS_ACCOUNT_MESSAGE)
} }
function showFailure(error: unknown) { function showFailure(error: unknown) {
@@ -143,8 +147,12 @@ function onCancel() {
<template> <template>
<UModal <UModal
:open="open" :open="open"
title="Invite a user" :title="t('Invite a user')"
description="We'll email them a link to choose a username and password. The link expires after 3 days." :description="
t(
'We\'ll email them a link to choose a username and password. The link expires after 3 days.',
)
"
:dismissible="!submitting" :dismissible="!submitting"
:close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }" :close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }"
@update:open="onOpenChange" @update:open="onOpenChange"
@@ -173,9 +181,9 @@ function onCancel() {
/> />
<UForm :state="{ email }" class="flex flex-col gap-4" @submit="onSubmit"> <UForm :state="{ email }" class="flex flex-col gap-4" @submit="onSubmit">
<UFormField <UFormField
label="Email" :label="t('Email')"
name="email" name="email"
:error="emailError || undefined" :error="translate(emailError) || undefined"
:ui="{ error: 'mt-1.5 text-xs/5' }" :ui="{ error: 'mt-1.5 text-xs/5' }"
> >
<template v-if="emailError" #error="{ error }"> <template v-if="emailError" #error="{ error }">
@@ -184,7 +192,7 @@ function onCancel() {
name="i-lucide-circle-alert" name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error" class="size-3.5 shrink-0 text-error"
/> />
{{ error }} {{ t(error) }}
</span> </span>
</template> </template>
<UInput <UInput
@@ -215,7 +223,7 @@ function onCancel() {
:disabled="submitting" :disabled="submitting"
@click="onCancel" @click="onCancel"
> >
Cancel {{ t('Cancel') }}
</UButton> </UButton>
<UButton <UButton
block block
@@ -225,7 +233,7 @@ function onCancel() {
:disabled="submitting" :disabled="submitting"
@click="onSubmit" @click="onSubmit"
> >
{{ submitting ? 'Sending…' : 'Send invite' }} {{ submitting ? t('Sending…') : t('Send invite') }}
</UButton> </UButton>
</div> </div>
</template> </template>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { locale, t } from '@/i18n'
import { chooseLanguage, languageName, languageOptions } from '@/composables/useLanguage'
import { LANGUAGE_MENU_CLASS, useLanguageMenuRadios } from '@/composables/useRadioMenuRoles'
const props = defineProps<{ mobile?: boolean }>()
const open = ref(false)
useLanguageMenuRadios(open)
const items = computed(() =>
languageOptions.map((option) => ({
type: 'checkbox' as const,
label: option.label,
checked: locale.value === option.value,
class: props.mobile
? 'h-11 items-center py-0 text-base font-normal text-highlighted'
: 'h-8 items-center py-0 text-base font-normal text-highlighted',
onUpdateChecked: () => chooseLanguage(option.value),
})),
)
const chipClass = computed(() => [
props.mobile ? 'h-11' : 'h-7',
'gap-1.5 rounded-none border border-accented bg-transparent px-2 text-sm font-normal text-toned ring-0 hover:bg-elevated',
open.value ? 'bg-elevated' : '',
])
function focusCurrentRow() {
document.querySelector<HTMLElement>(`.${LANGUAGE_MENU_CLASS} [aria-checked="true"]`)?.focus()
}
watch(open, (value) => {
if (value) setTimeout(focusCurrentRow, 0)
})
</script>
<template>
<UDropdownMenu
v-model:open="open"
:items="items"
:content="{
side: 'top',
align: 'end',
sideOffset: 8,
loop: true,
}"
:ui="{ content: `w-42 p-1 ${LANGUAGE_MENU_CLASS}`, itemTrailingIcon: 'size-4' }"
>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-languages"
:aria-label="t('Language: {0}', [languageName])"
:aria-expanded="open"
:class="chipClass"
:ui="{ leadingIcon: 'size-3.5 text-toned' }"
>
{{ languageName }}
</UButton>
</UDropdownMenu>
</template>
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue' import MediaBadge from '@/components/MediaBadge.vue'
defineProps<{ defineProps<{
@@ -10,7 +12,7 @@ defineProps<{
<template> <template>
<MediaBadge <MediaBadge
:kind="published ? 'online' : 'draft'" :kind="published ? 'online' : 'draft'"
:label="published ? 'Online' : 'Draft'" :label="published ? t('Online') : t('Draft')"
:size="size" :size="size"
/> />
</template> </template>
+6 -4
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import { useElementBounding, useMediaQuery } from '@vueuse/core' import { useElementBounding, useMediaQuery } from '@vueuse/core'
@@ -24,14 +26,14 @@ const centre = computed(() => `${content.left.value + content.width.value / 2}px
<template> <template>
<div <div
role="region" role="region"
aria-label="Selection" :aria-label="t('Selection')"
data-testid="media-bulk-bar" data-testid="media-bulk-bar"
class="fixed bottom-4 z-(--z-banner) flex items-center gap-2 bg-inverted py-2 pr-2 pl-4 text-inverted shadow-(--shadow-popover) max-md:inset-x-4 md:bottom-6 md:left-(--bulk-centre) md:w-100 md:-translate-x-1/2" class="fixed bottom-4 z-(--z-banner) flex items-center gap-2 bg-inverted py-2 pr-2 pl-4 text-inverted shadow-(--shadow-popover) max-md:inset-x-4 md:bottom-6 md:left-(--bulk-centre) md:w-100 md:-translate-x-1/2"
:class="{ invisible: obscured }" :class="{ invisible: obscured }"
:inert="obscured || undefined" :inert="obscured || undefined"
:style="isDesktop ? { '--bulk-centre': centre } : undefined" :style="isDesktop ? { '--bulk-centre': centre } : undefined"
> >
<span class="flex-1 font-medium">{{ count }} selected</span> <span class="flex-1 font-medium">{{ formatNumber(count) }} {{ t('selected') }}</span>
<UButton <UButton
color="error" color="error"
icon="i-lucide-trash-2" icon="i-lucide-trash-2"
@@ -40,7 +42,7 @@ const centre = computed(() => `${content.left.value + content.width.value / 2}px
class="h-11 px-3 md:h-8" class="h-11 px-3 md:h-8"
@click="emit('delete')" @click="emit('delete')"
> >
Delete {{ t('Delete') }}
</UButton> </UButton>
<UButton <UButton
color="neutral" color="neutral"
@@ -49,7 +51,7 @@ const centre = computed(() => `${content.left.value + content.width.value / 2}px
class="h-11 border border-(--ui-text-inverted) bg-transparent px-3 text-inverted ring-0 hover:bg-transparent md:h-8" class="h-11 border border-(--ui-text-inverted) bg-transparent px-3 text-inverted ring-0 hover:bg-transparent md:h-8"
@click="emit('clear')" @click="emit('clear')"
> >
Clear {{ t('Clear') }}
</UButton> </UButton>
</div> </div>
</template> </template>
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue' import MediaBadge from '@/components/MediaBadge.vue'
defineProps<{ defineProps<{
@@ -7,7 +9,7 @@ defineProps<{
</script> </script>
<template> <template>
<MediaBadge v-if="item.duplicate" kind="duplicate" label="Duplicate" /> <MediaBadge v-if="item.duplicate" kind="duplicate" :label="t('Duplicate')" />
<MediaBadge v-if="item.used_in > 0" kind="used" :label="`Used in ${item.used_in}`" /> <MediaBadge v-if="item.used_in > 0" kind="used" :label="t('Used in {0}', [item.used_in])" />
<MediaBadge v-else kind="unused" label="Unused" /> <MediaBadge v-else kind="unused" :label="t('Unused')" />
</template> </template>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import MediaCardBadges from '@/components/MediaCardBadges.vue' import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue' import MediaCheckbox from '@/components/MediaCheckbox.vue'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
@@ -65,7 +67,7 @@ const emit = defineEmits<{
> >
<MediaCheckbox <MediaCheckbox
:state="selected.includes(item.id) ? 'checked' : 'unchecked'" :state="selected.includes(item.id) ? 'checked' : 'unchecked'"
:label="`Select ${item.filename}`" :label="t('Select {0}', [item.filename])"
:tabbable="false" :tabbable="false"
@toggle="emit('toggle', item.id)" @toggle="emit('toggle', item.id)"
/> />
+57 -16
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core' import { useMediaQuery } from '@vueuse/core'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue' import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
@@ -47,11 +49,36 @@ const metadata = computed(() => {
return [] return []
} }
return [ return [
{ label: 'Type', value: formatType(item.content_type) }, {
{ label: 'Size', value: formatSize(item.size) }, get label() {
{ label: 'Dimensions', value: formatDimensions(item.width, item.height) }, return t('Type')
{ label: 'Uploaded', value: formatMediaDate(item.created_at) }, },
{ label: 'Uploaded by', value: item.uploaded_by }, value: formatType(item.content_type),
},
{
get label() {
return t('Size')
},
value: formatSize(item.size),
},
{
get label() {
return t('Dimensions')
},
value: formatDimensions(item.width, item.height),
},
{
get label() {
return t('Uploaded')
},
value: formatMediaDate(item.created_at),
},
{
get label() {
return t('Uploaded by')
},
value: item.uploaded_by,
},
] ]
}) })
@@ -63,8 +90,18 @@ const duplicateAlert = computed(() => {
} }
const names = props.copies.map((copy) => copy.filename) const names = props.copies.map((copy) => copy.filename)
return { return {
title: `Identical to ${props.copies.length} other ${props.copies.length === 1 ? 'image' : 'images'}`, get title() {
description: `${joinNames(names)} ${names.length === 1 ? 'has' : 'have'} the same content.`, return t('Identical to {0} other {1}', [
props.copies.length,
props.copies.length === 1 ? 'image' : 'images',
])
},
get description() {
return t('{0} {1} the same content.', [
joinNames(names),
names.length === 1 ? 'has' : 'have',
])
},
} }
}) })
</script> </script>
@@ -74,7 +111,7 @@ const duplicateAlert = computed(() => {
v-model:open="open" v-model:open="open"
side="right" side="right"
:title="item?.filename ?? ''" :title="item?.filename ?? ''"
description="Image details" :description="t('Image details')"
:close="closeButton" :close="closeButton"
:ui="{ :ui="{
content: 'w-full max-w-none md:max-w-115', content: 'w-full max-w-none md:max-w-115',
@@ -102,7 +139,7 @@ const duplicateAlert = computed(() => {
:class="ACTION_BUTTON" :class="ACTION_BUTTON"
@click="emit('copy-url')" @click="emit('copy-url')"
> >
Copy URL {{ t('Copy URL') }}
</UButton> </UButton>
<UButton <UButton
color="neutral" color="neutral"
@@ -112,7 +149,7 @@ const duplicateAlert = computed(() => {
:class="ACTION_BUTTON" :class="ACTION_BUTTON"
@click="emit('rename')" @click="emit('rename')"
> >
Rename {{ t('Rename') }}
</UButton> </UButton>
<UButton <UButton
color="neutral" color="neutral"
@@ -123,7 +160,7 @@ const duplicateAlert = computed(() => {
class="text-error" class="text-error"
@click="emit('delete')" @click="emit('delete')"
> >
Delete {{ t('Delete') }}
</UButton> </UButton>
</div> </div>
@@ -149,7 +186,7 @@ const duplicateAlert = computed(() => {
:ui="{ leadingIcon: 'size-3.5' }" :ui="{ leadingIcon: 'size-3.5' }"
@click="emit('review-duplicates')" @click="emit('review-duplicates')"
> >
Review duplicates {{ t('Review duplicates') }}
</UButton> </UButton>
</template> </template>
</UAlert> </UAlert>
@@ -169,8 +206,8 @@ const duplicateAlert = computed(() => {
<h3 class="text-base font-semibold text-highlighted"> <h3 class="text-base font-semibold text-highlighted">
{{ {{
item.used_in > 0 item.used_in > 0
? `Used in ${pluralArticles(item.used_in)}` ? t('Used in {0}', [pluralArticles(item.used_in)])
: 'Not used in any article' : t('Not used in any article')
}} }}
</h3> </h3>
<template v-if="item.used_in > 0"> <template v-if="item.used_in > 0">
@@ -196,11 +233,15 @@ const duplicateAlert = computed(() => {
</li> </li>
</ul> </ul>
<p class="text-xs text-muted"> <p class="text-xs text-muted">
Counted from the text of your articles each time you open this page. {{
t(
'Counted from the text of your articles each time you open this page.',
)
}}
</p> </p>
</template> </template>
<p v-else class="text-muted"> <p v-else class="text-muted">
This image is safe to delete. No article shows it. {{ t('This image is safe to delete. No article shows it.') }}
</p> </p>
</section> </section>
</div> </div>
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { DROP_TEXT, DROP_TITLE } from '@/lib/mediaUploadRows' import { DROP_TEXT, DROP_TITLE } from '@/lib/mediaUploadRows'
</script> </script>
@@ -9,7 +10,7 @@ import { DROP_TEXT, DROP_TITLE } from '@/lib/mediaUploadRows'
class="pointer-events-none fixed inset-2 top-16 z-(--z-popover) flex flex-col items-center justify-center gap-2 border-2 border-dashed border-primary bg-secondary-200 text-center text-primary-900 md:inset-3 md:top-15 dark:bg-secondary-300" class="pointer-events-none fixed inset-2 top-16 z-(--z-popover) flex flex-col items-center justify-center gap-2 border-2 border-dashed border-primary bg-secondary-200 text-center text-primary-900 md:inset-3 md:top-15 dark:bg-secondary-300"
> >
<UIcon name="i-lucide-upload" class="size-7" /> <UIcon name="i-lucide-upload" class="size-7" />
<span class="text-lg font-semibold">{{ DROP_TITLE }}</span> <span class="text-lg font-semibold">{{ t(DROP_TITLE) }}</span>
<span class="text-primary-600">{{ DROP_TEXT }}</span> <span class="text-primary-600">{{ t(DROP_TEXT) }}</span>
</div> </div>
</template> </template>
+27 -11
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40 const QUERY_MAX_LENGTH = 40
@@ -19,26 +21,40 @@ const current = computed(() => {
if (props.variant === 'first-run') { if (props.variant === 'first-run') {
return { return {
icon: 'i-lucide-images', icon: 'i-lucide-images',
title: 'No images yet', get title() {
description: 'Upload your first image. You can use it in any article.', return t('No images yet')
action: 'Upload an image', },
get description() {
return t('Upload your first image. You can use it in any article.')
},
action: t('Upload an image'),
} }
} }
if (props.variant === 'no-duplicates') { if (props.variant === 'no-duplicates') {
return { return {
icon: 'i-lucide-copy', icon: 'i-lucide-copy',
title: 'No duplicates', get title() {
description: 'Every image in your library is different.', return t('No duplicates')
action: 'Back to all images', },
get description() {
return t('Every image in your library is different.')
},
action: t('Back to all images'),
} }
} }
return { return {
icon: 'i-lucide-search', icon: 'i-lucide-search',
title: truncatedQuery.value get title() {
? `No images match "${truncatedQuery.value}"` return truncatedQuery.value
: 'No images match this filter', ? t('No images match \u0022{0}\u0022', [truncatedQuery.value])
description: `Nothing matches in ${props.filterLabel ?? 'All'}. Check the spelling, or clear the filters.`, : t('No images match this filter')
action: 'Clear filters', },
get description() {
return t('Nothing matches in {0}. Check the spelling, or clear the filters.', [
props.filterLabel ?? t('All'),
])
},
action: t('Clear filters'),
} }
}) })
</script> </script>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue' import { ref } from 'vue'
import MediaCardBadges from '@/components/MediaCardBadges.vue' import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue' import MediaCheckbox from '@/components/MediaCheckbox.vue'
@@ -127,7 +129,7 @@ function onKeyup(event: KeyboardEvent) {
> >
<MediaCheckbox <MediaCheckbox
:state="selected.includes(item.id) ? 'checked' : 'unchecked'" :state="selected.includes(item.id) ? 'checked' : 'unchecked'"
:label="`Select ${item.filename}`" :label="t('Select {0}', [item.filename])"
:tabbable="false" :tabbable="false"
@toggle="emit('toggle', item.id)" @toggle="emit('toggle', item.id)"
/> />
+26 -19
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core' import { useMediaQuery } from '@vueuse/core'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
@@ -55,7 +57,7 @@ const firstLoadSkeletons = computed(() =>
) )
const searching = computed(() => props.query !== '') const searching = computed(() => props.query !== '')
const summary = computed(() => const summary = computed(() =>
searching.value ? 'Newest first' : `Newest first · ${props.total} images`, searching.value ? t('Newest first') : t('Newest first · {0} images', [props.total]),
) )
const scrollHeight = computed(() => (props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77')) const scrollHeight = computed(() => (props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'))
const showEmptyLibrary = computed( const showEmptyLibrary = computed(
@@ -66,7 +68,7 @@ const showNoResults = computed(
) )
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0) const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
const showLoadMore = computed(() => showGrid.value && props.hasMore && !props.selected) const showLoadMore = computed(() => showGrid.value && props.hasMore && !props.selected)
const noResultsTitle = computed(() => `No images match "${props.query}"`) const noResultsTitle = computed(() => t('No images match \u0022{0}\u0022', [props.query]))
function clearDebounce() { function clearDebounce() {
if (debounceTimer !== null) { if (debounceTimer !== null) {
@@ -133,10 +135,13 @@ onBeforeUnmount(clearDebounce)
v-model="searchText" v-model="searchText"
type="text" type="text"
icon="i-lucide-search" icon="i-lucide-search"
placeholder="Search by filename" :placeholder="t('Search by filename')"
aria-label="Search by filename" :aria-label="t('Search by filename')"
class="w-full" class="w-full"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm', leadingIcon: SEARCH_ICON_SIZE }" :ui="{
base: 'h-11 text-lg md:h-8 md:text-sm',
leadingIcon: SEARCH_ICON_SIZE,
}"
> >
<template v-if="searchText" #trailing> <template v-if="searchText" #trailing>
<UButton <UButton
@@ -144,7 +149,7 @@ onBeforeUnmount(clearDebounce)
variant="link" variant="link"
size="xs" size="xs"
icon="i-lucide-x" icon="i-lucide-x"
aria-label="Clear search" :aria-label="t('Clear search')"
class="text-muted" class="text-muted"
@click="clearSearch" @click="clearSearch"
/> />
@@ -159,8 +164,8 @@ onBeforeUnmount(clearDebounce)
orientation="vertical" orientation="vertical"
:close="false" :close="false"
icon="i-lucide-circle-x" icon="i-lucide-circle-x"
title="Couldn't load your library" :title="t('Couldn\'t load your library')"
description="Check your connection and try again." :description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }" :ui="{ actions: 'mt-2' }"
> >
<template #actions> <template #actions>
@@ -172,7 +177,7 @@ onBeforeUnmount(clearDebounce)
:ui="{ leadingIcon: 'size-3.5' }" :ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry')" @click="emit('retry')"
> >
Try again {{ t('Try again') }}
</UButton> </UButton>
</template> </template>
</UAlert> </UAlert>
@@ -181,13 +186,13 @@ onBeforeUnmount(clearDebounce)
v-else-if="showEmptyLibrary" v-else-if="showEmptyLibrary"
variant="naked" variant="naked"
icon="i-lucide-images" icon="i-lucide-images"
title="Your library is empty" :title="t('Your library is empty')"
description="Images you upload appear here, ready to use in any article." :description="t('Images you upload appear here, ready to use in any article.')"
:ui="EMPTY_STATE_UI" :ui="EMPTY_STATE_UI"
> >
<template #actions> <template #actions>
<UButton icon="i-lucide-upload" class="h-11 px-4 md:h-8" @click="emit('upload')"> <UButton icon="i-lucide-upload" class="h-11 px-4 md:h-8" @click="emit('upload')">
Upload an image {{ t('Upload an image') }}
</UButton> </UButton>
</template> </template>
</UEmpty> </UEmpty>
@@ -197,7 +202,7 @@ onBeforeUnmount(clearDebounce)
variant="naked" variant="naked"
icon="i-lucide-search" icon="i-lucide-search"
:title="noResultsTitle" :title="noResultsTitle"
description="Check the spelling, or search for a shorter part of the filename." :description="t('Check the spelling, or search for a shorter part of the filename.')"
:ui="EMPTY_STATE_UI" :ui="EMPTY_STATE_UI"
> >
<template #actions> <template #actions>
@@ -207,7 +212,7 @@ onBeforeUnmount(clearDebounce)
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8" class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="clearSearch" @click="clearSearch"
> >
Clear search {{ t('Clear search') }}
</UButton> </UButton>
</template> </template>
</UEmpty> </UEmpty>
@@ -259,7 +264,7 @@ onBeforeUnmount(clearDebounce)
<span <span
v-if="isInArticle(item)" v-if="isInArticle(item)"
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-secondary-200 px-1.25 text-[0.6875rem] font-medium text-primary-900 dark:bg-secondary-300" class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-secondary-200 px-1.25 text-[0.6875rem] font-medium text-primary-900 dark:bg-secondary-300"
>In this article</span >{{ t('In this article') }}</span
> >
<span <span
v-if="isSelected(item)" v-if="isSelected(item)"
@@ -291,10 +296,12 @@ onBeforeUnmount(clearDebounce)
:icon="loadingMore ? 'i-lucide-loader-circle' : undefined" :icon="loadingMore ? 'i-lucide-loader-circle' : undefined"
:aria-disabled="loadingMore || undefined" :aria-disabled="loadingMore || undefined"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 aria-disabled:opacity-40 md:h-8" class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 aria-disabled:opacity-40 md:h-8"
:ui="{ leadingIcon: loadingMore ? 'size-4 animate-spin' : 'size-4' }" :ui="{
leadingIcon: loadingMore ? 'size-4 animate-spin' : 'size-4',
}"
@click="emit('load-more')" @click="emit('load-more')"
> >
{{ loadingMore ? 'Loading…' : 'Load more' }} {{ loadingMore ? t('Loading…') : t('Load more') }}
</UButton> </UButton>
</div> </div>
</template> </template>
@@ -317,7 +324,7 @@ onBeforeUnmount(clearDebounce)
</div> </div>
</div> </div>
<UFormField <UFormField
label="Alt text (optional)" :label="t('Alt text (optional)')"
help="Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration." help="Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration."
:ui="{ :ui="{
label: 'font-medium text-highlighted', label: 'font-medium text-highlighted',
@@ -327,7 +334,7 @@ onBeforeUnmount(clearDebounce)
<UInput <UInput
v-model="alt" v-model="alt"
type="text" type="text"
placeholder="e.g. North wall of the kitchen before plastering" :placeholder="t('e.g. North wall of the kitchen before plastering')"
class="w-full" class="w-full"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }" :ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
/> />
+4 -2
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import type { SelectAllState } from '@/composables/useMediaSelection' import type { SelectAllState } from '@/composables/useMediaSelection'
@@ -15,14 +17,14 @@ const modelValue = computed(() => {
} }
return props.state === 'all' return props.state === 'all'
}) })
const imageCount = computed(() => `${props.count} ${props.count === 1 ? 'image' : 'images'}`) const imageCount = computed(() => t(props.count === 1 ? '{0} image' : '{0} images', [props.count]))
</script> </script>
<template> <template>
<div class="flex items-center gap-2.5 text-muted"> <div class="flex items-center gap-2.5 text-muted">
<UCheckbox <UCheckbox
:model-value="modelValue" :model-value="modelValue"
label="Select all" :label="t('Select all')"
color="primary" color="primary"
class="-ml-2.75 md:ml-0" class="-ml-2.75 md:ml-0"
:ui="{ :ui="{
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
defineProps<{ defineProps<{
count: number count: number
}>() }>()
@@ -10,7 +12,7 @@ defineProps<{
data-testid="media-skeleton" data-testid="media-skeleton"
class="grid grid-cols-2 gap-2.5 md:grid-cols-5 md:gap-4" class="grid grid-cols-2 gap-2.5 md:grid-cols-5 md:gap-4"
> >
<span role="status" class="sr-only">Loading images</span> <span role="status" class="sr-only">{{ t('Loading images') }}</span>
<div <div
v-for="n in count" v-for="n in count"
:key="n" :key="n"
+13 -11
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue' import MediaBadge from '@/components/MediaBadge.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue' import MediaCheckbox from '@/components/MediaCheckbox.vue'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
@@ -44,19 +46,19 @@ function open(event: Event, id: number) {
<span class="flex justify-center"> <span class="flex justify-center">
<MediaCheckbox <MediaCheckbox
size="sm" size="sm"
label="Select all" :label="t('Select all')"
:state="ALL_STATES[allState]" :state="ALL_STATES[allState]"
@toggle="emit('toggle-all')" @toggle="emit('toggle-all')"
/> />
</span> </span>
<span>Filename</span> <span>{{ t('Filename') }}</span>
<span>Dimensions</span> <span>{{ t('Dimensions') }}</span>
<span>Size</span> <span>{{ t('Size') }}</span>
<span>Type</span> <span>{{ t('Type') }}</span>
<span>Used in</span> <span>{{ t('Used in') }}</span>
<span>Uploaded by</span> <span>{{ t('Uploaded by') }}</span>
<span class="flex items-center gap-1 text-highlighted"> <span class="flex items-center gap-1 text-highlighted">
Uploaded {{ t('Uploaded') }}
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" /> <UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span> </span>
</div> </div>
@@ -77,7 +79,7 @@ function open(event: Event, id: number) {
<span class="relative z-10 flex justify-center"> <span class="relative z-10 flex justify-center">
<MediaCheckbox <MediaCheckbox
size="sm" size="sm"
:label="`Select ${item.filename}`" :label="t('Select {0}', [item.filename])"
:state="selected.includes(item.id) ? 'checked' : 'unchecked'" :state="selected.includes(item.id) ? 'checked' : 'unchecked'"
@toggle="emit('toggle', item.id)" @toggle="emit('toggle', item.id)"
/> />
@@ -94,7 +96,7 @@ function open(event: Event, id: number) {
> >
{{ item.filename }} {{ item.filename }}
</button> </button>
<MediaBadge v-if="item.duplicate" kind="duplicate" label="Duplicate" /> <MediaBadge v-if="item.duplicate" kind="duplicate" :label="t('Duplicate')" />
</span> </span>
<span>{{ formatDimensions(item.width, item.height) }}</span> <span>{{ formatDimensions(item.width, item.height) }}</span>
<span>{{ formatSize(item.size) }}</span> <span>{{ formatSize(item.size) }}</span>
@@ -109,7 +111,7 @@ function open(event: Event, id: number) {
> >
{{ pluralArticles(item.used_in) }} {{ pluralArticles(item.used_in) }}
</button> </button>
<MediaBadge v-else kind="unused" label="Unused" /> <MediaBadge v-else kind="unused" :label="t('Unused')" />
</span> </span>
<span class="truncate pr-2">{{ item.uploaded_by }}</span> <span class="truncate pr-2">{{ item.uploaded_by }}</span>
<span>{{ formatMediaDate(item.created_at) }}</span> <span>{{ formatMediaDate(item.created_at) }}</span>
+60 -17
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { MediaCounts } from '@/composables/useMedia' import type { MediaCounts } from '@/composables/useMedia'
import type { MediaControls, MediaFilter, MediaSort, MediaView } from '@/lib/mediaQuery' import type { MediaControls, MediaFilter, MediaSort, MediaView } from '@/lib/mediaQuery'
@@ -7,15 +9,35 @@ import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
const SEARCH_DEBOUNCE_MS = 120 const SEARCH_DEBOUNCE_MS = 120
const SORT_LABELS: Record<MediaSort, string> = { const SORT_LABELS: Record<MediaSort, string> = {
newest: 'Newest', get newest() {
oldest: 'Oldest', return t('Newest')
name: 'Name', },
largest: 'Largest', get oldest() {
return t('Oldest')
},
get name() {
return t('Name')
},
get largest() {
return t('Largest')
},
} }
const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [ const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
{ value: 'grid', icon: 'i-lucide-layout-grid', label: 'Grid view' }, {
{ value: 'list', icon: 'i-lucide-list', label: 'List view' }, value: 'grid',
icon: 'i-lucide-layout-grid',
get label() {
return t('Grid view')
},
},
{
value: 'list',
icon: 'i-lucide-list',
get label() {
return t('List view')
},
},
] ]
const props = defineProps<{ const props = defineProps<{
@@ -68,9 +90,27 @@ function clearSearch() {
onBeforeUnmount(clearDebounce) onBeforeUnmount(clearDebounce)
const tabItems = computed(() => [ const tabItems = computed(() => [
{ label: 'All', value: 'all', count: props.counts?.all }, {
{ label: 'Unused', value: 'unused', count: props.counts?.unused }, get label() {
{ label: 'Duplicates', value: 'duplicates', count: props.counts?.duplicates }, return t('All')
},
value: 'all',
count: props.counts?.all,
},
{
get label() {
return t('Unused')
},
value: 'unused',
count: props.counts?.unused,
},
{
get label() {
return t('Duplicates')
},
value: 'duplicates',
count: props.counts?.duplicates,
},
]) ])
const tab = computed({ const tab = computed({
@@ -115,12 +155,12 @@ const sortItems = computed(() =>
v-if="item.value === 'duplicates' && item.count" v-if="item.value === 'duplicates' && item.count"
data-testid="duplicates-count" data-testid="duplicates-count"
class="inline-flex h-4.5 min-w-4.5 items-center justify-center bg-(--rc-info-soft) px-1.25 font-mono text-[0.6875rem] font-medium text-info" class="inline-flex h-4.5 min-w-4.5 items-center justify-center bg-(--rc-info-soft) px-1.25 font-mono text-[0.6875rem] font-medium text-info"
>{{ item.count }}</span >{{ item.count === undefined ? undefined : formatNumber(item.count) }}</span
> >
<span <span
v-else-if="item.value !== 'duplicates' && item.count !== undefined" v-else-if="item.value !== 'duplicates' && item.count !== undefined"
class="font-mono text-[0.6875rem] font-medium text-muted" class="font-mono text-[0.6875rem] font-medium text-muted"
>{{ item.count }}</span >{{ item.count === undefined ? undefined : formatNumber(item.count) }}</span
> >
</span> </span>
</template> </template>
@@ -130,10 +170,13 @@ const sortItems = computed(() =>
v-model="searchText" v-model="searchText"
type="text" type="text"
icon="i-lucide-search" icon="i-lucide-search"
placeholder="Search by filename" :placeholder="t('Search by filename')"
aria-label="Search by filename" :aria-label="t('Search by filename')"
class="order-1 w-full md:order-none md:mb-1.5 md:w-60" class="order-1 w-full md:order-none md:mb-1.5 md:w-60"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm', leadingIcon: 'size-3.5' }" :ui="{
base: 'h-11 text-lg md:h-8 md:text-sm',
leadingIcon: 'size-3.5',
}"
> >
<template v-if="searchText" #trailing> <template v-if="searchText" #trailing>
<UButton <UButton
@@ -141,7 +184,7 @@ const sortItems = computed(() =>
variant="link" variant="link"
size="xs" size="xs"
icon="i-lucide-x" icon="i-lucide-x"
aria-label="Clear search" :aria-label="t('Clear search')"
class="text-muted" class="text-muted"
@click="clearSearch" @click="clearSearch"
/> />
@@ -154,7 +197,7 @@ const sortItems = computed(() =>
color="neutral" color="neutral"
variant="outline" variant="outline"
trailing-icon="i-lucide-chevron-down" trailing-icon="i-lucide-chevron-down"
aria-label="Sort" :aria-label="t('Sort')"
class="h-11 flex-1 justify-between border border-accented bg-default px-2.5 text-sm font-normal text-highlighted ring-0 md:h-8 md:w-32 md:flex-none" class="h-11 flex-1 justify-between border border-accented bg-default px-2.5 text-sm font-normal text-highlighted ring-0 md:h-8 md:w-32 md:flex-none"
:ui="{ trailingIcon: 'size-3.5 text-muted' }" :ui="{ trailingIcon: 'size-3.5 text-muted' }"
> >
@@ -162,7 +205,7 @@ const sortItems = computed(() =>
</UButton> </UButton>
</UDropdownMenu> </UDropdownMenu>
<div role="group" aria-label="View" class="flex shrink-0 border border-accented"> <div role="group" :aria-label="t('View')" class="flex shrink-0 border border-accented">
<UButton <UButton
v-for="option in VIEW_OPTIONS" v-for="option in VIEW_OPTIONS"
:key="option.value" :key="option.value"
+17 -12
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
import type { UploadRow } from '@/composables/useMediaUpload' import type { UploadRow } from '@/composables/useMediaUpload'
import { formatUploadProgress } from '@/lib/imageUpload' import { formatUploadProgress } from '@/lib/imageUpload'
@@ -48,7 +50,7 @@ function percent(row: UploadRow) {
<template> <template>
<section <section
data-testid="media-upload-panel" data-testid="media-upload-panel"
aria-label="Uploads" :aria-label="t('Uploads')"
class="flex flex-col border border-default bg-default" class="flex flex-col border border-default bg-default"
> >
<div class="flex items-center gap-3 px-3.5 py-3"> <div class="flex items-center gap-3 px-3.5 py-3">
@@ -64,7 +66,7 @@ function percent(row: UploadRow) {
class="h-11 border border-accented bg-default px-3 text-xs text-highlighted ring-0 md:h-7" class="h-11 border border-accented bg-default px-3 text-xs text-highlighted ring-0 md:h-7"
@click="header.phase === 'working' ? emit('cancel-all') : emit('dismiss')" @click="header.phase === 'working' ? emit('cancel-all') : emit('dismiss')"
> >
{{ header.phase === 'working' ? 'Cancel all' : 'Dismiss' }} {{ header.phase === 'working' ? t('Cancel all') : t('Dismiss') }}
</UButton> </UButton>
</div> </div>
@@ -83,7 +85,7 @@ function percent(row: UploadRow) {
</div> </div>
<template v-if="row.status === 'waiting'"> <template v-if="row.status === 'waiting'">
<span class="text-muted">Waiting</span> <span class="text-muted">{{ t('Waiting') }}</span>
</template> </template>
<template v-else-if="row.status === 'uploading'"> <template v-else-if="row.status === 'uploading'">
@@ -91,19 +93,22 @@ function percent(row: UploadRow) {
<UProgress <UProgress
:model-value="percent(row)" :model-value="percent(row)"
size="sm" size="sm"
:ui="{ base: 'rounded-none', indicator: 'rounded-none' }" :ui="{
base: 'rounded-none',
indicator: 'rounded-none',
}"
/> />
</template> </template>
<template v-else-if="row.status === 'added'"> <template v-else-if="row.status === 'added'">
<span class="flex items-center gap-1.5 text-success"> <span class="flex items-center gap-1.5 text-success">
<UIcon name="i-lucide-circle-check" class="size-3.5 shrink-0" /> <UIcon name="i-lucide-circle-check" class="size-3.5 shrink-0" />
{{ ADDED_TEXT }} {{ t(ADDED_TEXT) }}
</span> </span>
</template> </template>
<template v-else-if="row.status === 'duplicate' && row.existing"> <template v-else-if="row.status === 'duplicate' && row.existing">
<span class="font-semibold text-highlighted">{{ DUPLICATE_TITLE }}</span> <span class="font-semibold text-highlighted">{{ t(DUPLICATE_TITLE) }}</span>
<span class="text-xs/5 text-toned">{{ <span class="text-xs/5 text-toned">{{
duplicateText(row.existing, formatMediaDate) duplicateText(row.existing, formatMediaDate)
}}</span> }}</span>
@@ -114,7 +119,7 @@ function percent(row: UploadRow) {
:class="BUTTON" :class="BUTTON"
@click="emit('use-existing', row.key)" @click="emit('use-existing', row.key)"
> >
Use existing {{ t('Use existing') }}
</UButton> </UButton>
<UButton <UButton
color="neutral" color="neutral"
@@ -122,7 +127,7 @@ function percent(row: UploadRow) {
:class="BUTTON" :class="BUTTON"
@click="emit('upload-anyway', row.key)" @click="emit('upload-anyway', row.key)"
> >
Upload anyway {{ t('Upload anyway') }}
</UButton> </UButton>
</div> </div>
</template> </template>
@@ -138,9 +143,9 @@ function percent(row: UploadRow) {
<template v-else-if="row.status === 'failed'"> <template v-else-if="row.status === 'failed'">
<span class="flex items-center gap-1.5 font-semibold text-error"> <span class="flex items-center gap-1.5 font-semibold text-error">
<UIcon name="i-lucide-circle-x" class="size-3.5 shrink-0" /> <UIcon name="i-lucide-circle-x" class="size-3.5 shrink-0" />
{{ FAILED_TITLE }} {{ t(FAILED_TITLE) }}
</span> </span>
<span class="text-xs/5 text-toned">{{ FAILED_TEXT }}</span> <span class="text-xs/5 text-toned">{{ t(FAILED_TEXT) }}</span>
<div class="flex"> <div class="flex">
<UButton <UButton
color="neutral" color="neutral"
@@ -150,7 +155,7 @@ function percent(row: UploadRow) {
:ui="{ leadingIcon: 'size-3.5' }" :ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry', row.key)" @click="emit('retry', row.key)"
> >
Try again {{ t('Try again') }}
</UButton> </UButton>
</div> </div>
</template> </template>
@@ -160,7 +165,7 @@ function percent(row: UploadRow) {
color="neutral" color="neutral"
variant="ghost" variant="ghost"
icon="i-lucide-x" icon="i-lucide-x"
:aria-label="`Cancel upload of ${row.name}`" :aria-label="t('Cancel upload of {0}', [row.name])"
class="size-6 justify-center text-muted" class="size-6 justify-center text-muted"
:ui="{ leadingIcon: 'size-3.5' }" :ui="{ leadingIcon: 'size-3.5' }"
@click="emit('cancel-row', row.key)" @click="emit('cancel-row', row.key)"
+18 -9
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
@@ -15,21 +17,27 @@ const props = defineProps<{
const emit = defineEmits<{ close: [confirmed: boolean] }>() const emit = defineEmits<{ close: [confirmed: boolean] }>()
const otherCount = computed(() => props.copies.length - 1) const otherCount = computed(() => props.copies.length - 1)
const others = computed(() => (otherCount.value === 1 ? 'copy' : `${otherCount.value} copies`)) const others = computed(() =>
otherCount.value === 1 ? t('copy') : t('{0} copies', [otherCount.value]),
)
const articles = computed(() => mergeArticleCount(props.copies, props.keep.id)) const articles = computed(() => mergeArticleCount(props.copies, props.keep.id))
const description = computed( const description = computed(() =>
() => t(
`Articles that use the other ${others.value} will be switched to ${props.keep.filename}, and the other ${others.value} will be deleted. ${pluralArticles(articles.value)} will be updated. This can't be undone.`, "Articles that use the other {0} will be switched to {1}, and the other {2} will be deleted. {3} will be updated. This can't be undone.",
[others.value, props.keep.filename, others.value, pluralArticles(articles.value)],
),
) )
</script> </script>
<template> <template>
<ConfirmDialog <ConfirmDialog
:title="`Merge into ${keep.filename}?`" :title="t('Merge into {0}?', [keep.filename])"
:description="description" :description="description"
:confirm-label="`Merge ${otherCount === 1 ? '1 copy' : `${otherCount} copies`}`" :confirm-label="
cancel-label="Cancel" t('Merge {0}', [otherCount === 1 ? t('1 copy') : t('{0} copies', [otherCount])])
pending-label="Merging…" "
:cancel-label="t('Cancel')"
:pending-label="t('Merging…')"
tone="danger" tone="danger"
:action="action" :action="action"
@close="emit('close', $event)" @close="emit('close', $event)"
@@ -40,7 +48,8 @@ const description = computed(
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<span class="truncate font-medium text-highlighted">{{ keep.filename }}</span> <span class="truncate font-medium text-highlighted">{{ keep.filename }}</span>
<span class="text-xs/5 text-muted" <span class="text-xs/5 text-muted"
>Kept · used in {{ pluralArticles(keep.used_in) }} now</span >{{ t('Kept · used in') }} {{ pluralArticles(keep.used_in) }}
{{ t('now') }}</span
> >
</div> </div>
</div> </div>
+33 -19
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import CharacterCounter from '@/components/CharacterCounter.vue' import CharacterCounter from '@/components/CharacterCounter.vue'
import { ApiError, useApi } from '@/composables/useApi' import { ApiError, useApi } from '@/composables/useApi'
@@ -34,10 +36,10 @@ const titleLengthError = computed(() => {
return '' return ''
} }
if (titleLength.value < TITLE_MIN_LENGTH) { if (titleLength.value < TITLE_MIN_LENGTH) {
return 'Use at least 3 characters.' return t('Use at least 3 characters.')
} }
if (titleLength.value > TITLE_MAX_LENGTH) { if (titleLength.value > TITLE_MAX_LENGTH) {
return 'Use 3 to 200 characters.' return t('Use 3 to 200 characters.')
} }
return '' return ''
}) })
@@ -46,7 +48,7 @@ const titleError = computed(() => titleServerError.value || titleLengthError.val
const bodyError = computed(() => const bodyError = computed(() =>
bodyTouched.value && !bodyValid.value bodyTouched.value && !bodyValid.value
? 'Add a first paragraph. It can be a single sentence.' ? t('Add a first paragraph. It can be a single sentence.')
: '', : '',
) )
@@ -56,10 +58,10 @@ const showHelp = computed(() => !submitting.value && formAlert.value === '')
const alertMessage = computed(() => { const alertMessage = computed(() => {
if (formAlert.value === 'invalid') { if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields." return t("Something in the form isn't valid. Check the highlighted fields.")
} }
if (formAlert.value === 'failed') { if (formAlert.value === 'failed') {
return "Couldn't create the article. Your text is still here. Try again in a moment." return t("Couldn't create the article. Your text is still here. Try again in a moment.")
} }
return '' return ''
}) })
@@ -78,7 +80,9 @@ function resetForm() {
async function focusTitleField() { async function focusTitleField() {
await nextTick() await nextTick()
await nextTick() await nextTick()
document.querySelector<HTMLInputElement>(`input[placeholder="${TITLE_PLACEHOLDER}"]`)?.focus() document
.querySelector<HTMLInputElement>(`input[placeholder="${t(TITLE_PLACEHOLDER)}"]`)
?.focus()
titleFocused.value = true titleFocused.value = true
} }
@@ -116,7 +120,7 @@ async function onSubmit() {
formAlert.value = '' formAlert.value = ''
submitting.value = true submitting.value = true
try { try {
const article = await api.request<Article>('/v1/article/', { const article = await api.request<Article>('/v1/article/?format=html', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.value, body: body.value }), body: JSON.stringify({ title: title.value, body: body.value }),
@@ -147,8 +151,8 @@ function onCancel() {
<template> <template>
<UModal <UModal
v-model:open="open" v-model:open="open"
title="New article" :title="t('New article')"
description="It's saved as a draft. Titles can't be changed later, so check it now." :description="t('It\'s saved as a draft. Titles can\'t be changed later, so check it now.')"
:dismissible="!submitting" :dismissible="!submitting"
:ui="{ :ui="{
content: content:
@@ -173,7 +177,11 @@ function onCancel() {
:description="alertMessage" :description="alertMessage"
/> />
<UForm :state="{ title, body }" class="flex flex-col gap-4"> <UForm :state="{ title, body }" class="flex flex-col gap-4">
<UFormField label="Title" name="title" :error="titleError || undefined"> <UFormField
:label="t('Title')"
name="title"
:error="translate(titleError) || undefined"
>
<template #hint> <template #hint>
<CharacterCounter <CharacterCounter
:value="titleLength" :value="titleLength"
@@ -188,13 +196,15 @@ function onCancel() {
name="i-lucide-circle-alert" name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error" class="size-3.5 shrink-0 text-error"
/> />
{{ error }} {{ t(error) }}
</span> </span>
</template> </template>
<template v-else-if="showHelp" #help>3 to 200 characters.</template> <template v-else-if="showHelp" #help>{{
t('3 to 200 characters.')
}}</template>
<UInput <UInput
v-model="title" v-model="title"
:placeholder="TITLE_PLACEHOLDER" :placeholder="t(TITLE_PLACEHOLDER)"
:disabled="submitting" :disabled="submitting"
color="primary" color="primary"
:highlight="titleFocused || !!titleError" :highlight="titleFocused || !!titleError"
@@ -204,22 +214,26 @@ function onCancel() {
@blur="onTitleBlur" @blur="onTitleBlur"
/> />
</UFormField> </UFormField>
<UFormField label="First paragraph" name="body" :error="bodyError || undefined"> <UFormField
:label="t('First paragraph')"
name="body"
:error="bodyError || undefined"
>
<template v-if="bodyError" #error="{ error }"> <template v-if="bodyError" #error="{ error }">
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<UIcon <UIcon
name="i-lucide-circle-alert" name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error" class="size-3.5 shrink-0 text-error"
/> />
{{ error }} {{ t(error) }}
</span> </span>
</template> </template>
<template v-else-if="showHelp" #help> <template v-else-if="showHelp" #help>
You can format it and add images in the editor. {{ t('You can format it and add images in the editor.') }}
</template> </template>
<UTextarea <UTextarea
v-model="body" v-model="body"
placeholder="Start writing. You'll continue in the editor." :placeholder="t('Start writing. You\'ll continue in the editor.')"
:disabled="submitting" :disabled="submitting"
:highlight="!!bodyError" :highlight="!!bodyError"
:rows="3" :rows="3"
@@ -244,7 +258,7 @@ function onCancel() {
:disabled="submitting" :disabled="submitting"
@click="onCancel" @click="onCancel"
> >
Cancel {{ t('Cancel') }}
</UButton> </UButton>
<UButton <UButton
block block
@@ -254,7 +268,7 @@ function onCancel() {
:disabled="submitting" :disabled="submitting"
@click="onSubmit" @click="onSubmit"
> >
{{ submitting ? 'Creating…' : 'Create draft' }} {{ submitting ? t('Creating…') : t('Create draft') }}
</UButton> </UButton>
</div> </div>
</template> </template>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { translate } from '@/i18n'
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
const DEFAULT_MESSAGE = const DEFAULT_MESSAGE =
@@ -43,7 +45,7 @@ onUnmounted(() => {
v-if="isOffline" v-if="isOffline"
color="neutral" color="neutral"
:icon="icon" :icon="icon"
:title="message" :title="translate(message)"
:ui="bleed ? BLEED_UI : undefined" :ui="bleed ? BLEED_UI : undefined"
/> />
</template> </template>
+2 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
interface PasswordRule { interface PasswordRule {
@@ -47,7 +48,7 @@ const items = computed(() =>
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]"> <li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" /> <UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
<span :class="item.textClass">{{ item.label }}</span> <span :class="item.textClass">{{ item.label }}</span>
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span> <span class="sr-only">{{ t(item.met ? 'met' : 'not met') }}</span>
</li> </li>
</ul> </ul>
</template> </template>
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
const props = withDefaults( const props = withDefaults(
@@ -23,12 +25,12 @@ const label = computed(() => {
return props.pending return props.pending
} }
if (props.online) { if (props.online) {
return 'Unpublish' return t('Unpublish')
} }
if (props.dirty) { if (props.dirty) {
return 'Save and publish' return t('Save and publish')
} }
return 'Publish' return t('Publish')
}) })
const icon = computed(() => { const icon = computed(() => {
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue' import { ref } from 'vue'
defineProps<{ defineProps<{
@@ -23,7 +25,7 @@ const open = ref(false)
class="min-h-11 self-start gap-1.5 p-0 font-medium text-highlighted md:min-h-8" class="min-h-11 self-start gap-1.5 p-0 font-medium text-highlighted md:min-h-8"
:ui="{ leadingIcon: 'size-3.5' }" :ui="{ leadingIcon: 'size-3.5' }"
> >
What's new in {{ version }} {{ t("What's new in") }} {{ version }}
</UButton> </UButton>
<template #content> <template #content>
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more