chore: groundwork for activity page
This commit is contained in:
1 parent
dc5d12348c
commit
8907bb49fe
11 files changed
+732
-2
No files matched your search
@@ -33,6 +33,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
||||
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [API keys](screens/integration-api-keys.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` |
|
||||
| [Activity](screens/activity.md) | `/activity` | 9 (plus 6 details, 3 My activity) | `source/RenewCMS Screens Activity.dc.html` |
|
||||
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html`, `source/RenewCMS Screens Media cleanup.dc.html` |
|
||||
| [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
@@ -41,6 +42,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
||||
|
||||
## Other boards
|
||||
|
||||
- `source/ActivityFrame.dc.html`: the frame that draws every Activity state, the Home card and the Settings retention card (the Activity board imports it). Its copy, component and API tables are in `source/activity-spec.js`. It holds the exact values. Home and Settings are drawn around dashed placeholders.
|
||||
- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values.
|
||||
- `source/SettingsFrame.dc.html`: the Media cleanup card and its purge dialog (the Media cleanup board imports it, with `MediaFrame.dc.html` for the Duplicates toolbar button). They hold the exact values.
|
||||
- `source/IntegrationPage.dc.html` and `source/ApiKeysPage.dc.html`: the page components for the Integration and API keys screens (imported by `AppFrame.dc.html`). They hold the exact values.
|
||||
|
||||
@@ -829,3 +829,23 @@ Settings card. `USwitch` (square, `rounded-none`), `UInputNumber` with "days" su
|
||||
|
||||
### Media toolbar button
|
||||
Secondary `UButton` icon `merge`, label "Purge duplicates", after the sort menu on desktop; full width in the stacked toolbar on mobile (44 px).
|
||||
|
||||
## Added with Activity
|
||||
Source: `source/activity-spec.js` (component inventory) and `source/ActivityFrame.dc.html`. All built on existing Nuxt UI v4 components and tokens; no new token.
|
||||
|
||||
| Component | Nuxt UI base | Props | Emits | Slots | Used on |
|
||||
|---|---|---|---|---|---|
|
||||
| ActivityPage | custom page | none (reads the session role) | none | none | Activity |
|
||||
| ActivitySummaryStrip | UCard (as button) | summary, loading, activeFilter | apply-filter | none | Activity (owner, admin) |
|
||||
| ActivityFilterBar | UInput, USelectMenu, UButton, UBadge | filters, people, canFilterPeople | update:filters, clear | none | Activity |
|
||||
| ActivityTable | UTable (day separator rows), UAvatar, UBadge, UPagination, USelectMenu | items, total, page, size, loading | open, update:page, update:size | empty | Activity (md and up) |
|
||||
| ActivityCardList | UCard | same as ActivityTable | open | empty | Activity (below md) |
|
||||
| ActivityDetailsPanel | USlideover (right on desktop, bottom on mobile), UCard, UButton | item, index, total, open | previous, next, close | none | Activity |
|
||||
| ActivityDiff | custom list | changes | none | none | ActivityDetailsPanel |
|
||||
| ActivityFeedCard | UCard, UAvatar, UButton | items, loading, error, own | retry | none | Home |
|
||||
| RetentionCard | UCard, USelectMenu, UAlert, UButton | settings, canEdit | saved | none | Settings (owner) |
|
||||
|
||||
- Sparkline: seven `div` bars inside the strip card, no chart library. The strip cards are real `button`s with the card as their label.
|
||||
- Day separators are full-width rows of the table. The "When" cell carries the exact time as its `title`.
|
||||
- The sentence is composed from the `action` code and params with the i18n messages (English and French); the target name is user content and is not translated.
|
||||
- Failed and security rows use `--rc-error-soft` / `--ui-error` and `--rc-warning-soft` / `--ui-warning` for emphasis, always paired with an icon or a word, never colour alone.
|
||||
@@ -11,15 +11,16 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it
|
||||
| `/register/validate` | AuthShell | public | VerifyPage | POST /v1/auth/verify (+ POST /login inline on 401) |
|
||||
| `/register/reset/request` | AuthShell | public | ResetRequestPage | POST /v1/auth/password-reset |
|
||||
| `/register/reset/validate` | AuthShell | public | ResetConfirmPage | POST /v1/auth/password-reset/confirm |
|
||||
| `/home` | DashboardShell | verified | HomePage | GET /v1/article/ · GET /v1/system/update (owner, for the banner) |
|
||||
| `/home` | DashboardShell | verified | HomePage | GET /v1/article/ · GET /v1/activity?size=5 · GET /v1/system/update (owner, for the banner) |
|
||||
| `/article` | DashboardShell | verified | ArticlesPage | GET /v1/article/ · PATCH /{id}/publish\|unpublish · DELETE /{id} |
|
||||
| `/article/new` | DashboardShell | verified | ArticlesPage + NewArticleModal | POST /v1/article/ |
|
||||
| `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · GET /v1/media/ · POST /v1/media/ · DELETE /{id} |
|
||||
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/purge-preview · POST /v1/media/purge · GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage |
|
||||
| `/activity` | DashboardShell | verified | ActivityPage + ActivityDetailsPanel | GET /v1/activity · GET /v1/activity/summary (owner, admin) · GET /v1/activity/export (owner, admin) |
|
||||
| `/integration` | DashboardShell | verified | IntegrationPage | none |
|
||||
| `/integration/api-keys` | DashboardShell | verified | ApiKeysPage + CreateApiKeyModal | GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} |
|
||||
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew |
|
||||
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart · GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge |
|
||||
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · PATCH /v1/settings (owner, retention) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart · GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge |
|
||||
| `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset |
|
||||
| `/invite/accept` | AuthShell | public | AcceptInvitePage | GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept* |
|
||||
| `/:pathMatch(.*)*` | either | any | NotFoundPage | none |
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Activity
|
||||
|
||||
- **Route:** `/activity`
|
||||
- **API:** GET /v1/activity · GET /v1/activity/summary · GET /v1/activity/export (see "API contract" below)
|
||||
- **Visual reference:** `source/RenewCMS Screens Activity.dc.html`, group "Activity, owner and admin" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). The frame is `source/ActivityFrame.dc.html`; its copy, component mapping and API tables are `source/activity-spec.js`.
|
||||
|
||||
The audit log of the back-office: who did what, and when, across articles, media, users and invites, API keys, authentication and system tasks. It is for non-technical owners, so every row reads as a sentence, not a log line. Events are append-only: nothing in the app edits or deletes them. Only the retention period removes them (Settings, "Retention").
|
||||
|
||||
Other groups of the same board:
|
||||
|
||||
| Group | What it draws | Written up in |
|
||||
|---|---|---|
|
||||
| Details slide-over | The slide-over and its bottom-sheet variant, six events | this file, "Details slide-over" |
|
||||
| My activity | The same screen for a user who is not owner or admin | this file, "My activity" |
|
||||
| Home card | The Recent activity card on Home | `screens/home.md`, "Recent activity card" |
|
||||
| Settings retention | The Retention card on Settings | `screens/settings.md`, "Activity retention" |
|
||||
|
||||
## Roles
|
||||
|
||||
Owner and admin see every event, the summary strip, the Person filter and Export. Any other role sees only its own events: the title becomes "My activity", the strip, the Person filter and Export are hidden, and the server scopes the list to that user (asking for another actor answers 403).
|
||||
|
||||
Today the product has only `owner` and `admin` (see `screens/users.md`, "Roles"), so "My activity" and the scoped answer cannot be reached by a real account yet. They are specified and tested now so a future role needs no rework. The board's sample "Editor" role and the nav it draws for a regular user (Home, Articles, Media, Activity) are illustrative.
|
||||
|
||||
## Sidebar
|
||||
|
||||
"Activity" (icon `history`) sits between Users and Integration for owner and admin: Home, Articles, Media, Users, Activity, Integration, Settings. The board draws the order that way. It is visible to every verified user. The mobile drawer gets the same item.
|
||||
|
||||
## The page
|
||||
|
||||
Title "Activity" (regular users: "My activity"). Subtitle "Who did what, and when, across your site." (regular users: "Everything you've done on this site."). Page padding 24 px desktop, 16 px mobile; the sidebar and top bar are the existing DashboardShell.
|
||||
|
||||
1. **Header action** (owner and admin): outline `UButton` with icon `download`, "Export CSV". While the file is prepared: "Preparing file…" and disabled. Success toast "Export started. Your file will download shortly." Error toast "Couldn't export activity. Try again." Disabled in "Empty, no activity yet".
|
||||
2. **Summary strip** (owner and admin): four `UCard`, each a `button`. Labels: "Total actions", "Active people", "Failed actions", "Security events". Each shows the number, a sub-line and a seven-bar sparkline (seven `div` bars, no chart library, oldest left).
|
||||
- Total: "12% vs previous 7 days" with `trending-up` / `trending-down` (the comparison is against the equal-length period just before the selected range). Click clears the filters.
|
||||
- Active people: "7 people, 2 API keys". Click does nothing (no equivalent filter).
|
||||
- Failed: "0.5% of all actions". Emphasis with `--rc-error-soft` and `--ui-error`, icon `circle-x`. Click applies Outcome = Failed.
|
||||
- Security: "5 failed logins". Emphasis with `--rc-warning-soft` and `--ui-warning`, icon `shield-alert`. Click applies the security filter (auth, role changes, API key create and revoke).
|
||||
- Zero: Failed shows "0" with "Nothing failed" and `circle-check`; Security shows "0" with "Nothing to flag" and `circle-check`; both drop the emphasis fill. No data at all: every card reads "0" with the sub-line "No data yet" (Failed and Security keep their calm lines).
|
||||
- The strip follows the date range filter. Mobile: a 2×2 grid.
|
||||
3. **Filter bar**: `UInput` (icon `search`, clearable, "Search activity"), Person `USelectMenu` (grouped: users, API keys, "System"; "All people"), Category `USelectMenu` ("All categories": Articles, Media, Users and invites, API keys, Authentication, System), Outcome button group (All / Succeeded / Failed; `soft` is the selected one), date presets (Today / 7 days / 30 days / Custom; Custom opens a date-range popover). Mobile: the bar collapses to a search field and a "Filters" button with a count badge; the controls move to a bottom sheet.
|
||||
4. **Active filter chips**: `UBadge` (subtle) with a trailing ghost `x` button: "Search: spring", "Person: Marta Alves", "Category: Authentication", "Outcome: Failed", "Dates: 1 – 30 Sep 2026", then a `link` button "Clear all". The default date range (7 days) is not a chip.
|
||||
5. **Timeline table**: newest first, grouped by day with full-width separator rows ("Today", "Yesterday", "3 Oct 2026"). Columns: When (relative within the day, clock time before; the full time is the cell's `title`), Actor (`UAvatar` with initials, a key icon for API keys or a cog for System, plus the name; badges "API key" / "System"; a signed-out actor reads "Not signed in"), Action (category icon and the sentence with the target name in semibold; a `shield` marker on security rows), Target (a link when it still exists; struck through with a "Deleted" badge when it does not), Outcome (`UBadge` "Succeeded" / "Failed"). Row click and Enter open the details.
|
||||
6. **Pagination**: "Showing 1–25 of 1,284", `UPagination`, "Rows per page" select (25, 50, 100). Mobile: "Page 1 of 52" with previous and next.
|
||||
7. **Mobile list**: below `md` the table is a stacked list of `UCard` rows (actor, sentence, time, outcome, "Target deleted" when relevant), 44 px touch targets.
|
||||
|
||||
Sentences are composed in the SPA from a stable `action` code and the event params, in the user's language (English and French), never stored as text. The target name and the typed values in a diff stay as the user wrote them.
|
||||
|
||||
## States
|
||||
|
||||
### Default, large dataset
|
||||
**Trigger:** Both requests resolved with 1,284 events over 52 pages. Newest first, day separators, a security row. Hover on the first row shows its exact time.
|
||||
|
||||
### Loading
|
||||
**Trigger:** Both requests in flight. Skeleton strip and table rows after 150 ms.
|
||||
|
||||
### Zero-value summary strip
|
||||
**Trigger:** A quiet period. Failed and Security show a calm 0.
|
||||
|
||||
### Filtered, card applied
|
||||
**Trigger:** The Failed actions card was clicked. Chips plus "Clear all"; mobile shows the count on Filters. Six results.
|
||||
|
||||
### Mobile filters sheet
|
||||
**Trigger:** Filters open as a bottom sheet on mobile. The desktop frame keeps the inline bar. Sheet: title "Filters", Person (owner and admin), Category, Outcome, Dates (two date fields when Custom), footer "Clear all" and "Show 23 results".
|
||||
|
||||
### Empty, no activity yet
|
||||
**Trigger:** A new site. `UEmpty` with icon `history`, title "No activity yet", description "Actions on articles, media, users and API keys will appear here." Export disabled.
|
||||
|
||||
### Empty, filters match nothing
|
||||
**Trigger:** Filters with zero results. Title "No activity matches these filters", description "Try a different search or a wider date range.", action "Clear filters".
|
||||
|
||||
### Error with retry
|
||||
**Trigger:** The list request failed (500 or network). `UEmpty` in the table area: "Couldn't load activity", "Check your connection and try again.", action "Try again". The strip still shows if its own call succeeded.
|
||||
|
||||
### Forbidden (403)
|
||||
**Trigger:** The API answered 403 for this role or filter. "You can't view this activity.", "Your role doesn't include this page. Ask an owner or admin if you need access.", action "Back to home". The 401 is global.
|
||||
|
||||
## Details slide-over
|
||||
|
||||
`USlideover`, right side on desktop (about 440 px wide), bottom sheet on mobile. Opens from a row (click or Enter), closes with Escape, the close button or the scrim; focus returns to the row. Title "Activity details".
|
||||
|
||||
Body, in `UCard` sections:
|
||||
|
||||
- The full sentence, with the target name in semibold.
|
||||
- When: the user's local time, then a muted "UTC" line.
|
||||
- Actor (with the same badges), Source ("Web app", "API key", "System task"), IP address, Browser. For a system event, IP address and Browser read "Not applicable".
|
||||
- Target: name (struck through with a "Deleted" badge, plus "This item was deleted. Its name is kept for reference." when it no longer exists), and a link when it exists.
|
||||
- Why it failed (failed events only): the plain-language reason from the API, never a stack trace.
|
||||
- Changes (edits only): rows of field, Before, After. Values longer than two lines clamp with "Show more" / "Show less". A body edit is a single row "Body edited (+312 characters)" and never shows text.
|
||||
- Footer: "Previous event" and "Next event" buttons and "Event 2 of 1,284". They move through the current filtered list, across page boundaries.
|
||||
|
||||
### Edit with a diff
|
||||
**Trigger:** A title change and a body edit. Title Before and After, then "Body edited (+312 characters)".
|
||||
|
||||
### Settings diff, long text
|
||||
**Trigger:** Long values clamp to two lines with "Show more".
|
||||
|
||||
### Failed action, very long target title
|
||||
**Trigger:** A failed publish. The reason is shown and the long target title wraps.
|
||||
|
||||
### Deleted target, no diff
|
||||
**Trigger:** A deleted image. Struck-through target, "Deleted" badge, no Changes section.
|
||||
|
||||
### System-initiated, no human actor
|
||||
**Trigger:** A scheduled task. "System" badge, source "System task", IP and Browser "Not applicable".
|
||||
|
||||
### Security event, failed login
|
||||
**Trigger:** The actor was not signed in. "Not signed in", a restrained `shield` marker, the reason, the IP and browser.
|
||||
|
||||
## My activity
|
||||
|
||||
The same screen for a role that is not owner or admin. No summary strip, no Person filter, no Export. The list is the user's own events (server scoped).
|
||||
|
||||
### Default
|
||||
**Trigger:** The user has events. Title "My activity".
|
||||
|
||||
### Empty
|
||||
**Trigger:** No events yet: "No activity yet", "Your actions will appear here once you start working on the site."
|
||||
|
||||
### Details
|
||||
**Trigger:** The slide-over on one of the user's own events.
|
||||
|
||||
## API contract
|
||||
|
||||
401 and 403 on every route follow `states.md`, "HTTP status to copy". The board's table in `source/activity-spec.js` is the source; this section fixes the parts it leaves open.
|
||||
|
||||
**`GET /v1/activity`**
|
||||
|
||||
- Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`, `api_keys`, `auth`, `system`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range` (`today`, `7d`, `30d`, `custom`), `from`, `to` (ISO dates, with `custom`), `page` (from 1), `size` (25, 50 or 100; default 25). Sorted by `created_at` descending, then `id` descending. Invalid values answer 400 "The request cannot be processed due to a mismatch in the format of the body."
|
||||
- `security=true` is what the Security card applies; it is a flag on the event, not a category.
|
||||
- Response: `{ items, total, page, size }`. An item:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 4821,
|
||||
"created_at": "2026-10-06T10:42:18Z",
|
||||
"action": "article.publish",
|
||||
"category": "articles",
|
||||
"actor": { "type": "user", "id": 3, "name": "Marta Alves" },
|
||||
"target": { "type": "article", "id": "8", "name": "Spring release", "exists": true },
|
||||
"outcome": "success",
|
||||
"failure_reason": null,
|
||||
"security": false,
|
||||
"source": "web",
|
||||
"ip": "203.0.113.12",
|
||||
"user_agent": "Chrome 129 on Windows",
|
||||
"changes": [{ "field": "online", "kind": "text", "before": "false", "after": "true" }]
|
||||
}
|
||||
```
|
||||
|
||||
- `actor.type` is `user`, `api_key`, `system` or `anonymous` (a failed login). `actor.name` is a snapshot taken when the event was recorded, so a renamed or deleted user still reads correctly. `target.name` is a snapshot too; `target.exists` is resolved when the list is read.
|
||||
- `action` is a stable dotted code. The SPA owns the wording, in English and French. `params` holds extra values a sentence needs (for example `{ "merged": 3, "articles_updated": 5 }` on a media purge); they are never secrets.
|
||||
- `changes[].kind` is `text` or `body`. A `body` change has `delta` (a signed number of characters) and no `before` or `after`. Only allowlisted fields ever appear in `changes`, and never a password, hash, API key, token or article body.
|
||||
- `source` is `web`, `api_key` or `system`. `ip` and `user_agent` are `null` for `system`.
|
||||
- A caller who is not owner or admin is scoped to its own events; `actor` other than itself answers 403.
|
||||
|
||||
**`GET /v1/activity/summary`** (owner and admin; 403 otherwise)
|
||||
|
||||
Same `range`, `from`, `to`. Response: `total`, `previous_total`, `active_people: { users, api_keys }`, `failed`, `security`, and `series` with seven daily counts for `total`, `active`, `failed` and `security`. The previous period is the equal-length period immediately before the range.
|
||||
|
||||
**`GET /v1/activity/export`** (owner and admin; 403 otherwise)
|
||||
|
||||
The same filters without `page` or `size`. Streams `text/csv; charset=utf-8` with `Content-Disposition: attachment`. Columns: `when` (RFC3339 UTC), `actor`, `action` (the code), `target`, `outcome`, `source`, `ip`. No changes, no user agent, no secrets. Cells that start with `=`, `+`, `-` or `@` are prefixed with a quote so a spreadsheet does not evaluate them.
|
||||
|
||||
**`GET /v1/settings` and `PATCH /v1/settings`**: see `screens/settings.md`, "Activity retention".
|
||||
|
||||
These routes are session only: with an API key they answer 403 "This action needs a real session." (A key never reads the audit trail.)
|
||||
|
||||
## What is recorded
|
||||
|
||||
Every row of the board is a sample. The real catalogue is the set of actions that exist in the product, in these categories:
|
||||
|
||||
| Category | Events |
|
||||
|---|---|
|
||||
| `articles` | create, edit (title, publish state, body size delta), publish, unpublish, delete |
|
||||
| `media` | upload, rename, delete, merge, purge (manual; the scheduled run is a `system` event) |
|
||||
| `users` | register, verify account, update profile (display name), invite create, cancel, renew, accept |
|
||||
| `api_keys` | create, revoke |
|
||||
| `auth` | login succeeded, login failed (security), logout, password reset requested (security), password reset completed (security) |
|
||||
| `system` | update checked, update downloaded, restart requested, scheduled media purge, activity retention changed, activity cleanup |
|
||||
|
||||
Not drawn as real events: the "Editor" role and role changes, a "Site description" or "Comments" setting, "Ran the nightly backup" (backups are a step of the update job, not a schedule), "Removed 3 expired invites" and an administrator resetting another user's password. They stay in the board as sample data and are only recorded if the matching feature exists.
|
||||
|
||||
Read requests are not recorded (no "article viewed"), and neither are public API reads.
|
||||
@@ -54,3 +54,13 @@ The greeting reads "Good morning, {name}", where the name is the display name wh
|
||||
**Trigger:** The download is checked. The same banner changes its copy, and a dismiss comes back on the next visit because the update is waiting.
|
||||
|
||||
- Banner: success tone, `circle-check` icon, title "Version 2.3.0 is ready to install", text "Restart from Settings to finish. The site is unavailable for about 10 seconds.", button "Go to Settings"
|
||||
|
||||
## Recent activity card
|
||||
|
||||
**API:** GET /v1/activity?size=5. Visual reference: `source/RenewCMS Screens Activity.dc.html`, group "Home card". The board draws the rest of Home as a dashed "Existing Home cards" placeholder, so this card is not pixel-checked against it.
|
||||
|
||||
A card with the last five events. Header "Recent activity" (regular users: "Your recent activity") and a "View all" link with `arrow-right` to `/activity`. Each row: a 24 px avatar, the sentence clamped to two lines with the target in semibold, then "Marta Alves · 2 min ago" (own events: only the time). A failed event gets a "Failed" badge; a successful security event a small `shield`.
|
||||
|
||||
- Placement as drawn: a 420 px column to the right of the existing cards on desktop; stacked on mobile, drawn above the existing cards. Confirm against the real Home layout before coding.
|
||||
- States: Owner or admin, Regular user, Loading (five skeleton rows after 150 ms), Error ("Couldn't load recent activity" with "Try again"), Empty ("No activity yet", "Actions on your site will appear here." / own: "Your actions will appear here.").
|
||||
- The card never blocks the page: an error or empty card leaves the stats and the article list as they are.
|
||||
@@ -177,3 +177,18 @@ Duplicates tab: secondary "Purge duplicates" button (merge) next to the sort men
|
||||
|
||||
### Components (Nuxt UI v4)
|
||||
`UCard`, `USwitch`, `UInputNumber` (min 1, max 365, suffix), `UFormField`, `UAlert`, `UButton`, `UModal`, `USkeleton`, `useToast`.
|
||||
|
||||
## Activity retention
|
||||
|
||||
**API:** GET /v1/settings · PATCH /v1/settings (owner only). Visual reference: `source/RenewCMS Screens Activity.dc.html`, group "Settings retention". The board draws the other cards as a dashed "Existing Settings cards" placeholder and gives the page a different subtitle ("Site-wide options. Only owners can change them."), which is not a decision to change the existing one.
|
||||
|
||||
A third card below Media cleanup, owner only (hidden for admins), same card styling, 640 px at most as drawn.
|
||||
|
||||
- Header: "Retention" / "How long activity is kept." "Saved" (check, success) at the right after a save, fading after 2 s.
|
||||
- "Keep activity for" `USelectMenu`: 30 days, 90 days, 365 days, Forever. Default 90 days. Helper: "Older events are removed automatically." For Forever: "Nothing is removed automatically. The log grows over time."
|
||||
- Info alert (`lock`): "Activity logs are append-only", "Events can't be edited or deleted from the app. Only the retention period removes them."
|
||||
- Divider, then read-only lines: "Last cleanup: 6 Oct 2026, 03:00 · 142 events removed" and "Next cleanup: 7 Oct 2026, 03:00". Forever: "Never" and "Not scheduled".
|
||||
- Saves on change. Choosing a shorter period than the current one first shows a warning alert, "Events older than 30 days will be removed", "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.", buttons "Apply 30 days" and "Cancel"; nothing is sent until Apply. Lengthening or choosing Forever saves at once.
|
||||
- Save error alert: "Couldn't save the retention period", "Your previous setting is still in place.", "Try again". The control reverts.
|
||||
- States: Default (90 days, saved), Shortening the period, Forever, Save error. Loading and Offline follow the Media cleanup card.
|
||||
- `GET /v1/settings` adds `activity_retention_days` (30, 90, 365 or 0 for forever), `activity_last_cleanup_at` (null before the first cleanup) and `activity_last_cleanup_removed`. `PATCH /v1/settings` takes `{ "activity_retention_days": 30 }`; any other value answers 400 "The request cannot be processed due to a mismatch in the format of the body."; a non-owner gets 403 "Only the owner can do this.". The cleanup runs once a day at 03:00 server time and never touches the last 24 hours.
|
||||
@@ -0,0 +1,252 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<div style="display:contents">{{ view }}</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":900},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":null,"tsType":"boolean"}}">
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
const s = this.props.s || {}, m = !!this.props.mobile, h = React.createElement
|
||||
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
|
||||
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
|
||||
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
|
||||
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } })
|
||||
const bh = m ? 44 : 32
|
||||
const MONO = "font-family:'IBM Plex Mono',monospace"
|
||||
const B = (label, o = {}) => {
|
||||
const k = o.k || 'outline'
|
||||
const bg = k === 'primary' ? 'var(--ui-primary)' : 'var(--ui-bg)'
|
||||
const fg = k === 'primary' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)'
|
||||
const bd = k === 'primary' ? bg : 'var(--ui-border-accented)'
|
||||
return S('height:' + (o.h || bh) + 'px;' + (o.w ? 'width:' + o.w + 'px;' : '') + 'padding:0 ' + (o.px ?? 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:' + (o.fs || 13) + 'px;white-space:nowrap;flex-shrink:0;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : ''), o.icon && I(o.icon, o.z || 16), label)
|
||||
}
|
||||
const BG = { ok: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'check'], fail: ['background:var(--rc-error-soft);color:var(--ui-error)', 'circle-x'], sec: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'shield-alert'], del: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'trash-2'], neutral: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)'], info: ['background:var(--rc-info-soft);color:var(--ui-info)'] }
|
||||
const Bd = (t, k, sz = 20) => S('height:' + sz + 'px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + BG[k][0], BG[k][1] && I(BG[k][1], 12), t)
|
||||
const Out = e => e.ok ? Bd('Succeeded', 'ok') : Bd('Failed', 'fail')
|
||||
const Alert = (tone, icon, title, text, action) => D('display:flex;gap:10px;padding:12px 14px;background:var(--rc-' + tone + '-soft);color:var(--ui-' + tone + ')', S('display:flex;padding-top:2px', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:8px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600', title), text && S('color:var(--ui-text-toned)', text)), action && D('display:flex;gap:8px', ...[].concat(action).map(a => B(a, { h: m ? 44 : 28, fs: 12 })))))
|
||||
const Empty = (icon, title, text, action, primary, pad = 56) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + pad + 'px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I(icon, 20)), S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);max-width:340px;text-wrap:pretty', text), action && B(action, { k: primary ? 'primary' : 'outline', px: 16 }))
|
||||
const Note = t => S('align-self:flex-start;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', t)
|
||||
const sk = (w, hh = 12, extra = '') => S('display:block;width:' + w + ';height:' + hh + 'px;background:var(--ui-bg-accented);flex-shrink:0;' + extra)
|
||||
|
||||
// ---------- data
|
||||
const U = n => ({ n, k: 'user' }), KEY = n => ({ n, k: 'key' }), SYS = { n: 'Scheduler', k: 'system' }, ANON = { n: 'Not signed in', k: 'anon' }
|
||||
const p2 = n => String(n).padStart(2, '0')
|
||||
const T = (d, hh, mm, ss) => ({ abs: d + ' 2026, ' + p2(hh) + ':' + p2(mm) + ':' + p2(ss), utc: d + ' 2026, ' + p2(hh - 1) + ':' + p2(mm) + ':' + p2(ss) + ' UTC' })
|
||||
const WEB = { src: 'Web app', ip: '203.0.113.24', ua: 'Firefox 131 on macOS' }
|
||||
const LONG = 'The complete guide to restoring a nineteenth-century timber-framed farmhouse: structural surveys, lime mortar, breathable insulation and what it all costs'
|
||||
const E = [
|
||||
{ id: 1, day: 'Today', rel: '2 min ago', ...T('6 Oct', 10, 42, 18), a: U('Marta Alves'), cat: 'articles', s: 'Published the article ', em: 'Spring release', tk: 'Article', ex: 1, ok: 1, tid: 'art_8f3k2', ...WEB, diff: [{ f: 'Publish state', b: 'Draft', a: 'Published' }] },
|
||||
{ id: 2, day: 'Today', rel: '29 min ago', ...T('6 Oct', 10, 15, 4), a: U('Marta Alves'), cat: 'articles', s: 'Edited the article ', em: 'Why we reuse original floorboards', tk: 'Article', ex: 1, ok: 1, tid: 'art_2m9xq', ...WEB, diff: [{ f: 'Title', b: 'Why we reuse floorboards', a: 'Why we reuse original floorboards' }, { f: 'Body', note: 'Body edited (+312 characters)' }] },
|
||||
{ id: 3, day: 'Today', rel: '1 h ago', ...T('6 Oct', 9, 31, 47), a: ANON, cat: 'auth', s: 'Failed login attempt for ', em: 'alex@example.com', ok: 0, sec: 1, why: 'The password did not match. 3 attempts in 10 minutes.', src: 'Web app', ip: '198.51.100.77', ua: 'Chrome 129 on Windows' },
|
||||
{ id: 4, day: 'Today', rel: '2 h ago', ...T('6 Oct', 8, 50, 9), a: U('Jonas Weber'), cat: 'keys', s: 'Revoked API key ', em: 'Mobile app', tk: 'API key', ex: 1, ok: 1, sec: 1, tid: 'key_71ab0', src: 'Web app', ip: '203.0.113.90', ua: 'Safari 18 on iOS' },
|
||||
{ id: 5, day: 'Today', rel: '7 h ago', ...T('6 Oct', 3, 0, 2), a: SYS, cat: 'system', s: 'Ran the nightly backup', em: '', ok: 1, src: 'System task', ip: null, ua: null },
|
||||
{ id: 6, day: 'Yesterday', rel: '18:20', ...T('5 Oct', 18, 20, 36), a: U('Inès Duarte'), cat: 'users', s: 'Changed the role of ', em: 'Jonas Weber', tk: 'User', ex: 1, ok: 1, sec: 1, tid: 'usr_c40d1', tail: ' from Editor to Admin', ...WEB, ip: '203.0.113.12', diff: [{ f: 'Role', b: 'Editor', a: 'Admin' }] },
|
||||
{ id: 7, day: 'Yesterday', rel: '16:05', ...T('5 Oct', 16, 5, 51), a: KEY('Website sync'), cat: 'media', s: 'Uploaded the image ', em: 'north-wall.jpg', tk: 'Image', ex: 1, ok: 1, tid: 'img_0pk4w', src: 'API key', ip: '192.0.2.18', ua: 'node-fetch 3.3' },
|
||||
{ id: 8, day: 'Yesterday', rel: '14:40', ...T('5 Oct', 14, 40, 13), a: U('Marta Alves'), cat: 'media', s: 'Deleted the image ', em: 'old-banner.png', tk: 'Image', ex: 0, ok: 1, tid: 'img_9zq1c', ...WEB },
|
||||
{ id: 9, day: 'Yesterday', rel: '11:12', ...T('5 Oct', 11, 12, 27), a: U('Inès Duarte'), cat: 'articles', s: 'Failed to publish the article ', em: LONG, tk: 'Article', ex: 1, ok: 0, tid: 'art_5t7yh', why: 'The article has no cover image. Add one, then publish again.', ...WEB, ip: '203.0.113.12' },
|
||||
{ id: 10, day: 'Yesterday', rel: '03:00', ...T('5 Oct', 3, 0, 1), a: SYS, cat: 'users', s: 'Removed 3 expired invites', em: '', ok: 1, src: 'System task', ip: null, ua: null },
|
||||
{ id: 11, day: '3 Oct 2026', rel: '16:48', ...T('3 Oct', 16, 48, 22), a: U('Inès Duarte'), cat: 'system', s: 'Changed the setting ', em: 'Site description', tk: 'Setting', ex: 1, ok: 1, tid: 'set_site_desc', ...WEB, ip: '203.0.113.12', diff: [{ f: 'Site description', b: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes.', a: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes, plus a seasonal maintenance checklist.', long: 1 }, { f: 'Comments', b: 'Off', a: 'On' }] },
|
||||
{ id: 12, day: '3 Oct 2026', rel: '15:02', ...T('3 Oct', 15, 2, 40), a: U('Jonas Weber'), cat: 'keys', s: 'Created API key ', em: 'Mobile app', tk: 'API key', ex: 0, ok: 1, sec: 1, tid: 'key_71ab0', ...WEB, ip: '203.0.113.90' },
|
||||
{ id: 13, day: '3 Oct 2026', rel: '14:20', ...T('3 Oct', 14, 20, 5), a: U('Inès Duarte'), cat: 'users', s: 'Updated the user ', em: 'Sam Rivera', tk: 'User', ex: 1, ok: 1, tid: 'usr_77ea2', ...WEB, ip: '203.0.113.12', diff: [{ f: 'Name', b: 'Sam Rivera', a: 'Samuel Rivera' }, { f: 'Email', b: 'sam@example.com', a: 'samuel@example.com' }] },
|
||||
{ id: 14, day: '3 Oct 2026', rel: '11:36', ...T('3 Oct', 11, 36, 58), a: U('Marta Alves'), cat: 'media', s: 'Uploaded the image ', em: 'floorboards-copy.jpg', tk: 'Image', ex: 1, ok: 1, tid: 'img_4ww8e', ...WEB },
|
||||
{ id: 15, day: '3 Oct 2026', rel: '09:05', ...T('3 Oct', 9, 5, 12), a: U('Inès Duarte'), cat: 'auth', s: 'Reset the password for ', em: 'Sam Rivera', tk: 'User', ex: 1, ok: 1, sec: 1, tid: 'usr_77ea2', ...WEB, ip: '203.0.113.12' }
|
||||
]
|
||||
const CI = { articles: 'file-text', media: 'image', users: 'users', keys: 'key-round', auth: 'shield-check', system: 'server-cog' }
|
||||
const CL = { articles: 'Articles', media: 'Media', users: 'Users & invites', keys: 'API keys', auth: 'Authentication', system: 'System' }
|
||||
const user = s.role === 'user'
|
||||
const ME = user ? 'Marta Alves' : 'Inès Duarte'
|
||||
let list = s.ids ? s.ids.map(i => E.find(e => e.id === i)) : (user ? E.filter(e => e.a.n === 'Marta Alves') : E)
|
||||
const total = s.total || (user ? '41' : '1,284')
|
||||
const sentence = e => e.s + e.em + (e.tail || '')
|
||||
const Av = (a, z = 24) => {
|
||||
const base = 'width:' + z + 'px;height:' + z + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;'
|
||||
if (a.k === 'user') return S(base + 'background:var(--rc-sand);color:var(--rc-on-sand);font:600 ' + Math.round(z * 0.42) + "px 'IBM Plex Sans',sans-serif", a.n.split(' ').map(x => x[0]).join('').slice(0, 2).toUpperCase())
|
||||
return S(base + 'border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I({ key: 'key-round', system: 'cog', anon: 'user-round-x' }[a.k], Math.round(z * 0.6)))
|
||||
}
|
||||
const ActorBd = a => a.k === 'key' ? Bd('API key', 'neutral') : a.k === 'system' ? Bd('System', 'neutral') : null
|
||||
const ell = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0'
|
||||
const link = 'text-decoration:underline;text-underline-offset:3px;color:var(--ui-text-highlighted)'
|
||||
const SecIcon = z => S('display:flex;color:var(--ui-warning)', I('shield-alert', z))
|
||||
|
||||
// ---------- controls
|
||||
const Search = () => D('height:' + bh + 'px;' + (m ? 'flex:1;' : 'width:240px;') + 'padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (F.q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (m ? ';font-size:16px' : ''), I('search', 14), S('flex:1;' + ell, F.q || 'Search activity'), F.q && S('width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
|
||||
const Sel = (val, ph, w, icon) => D('height:' + bh + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (val ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + ';' + (w ? 'width:' + w + 'px;flex-shrink:0' : 'width:100%'), icon && I(icon, 14, 'color:var(--ui-text-muted)'), S('flex:1;' + ell, val || ph), I('chevron-down', 14, 'color:var(--ui-text-muted)'))
|
||||
const Seg = (opts, on, fw) => D('display:flex;border:1px solid var(--ui-border-accented);flex-shrink:0;' + (fw ? 'width:100%' : ''), ...opts.map(o => S('height:' + (bh - 2) + 'px;padding:0 ' + (m ? 12 : 10) + 'px;' + (fw ? 'flex:1;justify-content:center;' : '') + 'display:flex;align-items:center;gap:6px;white-space:nowrap;font-weight:' + (o === on ? 500 : 400) + ';background:' + (o === on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (o === on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), o === 'Custom' && I('calendar', 14), o)))
|
||||
const F = { q: '', actor: '', cat: '', out: 'All', range: '7 days', ...(s.f || {}) }
|
||||
const RANGES = ['Today', '7 days', '30 days', 'Custom']
|
||||
const chips = []
|
||||
if (F.q) chips.push('Search: ' + F.q)
|
||||
if (F.actor && !user) chips.push('Person: ' + F.actor)
|
||||
if (F.cat) chips.push('Category: ' + F.cat)
|
||||
if (F.out !== 'All') chips.push('Outcome: ' + F.out)
|
||||
if (F.range !== '7 days') chips.push('Dates: ' + (F.range === 'Custom' ? (F.custom || '1 – 30 Sep 2026') : F.range))
|
||||
const nF = chips.length
|
||||
const Chips = () => nF > 0 && D('display:flex;flex-wrap:wrap;align-items:center;gap:8px', ...chips.map(c => S('height:' + (m ? 32 : 24) + 'px;padding:0 4px 0 8px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-elevated);border:1px solid var(--ui-border);color:var(--ui-text-highlighted);font-size:12px;font-weight:500', c, S('width:20px;height:20px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 12)))), S('height:' + (m ? 44 : 24) + 'px;display:flex;align-items:center;padding:0 6px;font-weight:500;font-size:12px;' + link, 'Clear all'))
|
||||
const FilterBar = () => m
|
||||
? D('display:flex;gap:8px', Search(), h('span', { style: P('height:44px;padding:0 12px;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;font-size:13px;flex-shrink:0') }, I('sliders-horizontal', 16), 'Filters', nF > 0 && S('min-width:18px;height:18px;padding:0 5px;display:inline-flex;align-items:center;justify-content:center;background:var(--ui-primary);color:var(--ui-bg);font:500 11px \'IBM Plex Mono\',monospace', String(nF))))
|
||||
: D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', Search(), !user && Sel(F.actor, 'All people', 168, 'user-round'), Sel(F.cat, 'All categories', 176, 'layout-list'), Seg(['All', 'Succeeded', 'Failed'], F.out), D('flex:1;min-width:0'), Seg(RANGES, F.range))
|
||||
|
||||
// ---------- summary
|
||||
const mk = (l, v, sub, ic, tone, ser) => ({ l, v, sub, ic, tone, ser })
|
||||
const STAT = s.sum === 'zero' ? [mk('Total actions', '214', '4% vs previous 7 days', 'trending-down', 'n', [3, 5, 2, 6, 4, 1, 2]), mk('Active people', '5', '4 people, 1 API key', '', 'n', [2, 3, 2, 3, 2, 1, 2]), mk('Failed actions', '0', 'Nothing failed', 'circle-check', 'ok0', [0, 0, 0, 0, 0, 0, 0]), mk('Security events', '0', 'Nothing to flag', 'circle-check', 'ok0', [0, 0, 0, 0, 0, 0, 0])]
|
||||
: s.sum === 'none' ? [mk('Total actions', '0', 'No data yet', '', 'n', [0, 0, 0, 0, 0, 0, 0]), mk('Active people', '0', 'No data yet', '', 'n', [0, 0, 0, 0, 0, 0, 0]), mk('Failed actions', '0', 'Nothing failed', 'circle-check', 'ok0', [0, 0, 0, 0, 0, 0, 0]), mk('Security events', '0', 'Nothing to flag', 'circle-check', 'ok0', [0, 0, 0, 0, 0, 0, 0])]
|
||||
: [mk('Total actions', '1,284', '12% vs previous 7 days', 'trending-up', 'n', [4, 6, 5, 8, 7, 9, 6]), mk('Active people', '9', '7 people, 2 API keys', '', 'n', [5, 6, 6, 7, 7, 8, 9]), mk('Failed actions', '6', '0.5% of all actions', 'circle-x', 'error', [0, 1, 0, 2, 1, 0, 2]), mk('Security events', '11', '5 failed logins', 'shield-alert', 'warning', [1, 0, 2, 1, 3, 2, 2])]
|
||||
const Bars = (ser, tone, z) => {
|
||||
const mx = Math.max(...ser, 1), col = tone === 'error' ? 'var(--ui-error)' : tone === 'warning' ? 'var(--ui-warning)' : tone === 'ok0' ? 'var(--ui-border-accented)' : 'var(--ui-text-muted)'
|
||||
return D('display:flex;align-items:flex-end;gap:2px;height:' + z + 'px;flex-shrink:0', ...ser.map(v => S('display:block;width:4px;height:' + Math.max(2, Math.round(v / mx * z)) + 'px;background:' + col)))
|
||||
}
|
||||
const StatCard = (c, i) => {
|
||||
const act = s.card === i, tone = c.tone
|
||||
const bg = tone === 'error' ? 'var(--rc-error-soft)' : tone === 'warning' ? 'var(--rc-warning-soft)' : 'var(--ui-bg)'
|
||||
const vc = tone === 'error' ? 'var(--ui-error)' : tone === 'warning' ? 'var(--ui-warning)' : 'var(--ui-text-highlighted)'
|
||||
const sc = tone === 'ok0' ? 'var(--ui-success)' : 'var(--ui-text-muted)'
|
||||
return D('display:flex;flex-direction:column;gap:2px;padding:' + (m ? '10px 12px' : '12px 14px') + ';background:' + bg + ';border:1px solid ' + (act ? 'var(--ui-primary)' : 'var(--ui-border)') + ';' + (act ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : '') + (m ? 'flex:0 0 156px;' : 'min-width:0;'),
|
||||
D('display:flex;align-items:center;justify-content:space-between;gap:8px', S('color:var(--ui-text-toned);font-weight:500;' + ell, c.l), Bars(c.ser, tone, m ? 16 : 20)),
|
||||
S('font:600 ' + (m ? 22 : 24) + "px/32px 'IBM Plex Sans',sans-serif;color:" + vc, c.v),
|
||||
S('display:flex;align-items:center;gap:4px;font-size:12px;color:' + sc + ';' + ell, c.ic && I(c.ic, 12), c.sub))
|
||||
}
|
||||
const SkelCard = () => D('display:flex;flex-direction:column;gap:8px;padding:' + (m ? '10px 12px' : '14px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);' + (m ? 'flex:0 0 156px' : 'min-width:0'), sk('55%', 12), sk('40%', 22), sk('70%', 10))
|
||||
const Strip = () => {
|
||||
if (user) return null
|
||||
const kids = s.state === 'loading' ? [0, 1, 2, 3].map(SkelCard) : STAT.map(StatCard)
|
||||
return m ? D('display:flex;gap:8px;overflow:hidden;margin:0 -16px;padding:0 16px', ...kids) : D('display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px', ...kids)
|
||||
}
|
||||
|
||||
// ---------- table
|
||||
const cols = user ? '96px minmax(0,1fr) 230px 120px' : '96px 210px minmax(0,1fr) 230px 120px'
|
||||
const TableHead = () => D('display:grid;grid-template-columns:' + cols + ';height:36px;align-items:center;padding-right:16px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500;flex-shrink:0', S('padding-left:16px;display:flex;align-items:center;gap:4px;color:var(--ui-text-highlighted)', 'When', I('arrow-down', 12)), !user && S('', 'Actor'), S('', 'Action'), S('', 'Target'), S('', 'Outcome'))
|
||||
const Day = d => D('display:flex;align-items:center;height:32px;padding:0 ' + (m ? 16 : 16) + 'px;background:var(--ui-bg-muted);border-top:1px solid var(--ui-border);font-weight:600;font-size:12px;color:var(--ui-text-highlighted);flex-shrink:0', d)
|
||||
const Tip = e => S('position:absolute;left:12px;top:38px;z-index:5;padding:6px 8px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;white-space:nowrap;box-shadow:var(--rc-shadow-pop)', e.abs)
|
||||
const TargetCell = e => !e.tk ? S('color:var(--ui-text-muted)', '—') : D('display:flex;flex-direction:column;min-width:0;padding-right:12px', e.ex ? S('display:flex;align-items:center;gap:4px;' + link, S(ell, e.em), I('arrow-up-right', 12, 'color:var(--ui-text-muted)')) : D('display:flex;align-items:center;gap:6px;min-width:0', S('flex:1;text-decoration:line-through;color:var(--ui-text-muted);' + ell, e.em), Bd('Deleted', 'del')), S('font-size:12px;color:var(--ui-text-muted)', e.tk))
|
||||
const Row = e => {
|
||||
const hov = s.tip === e.id, sel = s.panel === e.id
|
||||
return D('position:relative;z-index:' + (hov ? 3 : 1) + ';display:grid;grid-template-columns:' + cols + ';align-items:center;min-height:52px;padding-right:16px;border-top:1px solid var(--ui-border);background:' + (sel || hov ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';' + (e.sec ? 'box-shadow:inset 2px 0 0 var(--ui-warning);' : ''),
|
||||
S('padding-left:16px;position:relative;color:var(--ui-text-toned);white-space:nowrap', e.rel, hov && Tip(e)),
|
||||
!user && D('display:flex;align-items:center;gap:8px;min-width:0;padding-right:12px', Av(e.a), S('font-weight:500;color:var(--ui-text-highlighted);' + ell, e.a.n), ActorBd(e.a)),
|
||||
D('display:flex;align-items:center;gap:10px;min-width:0;padding-right:16px', S('display:flex;color:var(--ui-text-muted)', I(CI[e.cat], 16)), S('color:var(--ui-text);' + ell, e.s, S('font-weight:600;color:var(--ui-text-highlighted)', e.em), e.tail || ''), e.sec && SecIcon(14)),
|
||||
TargetCell(e), D('display:flex', Out(e)))
|
||||
}
|
||||
const Card = e => {
|
||||
const sel = s.panel === e.id
|
||||
return D('display:flex;flex-direction:column;gap:8px;padding:12px 16px;border-top:1px solid var(--ui-border);background:' + (sel ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';' + (e.sec ? 'box-shadow:inset 2px 0 0 var(--ui-warning)' : ''),
|
||||
D('display:flex;align-items:center;gap:8px;min-width:0', !user && Av(e.a, 24), !user && S('font-weight:500;color:var(--ui-text-highlighted);' + ell, e.a.n), !user && ActorBd(e.a), D('flex:1'), S('color:var(--ui-text-muted);font-size:12px;white-space:nowrap', e.rel)),
|
||||
D('display:flex;gap:8px;align-items:flex-start', S('display:flex;padding-top:2px;color:var(--ui-text-muted)', I(CI[e.cat], 16)), S('flex:1;min-width:0;color:var(--ui-text);font-size:15px;line-height:22px;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden', e.s, S('font-weight:600;color:var(--ui-text-highlighted)', e.em), e.tail || '')),
|
||||
D('display:flex;align-items:center;gap:6px;flex-wrap:wrap', Out(e), e.sec && Bd('Security', 'sec'), e.tk && !e.ex && Bd('Target deleted', 'del')))
|
||||
}
|
||||
const rowsOf = fn => { const out = []; let day = null; list.forEach(e => { if (e.day !== day) { out.push(Day(e.day)); day = e.day } out.push(fn(e)) }); return out }
|
||||
const SkelRows = () => Array.from({ length: m ? 5 : 9 }, (_, i) => m
|
||||
? D('display:flex;flex-direction:column;gap:10px;padding:14px 16px;border-top:1px solid var(--ui-border)', D('display:flex;gap:8px;align-items:center', sk('24px', 24), sk('90px', 12), D('flex:1'), sk('40px', 10)), sk(['90%', '75%', '85%'][i % 3], 14), sk('70px', 20))
|
||||
: D('display:grid;grid-template-columns:' + cols + ';align-items:center;height:52px;padding-right:16px;border-top:1px solid var(--ui-border)', S('padding-left:16px', sk('56px')), !user && D('display:flex;gap:8px;align-items:center', sk('24px', 24), sk('90px')), sk(['70%', '55%', '82%'][i % 3], 12), sk('60%'), sk('72px', 20)))
|
||||
const Table = () => D('flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;background:var(--ui-bg);border:1px solid var(--ui-border)', !m && TableHead(), ...(s.state === 'loading' ? (m ? [] : []).concat(SkelRows()) : rowsOf(m ? Card : Row)))
|
||||
const PgBtn = (c, on, dis) => S('width:' + bh + 'px;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)') + ';font-weight:500;flex-shrink:0;opacity:' + (dis ? 0.4 : 1), c)
|
||||
const Pager = () => s.state === 'loading' ? null : m
|
||||
? D('display:flex;align-items:center;gap:8px;flex-shrink:0', PgBtn(I('chevron-left'), 0, 1), D('flex:1;display:flex;flex-direction:column;align-items:center;font-size:12px;color:var(--ui-text-muted)', S('color:var(--ui-text-highlighted);font-weight:500', 'Page 1 of ' + (user ? '2' : '52')), S('', '1–25 of ' + total)), D('width:96px;flex-shrink:0', Sel('25', '', 0)), PgBtn(I('chevron-right')))
|
||||
: D('display:flex;align-items:center;gap:16px;flex-shrink:0', S('color:var(--ui-text-muted)', 'Showing 1–' + (user ? '25' : '25') + ' of ' + total), D('flex:1'), D('display:flex;align-items:center;gap:8px;color:var(--ui-text-muted)', 'Rows per page', D('width:80px', Sel('25', '', 0))), D('display:flex;gap:4px', PgBtn(I('chevron-left'), 0, 1), PgBtn('1', 1), PgBtn('2'), PgBtn('3'), PgBtn('…'), PgBtn(user ? '2' : '52'), PgBtn(I('chevron-right'))))
|
||||
|
||||
// ---------- header + page
|
||||
const title = user ? 'My activity' : 'Activity'
|
||||
const sub = user ? 'Everything you\'ve done on this site.' : 'Who did what, and when, across your site.'
|
||||
const noData = s.state === 'none'
|
||||
const Header = () => D('display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;flex-shrink:0', D('flex:1;min-width:180px;display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted)', sub)), !user && s.state !== 'forbidden' && B('Export CSV', { icon: 'download', dis: noData || s.state === 'error' }))
|
||||
const Page = () => {
|
||||
const k = [Header()]
|
||||
if (s.state === 'forbidden') { k.push(Empty('lock', 'You can\'t view this activity', 'Your role doesn\'t include this page. Ask an owner or admin if you need access.', 'Back to home', false)); return k }
|
||||
const st = Strip(); if (st) k.push(st)
|
||||
k.push(FilterBar()); const c = Chips(); if (c) k.push(c)
|
||||
if (s.state === 'error') k.push(Alert('error', 'circle-x', 'Couldn\'t load activity', 'Check your connection and try again.', 'Try again'))
|
||||
else if (s.state === 'none') k.push(Empty('history', 'No activity yet', user ? 'Your actions will appear here once you start working on the site.' : 'Actions on articles, media, users and API keys will appear here.'))
|
||||
else if (s.state === 'nomatch') k.push(Empty('search-x', 'No activity matches these filters', 'Try a different search or a wider date range.', 'Clear filters', false))
|
||||
else { k.push(Table()); const pg = Pager(); if (pg) k.push(pg) }
|
||||
return k
|
||||
}
|
||||
|
||||
// ---------- slide-over
|
||||
const Av2 = Av
|
||||
const Panel = () => {
|
||||
const e = E.find(x => x.id === s.panel), i = list.indexOf(e)
|
||||
const lab = t => S('color:var(--ui-text-muted)', t)
|
||||
const val = (...c) => D('display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;color:var(--ui-text-highlighted)', ...c)
|
||||
const Diff = d => {
|
||||
if (d.note) return D('display:flex;flex-direction:column;gap:6px;padding:12px;border-top:1px solid var(--ui-border-muted)', S('font-weight:500;color:var(--ui-text-highlighted)', d.f), S('display:flex;align-items:center;gap:6px;color:var(--ui-text-toned)', I('file-pen-line', 14), d.note))
|
||||
const clamp = d.long && !s.expand
|
||||
const box = (t, bg, bd, c) => S('display:block;padding:8px 10px;background:' + bg + ';border:1px solid ' + bd + ';color:' + c + ';overflow-wrap:anywhere;' + (clamp ? 'display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden' : ''), t)
|
||||
return D('display:flex;flex-direction:column;gap:8px;padding:12px;border-top:1px solid var(--ui-border-muted)', S('font-weight:500;color:var(--ui-text-highlighted)', d.f),
|
||||
D('display:grid;grid-template-columns:' + (m ? 'minmax(0,1fr)' : 'minmax(0,1fr) 16px minmax(0,1fr)') + ';gap:' + (m ? 6 : 8) + 'px;align-items:' + (m ? 'stretch' : 'start'), D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-size:11px;color:var(--ui-text-muted)', 'Before'), box(d.b, 'var(--ui-bg-elevated)', 'var(--ui-border)', 'var(--ui-text-toned)')), m ? S('display:flex;justify-content:center;color:var(--ui-text-muted)', I('arrow-down', 14)) : S('display:flex;padding-top:30px;color:var(--ui-text-muted)', I('arrow-right', 14)), D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-size:11px;color:var(--ui-text-muted)', 'After'), box(d.a, 'var(--ui-bg)', 'var(--ui-border-accented)', 'var(--ui-text-highlighted)'))),
|
||||
d.long && S('align-self:flex-start;font-weight:500;font-size:12px;min-height:' + (m ? 32 : 16) + 'px;' + link, clamp ? 'Show more' : 'Show less'))
|
||||
}
|
||||
const body = D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:20px',
|
||||
D('display:flex;flex-direction:column;gap:10px', S('font:600 18px/26px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted);overflow-wrap:anywhere', sentence(e)), D('display:flex;gap:6px;flex-wrap:wrap;align-items:center', Out(e), e.sec && Bd('Security', 'sec'), S('display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--ui-text-muted)', I(CI[e.cat], 14), CL[e.cat]))),
|
||||
!e.ok && Alert('error', 'circle-x', 'Why it failed', e.why),
|
||||
D('display:grid;grid-template-columns:96px minmax(0,1fr);gap:10px 12px;align-items:start', lab('When'), D('display:flex;flex-direction:column;gap:2px', S('color:var(--ui-text-highlighted)', e.abs + ' (your time)'), S('color:var(--ui-text-muted);font-size:12px', e.utc)), lab('Actor'), val(Av(e.a, 24), S('font-weight:500', e.a.n), ActorBd(e.a)), lab('Source'), val(e.src), lab('IP address'), e.ip ? S(MONO + ';font-size:12px;color:var(--ui-text-highlighted)', e.ip) : S('color:var(--ui-text-muted)', 'Not applicable'), lab('Browser'), e.ua ? S('color:var(--ui-text-highlighted)', e.ua) : S('color:var(--ui-text-muted)', 'Not applicable')),
|
||||
e.tk && D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Target'), D('display:flex;flex-direction:column;gap:6px;border:1px solid var(--ui-border);padding:10px 12px', D('display:flex;align-items:center;gap:8px;min-width:0', e.ex ? S('flex:1;display:flex;align-items:center;gap:4px;overflow-wrap:anywhere;' + link, e.em, I('arrow-up-right', 14, 'color:var(--ui-text-muted)')) : S('flex:1;text-decoration:line-through;color:var(--ui-text-muted);overflow-wrap:anywhere', e.em), !e.ex && Bd('Deleted', 'del')), S('font-size:12px;color:var(--ui-text-muted)', e.tk + ' · ' + e.tid), !e.ex && S('font-size:12px;color:var(--ui-text-toned)', 'This item was deleted. Its name is kept for reference.'))),
|
||||
e.diff && D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Changes'), D('border:1px solid var(--ui-border)', ...e.diff.map((d, j) => { const r = Diff(d); return r }))))
|
||||
const foot = D('flex-shrink:0;display:flex;align-items:center;gap:8px;padding:12px ' + (m ? 16 : 20) + 'px;border-top:1px solid var(--ui-border)', B('Previous event', { icon: 'chevron-up', dis: i === 0, full: m }), B('Next event', { icon: 'chevron-down', dis: i === list.length - 1, full: m }), !m && D('flex:1'), !m && S('color:var(--ui-text-muted);font-size:12px', 'Event ' + (i + 1) + ' of ' + total))
|
||||
const head = D('height:56px;flex-shrink:0;display:flex;align-items:center;gap:12px;padding:0 ' + (m ? 8 : 20) + 'px 0 ' + (m ? 16 : 20) + 'px;border-bottom:1px solid var(--ui-border)', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Activity details'), m && S('color:var(--ui-text-muted);font-size:12px', (i + 1) + ' of ' + total), S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x')))
|
||||
return D('position:absolute;inset:0;z-index:40;display:flex;justify-content:' + (m ? 'stretch' : 'flex-end') + ';align-items:' + (m ? 'flex-end' : 'stretch') + ';background:var(--rc-overlay)',
|
||||
D((m ? 'width:100%;height:88%;' : 'width:480px;height:100%;') + 'background:var(--ui-bg);display:flex;flex-direction:column;box-shadow:var(--rc-shadow-modal)', m && D('flex-shrink:0;display:flex;justify-content:center;padding-top:8px', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')), head, body, foot))
|
||||
}
|
||||
const Drawer = () => {
|
||||
const sec = (l, c) => D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', l), c)
|
||||
return D('position:absolute;inset:0;z-index:40;display:flex;align-items:flex-end;background:var(--rc-overlay)', D('width:100%;background:var(--ui-bg);display:flex;flex-direction:column;box-shadow:var(--rc-shadow-modal)',
|
||||
D('display:flex;justify-content:center;padding-top:8px', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
|
||||
D('height:56px;display:flex;align-items:center;padding:0 8px 0 16px;border-bottom:1px solid var(--ui-border)', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Filters'), S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
|
||||
D('padding:16px;display:flex;flex-direction:column;gap:16px', !user && sec('Person', Sel(F.actor, 'All people', 0, 'user-round')), sec('Category', Sel(F.cat, 'All categories', 0, 'layout-list')), sec('Outcome', Seg(['All', 'Succeeded', 'Failed'], F.out, 1)), sec('Dates', D('display:flex;flex-direction:column;gap:8px', Seg(['Today', '7 days', '30 days', 'Custom'], F.range, 1), F.range === 'Custom' && D('display:flex;gap:8px;align-items:center', D('flex:1', Sel('1 Sep 2026', '', 0, 'calendar')), D('flex:1', Sel('30 Sep 2026', '', 0, 'calendar')))))),
|
||||
D('display:flex;gap:8px;padding:12px 16px 16px;border-top:1px solid var(--ui-border)', B('Clear all', { full: true, dis: nF === 0 }), B('Show ' + (s.count || '23') + ' results', { k: 'primary', full: true }))))
|
||||
}
|
||||
|
||||
// ---------- home + settings
|
||||
const Rows5 = (ls, own) => ls.map((e, i) => D('display:flex;gap:10px;align-items:flex-start;padding:10px 0;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Av(e.a, 24), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('color:var(--ui-text);overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden', e.s, S('font-weight:600;color:var(--ui-text-highlighted)', e.em), e.tail || ''), S('font-size:12px;color:var(--ui-text-muted)', own ? e.rel : e.a.n + ' · ' + e.rel)), !e.ok && Bd('Failed', 'fail'), e.sec && e.ok && SecIcon(14)))
|
||||
const ActCard = () => {
|
||||
const own = user, ls = own ? E.filter(e => e.a.n === 'Marta Alves') : E.slice(0, 5)
|
||||
const st = s.card || 'ok'
|
||||
const inner = st === 'loading' ? Array.from({ length: 5 }, (_, i) => D('display:flex;gap:10px;padding:10px 0;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), sk('24px', 24), D('flex:1;display:flex;flex-direction:column;gap:6px', sk(['85%', '70%', '90%', '60%', '78%'][i], 12), sk('35%', 10))))
|
||||
: st === 'error' ? [Alert('error', 'circle-x', 'Couldn\'t load recent activity', null, 'Try again')]
|
||||
: st === 'empty' ? [D('padding:24px 0;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center', S('color:var(--ui-text-muted)', I('history', 20)), S('font-weight:600;color:var(--ui-text-highlighted)', 'No activity yet'), S('color:var(--ui-text-muted);max-width:240px', own ? 'Your actions will appear here.' : 'Actions on your site will appear here.'))]
|
||||
: Rows5(ls, own)
|
||||
return D('background:var(--ui-bg);border:1px solid var(--ui-border);display:flex;flex-direction:column;min-width:0', D('display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--ui-border)', S('flex:1;font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', own ? 'Your recent activity' : 'Recent activity'), S('display:flex;align-items:center;gap:4px;font-weight:500;min-height:' + (m ? 44 : 20) + 'px;' + link, 'View all', I('arrow-right', 14))), D('padding:4px 16px 6px;display:flex;flex-direction:column', ...inner))
|
||||
}
|
||||
const Ph = (t, hh) => D('height:' + hh + 'px;border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);text-align:center;padding:16px', t)
|
||||
const HomeBody = () => [D('display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Home'), S('color:var(--ui-text-muted)', 'Welcome back, ' + ME.split(' ')[0] + '.')), m ? D('display:flex;flex-direction:column;gap:16px', ActCard(), Ph('Existing Home cards', 160)) : D('display:grid;grid-template-columns:minmax(0,1fr) 420px;gap:16px;align-items:start', Ph('Existing Home cards', 420), ActCard())]
|
||||
const RET = { '30': '30 days', '90': '90 days', '365': '365 days', '0': 'Forever' }
|
||||
const SettingsBody = () => {
|
||||
const r = s.ret || '90', fv = r === '0'
|
||||
const dl = (k, v) => D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:' + (m ? 0 : 12) + 'px', S('width:' + (m ? 'auto' : '120px') + ';color:var(--ui-text-muted);flex-shrink:0', k), S('color:var(--ui-text-highlighted)', v))
|
||||
const card = D('background:var(--ui-bg);border:1px solid var(--ui-border);max-width:640px;display:flex;flex-direction:column',
|
||||
D('display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--ui-border)', D('flex:1;display:flex;flex-direction:column', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Retention'), S('color:var(--ui-text-muted);font-size:12px', 'How long activity is kept.')), s.saved && S('display:flex;align-items:center;gap:4px;color:var(--ui-success);font-size:12px', I('check', 14), 'Saved')),
|
||||
D('padding:16px;display:flex;flex-direction:column;gap:16px',
|
||||
s.err && Alert('error', 'circle-x', 'Couldn\'t save the retention period', 'Your previous setting is still in place.', 'Try again'),
|
||||
D('display:flex;flex-direction:column;gap:6px;max-width:260px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Keep activity for'), Sel(RET[s.shorten ? '30' : r], '', 0), S('color:var(--ui-text-muted);font-size:12px', fv ? 'Nothing is removed automatically. The log grows over time.' : 'Older events are removed automatically.')),
|
||||
s.shorten && Alert('warning', 'triangle-alert', 'Events older than 30 days will be removed', 'This happens at the next cleanup on 7 Oct 2026, 03:00. It can\'t be undone.', ['Apply 30 days', 'Cancel']),
|
||||
Alert('info', 'lock', 'Activity logs are append-only', 'Events can\'t be edited or deleted from the app. Only the retention period removes them.'),
|
||||
D('display:flex;flex-direction:column;gap:6px;padding-top:12px;border-top:1px solid var(--ui-border)', dl('Last cleanup', fv ? 'Never' : '6 Oct 2026, 03:00 · 142 events removed'), dl('Next cleanup', fv ? 'Not scheduled' : '7 Oct 2026, 03:00'))))
|
||||
return [D('display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Settings'), S('color:var(--ui-text-muted)', 'Site-wide options. Only owners can change them.')), Ph('Existing Settings cards', m ? 100 : 120), card]
|
||||
}
|
||||
|
||||
// ---------- shell
|
||||
const logo = h('svg', { width: 26, height: 26, viewBox: '0 0 16 16', shapeRendering: 'crispEdges', style: { color: 'var(--ui-text-highlighted)', flexShrink: 0 } }, h('rect', { width: 16, height: 16, fill: 'currentColor' }), h('path', { fill: 'var(--ui-bg)', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
|
||||
const kind = s.kind || 'page'
|
||||
const navKey = kind === 'home' ? 'home' : kind === 'settings' ? 'settings' : 'activity'
|
||||
const nav = (user ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']])
|
||||
const side = D('width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px', D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px', logo, S('font-weight:600;font-size:14px;color:var(--ui-text-highlighted)', 'RenewCMS')),
|
||||
...nav.map(([k, ic, t]) => D('display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:' + (k === navKey ? 'var(--rc-sand)' : 'transparent') + ';color:' + (k === navKey ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';font-weight:' + (k === navKey ? 500 : 400), I(ic), t)),
|
||||
D('flex:1'), D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;color:var(--ui-text-highlighted)', Av(U(ME), 26), S('flex:1;font-weight:500', ME), I('chevrons-up-down', 14)))
|
||||
const crumb = { page: title, home: 'Home', settings: 'Settings' }[kind]
|
||||
const topbar = m
|
||||
? D('height:56px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 6px;gap:4px;color:var(--ui-text-highlighted)', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I('menu', 20)), S('flex:1;font-weight:600;font-size:16px', crumb))
|
||||
: D('height:48px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 24px;gap:8px;color:var(--ui-text-muted)', I('panel-left-close'), S('width:1px;height:16px;background:var(--ui-border);margin:0 6px'), S('color:var(--ui-text-highlighted);font-weight:500', crumb))
|
||||
const kids = kind === 'home' ? HomeBody() : kind === 'settings' ? SettingsBody() : Page()
|
||||
const content = D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 24) + 'px;display:flex;flex-direction:column;gap:16px', ...kids)
|
||||
const root = D('width:' + (m ? 390 : 1440) + 'px;height:' + (m ? 844 : 900) + 'px;position:relative;display:flex;flex-direction:' + (m ? 'column' : 'row') + ';background:var(--ui-bg-muted);color:var(--ui-text);font-family:\'IBM Plex Sans\',sans-serif;font-size:13px;line-height:20px;overflow:hidden',
|
||||
!m && side, D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', topbar, content), s.panel && Panel(), m && s.drawer && Drawer())
|
||||
return { view: root }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="design_doc_mode" content="canvas">
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}</style>
|
||||
</helmet>
|
||||
<div ref="{{ rootRef }}" data-screen-label="Activity screens" style="background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
|
||||
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
|
||||
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screen: Activity log</h1>
|
||||
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">New /activity screen, a Recent activity card for Home, and a Retention card for Settings. 1440 px at 50%, 390 px at 75%. Owner and admin see everything; regular users get "My activity". The theme switch shows every state in dark mode. Copy, component mapping and API calls are at the bottom.</p>
|
||||
</div>
|
||||
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
|
||||
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
|
||||
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="5">
|
||||
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
|
||||
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
|
||||
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
||||
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><dc-import name="ActivityFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></div></div>
|
||||
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><dc-import name="ActivityFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px">
|
||||
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Copy per state</h2>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||
<sc-for list="{{ copy }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for>
|
||||
</div>
|
||||
</section>
|
||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px">
|
||||
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Component inventory (Nuxt UI v4)</h2>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||
<sc-for list="{{ comps }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for>
|
||||
</div>
|
||||
</section>
|
||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px">
|
||||
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API calls</h2>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||
<sc-for list="{{ api }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"light","tsType":"'system' | 'light' | 'dark'"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { mode: this.props.theme ?? 'light', spec: { copy: [], comps: [], api: [] } }
|
||||
rootRef = React.createRef()
|
||||
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-on-sand': '#111111', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
|
||||
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-on-sand': '#111111', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
|
||||
resolved() {
|
||||
if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
return this.state.mode
|
||||
}
|
||||
apply() {
|
||||
const el = this.rootRef.current
|
||||
if (!el) return
|
||||
const vars = this.resolved() === 'dark' ? this.dark : this.light
|
||||
for (const k in vars) el.style.setProperty(k, vars[k])
|
||||
document.body.style.background = vars['--rc-canvas']
|
||||
}
|
||||
componentDidMount() {
|
||||
this.apply()
|
||||
import('./activity-spec.js').then(x => this.setState({ spec: { copy: x.copy, comps: x.comps, api: x.api } })).catch(() => {})
|
||||
}
|
||||
componentDidUpdate(p) {
|
||||
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
|
||||
this.apply()
|
||||
}
|
||||
groups() {
|
||||
const W = (name, trigger, o = {}, mo = {}) => ({ name, trigger, s: o, m: { ...o, ...mo } })
|
||||
const U = { role: 'user' }
|
||||
const f1 = { cat: 'Authentication', out: 'Failed', range: '30 days' }
|
||||
return [
|
||||
{ label: 'Activity, owner and admin', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', states: [
|
||||
W('Default, large dataset', 'Newest first, day separators, a security row, 1,284 events across 52 pages. Hover on the first row shows the exact time.', { tip: 1 }),
|
||||
W('Loading', 'Skeleton strip and table while both requests are in flight.', { state: 'loading' }),
|
||||
W('Zero-value summary strip', 'Quiet period: Failed and Security show a calm 0.', { sum: 'zero' }),
|
||||
W('Filtered, card applied', 'Failed actions card clicked, chips and Clear all. Mobile shows the count on Filters.', { f: f1, card: 2, count: 6, total: '6', ids: [3], sum: 'default' }, {}),
|
||||
W('Mobile filters sheet', 'Filters open as a bottom sheet (desktop uses the inline bar above).', { f: { ...f1, range: 'Custom' }, drawer: 1, count: 6, total: '6', ids: [3] }),
|
||||
W('Empty, no activity yet', 'New site. Export is disabled.', { state: 'none', sum: 'none' }),
|
||||
W('Empty, filters match nothing', 'Offers Clear filters.', { state: 'nomatch', f: { q: 'sitemap', cat: 'Media', out: 'Failed' }, total: '0' }),
|
||||
W('Error with retry', 'Request failed. The summary strip still shows if its call succeeded.', { state: 'error' }),
|
||||
W('Forbidden (403)', 'API returned 403 for this role or filter.', { state: 'forbidden', role: 'user' })
|
||||
] },
|
||||
{ label: 'Details slide-over', title: 'Details slide-over and bottom sheet', route: 'Row click', api: 'Uses the item from GET /v1/activity', states: [
|
||||
W('Edit with a diff', 'Title change and "Body edited (+312 characters)". No body text.', { panel: 2 }),
|
||||
W('Settings diff, long text', 'Long values clamp to two lines with Show more.', { panel: 11 }),
|
||||
W('Failed action, very long target title', 'Failure reason shown, target title wraps.', { panel: 9 }),
|
||||
W('Deleted target, no diff', 'Struck-through target with a Deleted badge.', { panel: 8 }),
|
||||
W('System-initiated, no human actor', 'Scheduler with a System badge. IP and browser not applicable.', { panel: 5 }),
|
||||
W('Security event, failed login', 'Actor not signed in. Restrained shield marker.', { panel: 3 })
|
||||
] },
|
||||
{ label: 'My activity', title: 'My activity, regular user', route: '/activity', api: 'GET /v1/activity (server scoped to the user)', states: [
|
||||
W('Default', 'No summary strip, no actor filter, no Export. Title becomes My activity.', U),
|
||||
W('Empty', 'The user has not done anything yet.', { ...U, state: 'none' }),
|
||||
W('Details', 'Same slide-over, own event.', { ...U, panel: 1 })
|
||||
] },
|
||||
{ label: 'Home card', title: 'Home, Recent activity card', route: '/', api: 'GET /v1/activity?size=5', states: [
|
||||
W('Owner or admin', 'Last 5 events with the actor name.', { kind: 'home' }),
|
||||
W('Regular user', 'Own events only.', { kind: 'home', ...U }),
|
||||
W('Loading', '', { kind: 'home', card: 'loading' }),
|
||||
W('Error', '', { kind: 'home', card: 'error' }),
|
||||
W('Empty', '', { kind: 'home', card: 'empty', ...U })
|
||||
] },
|
||||
{ label: 'Settings retention', title: 'Settings, Retention (owner only)', route: '/settings', api: 'GET /v1/settings · PATCH /v1/settings', states: [
|
||||
W('Default, 90 days', 'Saves on change.', { kind: 'settings', saved: true }),
|
||||
W('Shortening the period', 'Confirms before older events are removed.', { kind: 'settings', ret: '90', shorten: true }),
|
||||
W('Forever', 'No cleanup scheduled.', { kind: 'settings', ret: '0' }),
|
||||
W('Save error', '', { kind: 'settings', err: true })
|
||||
] }
|
||||
]
|
||||
}
|
||||
renderVals() {
|
||||
const cur = this.state.mode
|
||||
const spec = this.state.spec
|
||||
const rows = a => a.map(r => ({ a: r[0], b: r[1] }))
|
||||
return {
|
||||
rootRef: this.rootRef,
|
||||
modes: ['light', 'dark', 'system'].map(k => ({ label: k[0].toUpperCase() + k.slice(1), on: cur === k, bg: cur === k ? 'var(--ui-primary)' : 'var(--ui-bg)', fg: cur === k ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', pick: () => this.setState({ mode: k }) })),
|
||||
groups: this.groups(),
|
||||
copy: rows(spec.copy), comps: rows(spec.comps), api: rows(spec.api)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,55 @@
|
||||
export const copy = [
|
||||
['Page title', 'Activity. Regular users: My activity.'],
|
||||
['Subtitle', 'Who did what, and when, across your site. Regular users: Everything you\'ve done on this site.'],
|
||||
['Header action', 'Export CSV (owner and admin only). Preparing: Preparing file… Success toast: Export started. Your file will download shortly. Error toast: Couldn\'t export activity. Try again.'],
|
||||
['Summary labels', 'Total actions · Active people · Failed actions · Security events'],
|
||||
['Summary sub-lines', '12% vs previous 7 days · 7 people, 2 API keys · 0.5% of all actions · 5 failed logins'],
|
||||
['Summary zero', 'Failed: Nothing failed. Security: Nothing to flag. No data at all: No data yet.'],
|
||||
['Filter bar', 'Search activity · All people · All categories · All / Succeeded / Failed · Today / 7 days / 30 days / Custom'],
|
||||
['Chips', 'Search: spring · Person: Marta Alves · Category: Authentication · Outcome: Failed · Dates: 1 – 30 Sep 2026 · Clear all'],
|
||||
['Mobile filters', 'Filters (count badge) · sheet title Filters · Clear all · Show 23 results'],
|
||||
['Day separators', 'Today · Yesterday · 3 Oct 2026'],
|
||||
['Outcome badges', 'Succeeded · Failed. Row markers: Security (shield icon). Target badge: Deleted. Mobile card: Target deleted.'],
|
||||
['Actor badges', 'API key · System. Not signed in: Not signed in.'],
|
||||
['Empty, no activity', 'No activity yet. Actions on articles, media, users and API keys will appear here. Regular users: Your actions will appear here once you start working on the site.'],
|
||||
['Empty, no match', 'No activity matches these filters. Try a different search or a wider date range. Action: Clear filters'],
|
||||
['Error', 'Couldn\'t load activity. Check your connection and try again. Action: Try again'],
|
||||
['Forbidden (403)', 'You can\'t view this activity. Your role doesn\'t include this page. Ask an owner or admin if you need access. Action: Back to home'],
|
||||
['Pagination', 'Showing 1–25 of 1,284 · Rows per page 25 · mobile: Page 1 of 52'],
|
||||
['Slide-over', 'Activity details · When (your time) + UTC line · Actor · Source (Web app, API key, System task) · IP address · Browser · Target · Changes · Before / After · Show more / Show less · Previous event · Next event · Event 2 of 1,284'],
|
||||
['Slide-over, failed', 'Why it failed. Reason text comes from the API (plain language, no stack traces).'],
|
||||
['Slide-over, deleted target', 'This item was deleted. Its name is kept for reference.'],
|
||||
['Slide-over, system', 'Source: System task. IP address and Browser: Not applicable.'],
|
||||
['Body edits', 'Body edited (+312 characters). No body text is shown.'],
|
||||
['Home card', 'Recent activity (regular users: Your recent activity) · View all · Empty: No activity yet · Error: Couldn\'t load recent activity. Try again'],
|
||||
['Retention', 'Retention · How long activity is kept. · Keep activity for: 30 days / 90 days / 365 days / Forever · Older events are removed automatically. · Forever: Nothing is removed automatically. The log grows over time.'],
|
||||
['Retention notice', 'Activity logs are append-only. Events can\'t be edited or deleted from the app. Only the retention period removes them.'],
|
||||
['Retention shorten', 'Events older than 30 days will be removed. This happens at the next cleanup on 7 Oct 2026, 03:00. It can\'t be undone. Actions: Apply 30 days · Cancel'],
|
||||
['Retention status', 'Last cleanup 6 Oct 2026, 03:00 · 142 events removed · Next cleanup 7 Oct 2026, 03:00 · Never / Not scheduled for Forever. Save: Saved · Error: Couldn\'t save the retention period. Your previous setting is still in place.']
|
||||
]
|
||||
export const comps = [
|
||||
['Page shell, sidebar entry', 'Existing dashboard shell. "Activity" (icon history) between Users and Settings, all verified users.'],
|
||||
['Page header', 'Plain heading + UButton (outline, icon download) for Export CSV.'],
|
||||
['Summary strip', 'Four UCard, each with a UButton-style click target (as="button"). Sparkline: seven div bars in the card (no chart library). Semantic emphasis via error and warning soft tokens.'],
|
||||
['Search', 'UInput, icon search, clearable.'],
|
||||
['Person and Category', 'USelectMenu. Person lists users, API keys and "System" in groups.'],
|
||||
['Outcome and date presets', 'Groups of UButton (soft = selected, outline = other). Custom opens a date-range popover.'],
|
||||
['Active filter chips', 'UBadge (variant subtle) with a trailing UButton (ghost, icon x). "Clear all" is UButton variant link.'],
|
||||
['Timeline table', 'UTable with custom row slots for day separators (full-width row). Cells: UAvatar (initials, key or system icon), UBadge (API key, System, Succeeded, Failed, Deleted, Security).'],
|
||||
['Row hover time', 'Native title attribute on the time cell (keeps to the allowed component list).'],
|
||||
['Pagination', 'UPagination + USelectMenu for page size.'],
|
||||
['Details panel', 'USlideover side="right" on desktop, side="bottom" on mobile. Body uses UCard sections. Prev and next are UButton.'],
|
||||
['Mobile filters', 'USlideover side="bottom" with the same USelectMenu and UButton controls.'],
|
||||
['Mobile list', 'UTable replaced below the md breakpoint by a stacked list of UCard rows.'],
|
||||
['Empty, error, forbidden', 'UEmpty with icon, title, description and one UButton action. Error uses UEmpty in the table area.'],
|
||||
['Loading', 'Skeleton blocks (bg-accented divs with animate-pulse) inside the same UCard and UTable layout.'],
|
||||
['Home card', 'UCard with header, a list of UAvatar rows, and a link UButton.'],
|
||||
['Retention', 'UCard in Settings with USelectMenu and read-only text lines.']
|
||||
]
|
||||
export const api = [
|
||||
['GET /v1/activity', 'Query: q, actor (user:ID, key:ID, system), category (articles, media, users, api_keys, auth, system), outcome (all, success, failed), range (today, 7d, 30d, custom), from, to (ISO dates), page, size (25, 50, 100). Sorted newest first. Returns items (id, created_at, actor {type, id, name}, category, sentence parts, target {type, id, name, exists}, outcome, failure_reason, security, source, ip, user_agent, changes [{field, before, after, kind}]), total, page, size. Users who are not owner or admin are scoped to their own events by the server; requesting another actor returns 403. Body edits return a single change with kind "body" and a character delta, never the text. The same call powers the Home card (size=5) and the slide-over (items carry the detail fields).'],
|
||||
['GET /v1/activity/summary', 'Query: the same range, from, to. Owner and admin only (403 otherwise). Returns total, previous_total, active_people {users, api_keys}, failed, security, and a 7-day series for each. Clicking a card applies outcome=failed or category in (auth, users role changes, api_keys) to the table, or clears filters for Total.'],
|
||||
['GET /v1/activity/export', 'Query: the same filters, no page or size. Owner and admin only. Streams text/csv with columns when, actor, action, target, outcome, source, ip. No changes or secrets.'],
|
||||
['GET /v1/settings', 'Existing endpoint. Adds activity_retention_days (30, 90, 365, 0 for forever), activity_last_cleanup_at, activity_last_cleanup_removed.'],
|
||||
['PATCH /v1/settings', 'Existing endpoint. Body { activity_retention_days }. Owner only (403 otherwise). Shortening needs the confirm step in the UI first.']
|
||||
]
|
||||
@@ -305,3 +305,23 @@ Added by the Media screens, none of which exist yet:
|
||||
| Unattended run | Settings | No toast. Updated on next visit |
|
||||
|
||||
401 and 403 are global and have no local copy.
|
||||
|
||||
## Added with Activity
|
||||
### Activity
|
||||
| State | Surface | Behaviour |
|
||||
|---|---|---|
|
||||
| Default | `/activity` | Strip, filters, day-grouped table, pagination |
|
||||
| Loading | `/activity` | Skeleton strip and rows after 150 ms |
|
||||
| Zero-value strip | `/activity` | Failed and Security show a calm 0 |
|
||||
| Filtered | `/activity` | Chips and "Clear all"; a strip card applies its filter |
|
||||
| Empty, no activity | `/activity` | "No activity yet"; Export disabled |
|
||||
| Empty, no match | `/activity` | "No activity matches these filters" with "Clear filters" |
|
||||
| Error | `/activity` | "Couldn't load activity" with "Try again"; the strip may still show |
|
||||
| Forbidden (403) | `/activity` | "You can't view this activity." with "Back to home" |
|
||||
| Details | Slide-over / bottom sheet | Sentence, time, actor, source, target, reason, changes, previous and next |
|
||||
| My activity | `/activity` | No strip, Person filter or Export; own events only |
|
||||
| Export | Header button, toasts | "Preparing file…", then "Export started. Your file will download shortly." or "Couldn't export activity. Try again." |
|
||||
| Home card | `/home` | Owner or admin, regular user, loading, error, empty |
|
||||
| Retention | `/settings` | Default, shortening (confirm), Forever, save error |
|
||||
|
||||
401 is global. The 403 on `/activity` has local copy because the page itself is refused; the 403 on the summary or export of a non-admin is never shown, since the controls are hidden.
|
||||
Reference in new issue
Block a user