87 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 4da6a398d7 A ticked checkbox is filled and raised, and a file input is a thumb-sized neo field
Build / Build (push) Successful in 57s
Deploy / decepub.thepra.dev (push) Successful in 1m54s
daisyUI fills a ticked checkbox only for a colour variant, so without one it drew a tick on nothing: a checkbox without a
colour now takes the primary colour, filled and raised like a button once ticked (an inset well while not), and a
radio's dot is the primary colour. A file input's own button (Choose File, Browse…) is raised like the app's buttons
instead of daisyUI's flat neutral block, and on a touch screen the file input is 44px tall like the other fields; it had
stayed at its small 32px. Mobile/ControlsTests opens Settings' Data import/export on the narrowest phone, which the
sweep (sections closed) never reached, and photographs it in both themes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 19:52:26 +02:00
thepraandClaude Opus 5.5 356ea7b430 A picture picked is the form's at once, not once its preview is made
Build / Build (push) Successful in 31s
Deploy / decepub.thepra.dev (push) Successful in 1m35s
Settings' profile kept a picked avatar or header only after the browser had made its small preview, so a Save clicked
meanwhile saved the profile without it and said nothing; the composer added a picture to the post only after its
thumbnail, so a Publish meanwhile left it out. Both now take the file (and the composer starts its upload) as soon as
it is picked, and fill the preview in afterwards; a preview the browser can't make no longer drops the picture,
PrivaPub decides. Found by the e2e suite under load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 13:32:48 +02:00
thepraandClaude Opus 5.5 48df316256 Settings takes a persona's whole archive away and brings one back
Data import/export gains the persona's archive (PrivaPub's /clientapi/persona/{id}/archive): made on PrivaPub in
Mastodon's account-archive layout and downloaded through a link; and an archive, its own or Mastodon's, uploaded in
pieces straight from the picked file (js/transfer.js) and imported into the persona, the parts chosen (likes, located
and scheduled posts only when ticked), with progress, a report of what came and what was left out, and Stop. Imported
posts are delivered to no one.

e2e: a town persona's archive downloaded and imported into another, whose profile then shows the posts; the copies are
taken away after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 12:49:51 +02:00
thepraandClaude Opus 5.5 dc2190e271 Administration backs the server up, downloads, uploads and restores it
A Backups section (for administrators, PrivaPub's /clientapi/admin/backups): the backups kept with their kind, size,
media and the logins deleted since, what runs, the restore waiting and the last restore's report. Back up now; download
for the password through a link good for ten minutes, which the browser saves to disk; upload a downloaded backup in
pieces straight from the picked file (js/transfer.js PUTs file.slice at the offset PrivaPub has, never through .NET's
memory, and carries on after a refused or lost piece); delete after asking; restore for the password and the host typed
out, saying what it keeps and what it loses, then showing PrivaPub restarting until the ended session sends the page to
sign in.

e2e: a backup made, downloaded and uploaded again; and, only with --filter "Category=Restore", a real restore of the
pasture, which signs the town in again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 12:45:30 +02:00
thepraandClaude Opus 5.5 70cb389537 Media survive failures: uploaded when attached, the server's limits, alt text asked for
The composer uploads each file as soon as it is attached (streamed from the browser file, under its own ten-minute
timeout), shows it uploading, processing (a 202 followed until ready), ready or failed with Retry, and cancels one
removed; publishing sends the ids, so a failed publish or a retried upload sends nothing twice. Size limits and types
come from /api/v2/instance, a file without a browser type gets one from its extension, and each refusal names the file
and why. Previews are 320 px thumbnails, not whole files in memory. A picture without a description is asked about once
("Publish anyway"; a setting turns it off), descriptions are sent with PUT, and an edit keeps the post's media, language
and sensitive flag.

Profile pictures are checked against the server's types and can be removed. A card shows "Media unavailable" with the
original's link when a picture fails, the blurhash behind a sensitive placeholder, and plays a GIF only on tap under
reduced motion. A failed export says so instead of downloading part of a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 11:56:59 +02:00
thepraandClaude Opus 5.5 9d662f702b The feed memory test counts the deletions it was told of
Build / Build (push) Successful in 30s
Deploy / decepub.thepra.dev (push) Successful in 1m33s
In one full run (241 of 242 passing) the home feed came back from a thread with fewer posts than it was left with, and
passed three times alone. The feed takes a post out at once when the stream says it was deleted, and earlier tests do
delete posts in that persona's home, but PrivaPub's log shows no federated Delete at that minute, so the cause is not
proven. The test now allows for the deletions the stream reported during the trip and says the three numbers when it
fails; with none reported, a shorter feed still fails, and coming back still may not read the home feed again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 02:55:30 +02:00
thepraandClaude Opus 5.5 e94e32d198 The globe's nerd stats are instruments in the scene
Owner decision 2026-10-06: the globe tells its numbers where they belong instead of in a table. instruments.js adds
one three.js group to globe.gl's scene and a DOM overlay of chips:
- the Sun: a ray and a bead over the subsolar point (with the irradiance), the day/night line, the golden hour and
  the three twilights, the declination, the noon and midnight meridian, and the analemma at this hour, its stretch to
  the next equinox or solstice brighter;
- a focused post's place: its horizon, its Sun's line and height, and its day lit on its latitude, with one chip for
  the place, the phase, the solar time, the elevation and the day's length;
- the tropics and polar circles (the axial tilt), the CDN ring, the view's centre and spread, and rings around the
  posts in view;
- a small orrery in the top left corner, re-anchored to the camera before each render, with the Earth–Sun distance,
  the seasons, the next equinox or solstice and the next perihelion or aphelion stacked under it;
- the frame rate as a ring around the globe; the rest (points, textures, countries, renderer) as a legend.

The chips are placed after each frame globe.gl draws, clear of each other, the moment's caption, the orrery and the
legend, and slide back inside the canvas at its edge; country names keep clear of them. Geometry changes only inside
a wake globe.js already made, so an idle globe still draws nothing. three.js's classes come from globe.gl's own
objects, looked for before each render until its globe is in the scene. A phone's banner draws the day/night line
and the subsolar point; full screen draws everything, and a globe built in the banner now moves its camera back when
opened full screen.

GlobePanel loses both tooltips and the one-second poll that read every post into a table; the Sun's rows stay as
screen-reader text, and a failed start says so. Seven keys only the tooltips used are gone.

GlobeTests (desktop with a located post written from Milano and pinned, in both themes and turned to the subsolar
point; a phone's banner, full screen and back; an idle globe drawing no frame) pass. Under SwiftShader the instruments
cost 1–3 ms of a ~50 ms frame and under 0.1 ms of script; FeedPerfTests' heaps are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 02:15:06 +02:00
thepraandClaude Opus 5.5 e88f4afc45 A located post's author deletes it
A located post's card had no action at all, so whoever wrote one (with this device's position) could not take it back
from the app. The persona's own located card now has Delete, which asks first, without the line about other servers
(a located post never left PrivaPub), and goes through /clientapi/post/delete, since the Mastodon API looks past
located posts. The globe's end-to-end test deletes the post it writes this way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 02:14:53 +02:00
thepraandClaude Opus 5.5 eade4e399a The feed doesn't flash "Empty" while the persona is switched to
LoadSession said the session was loaded before SwitchPersona had set the persona, so a render in between (any
CascadingState change) showed the feed a loaded session without a persona, and it said "Empty" until the persona came.
The session counts as loaded once the persona is known, or couldn't be used. StreamingTests took that flash for the
feed and, on a slower start, posted before the stream was open; it now waits until the app has subscribed to the home
stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 02:14:42 +02:00
thepraandClaude Opus 5.5 a3461c3ece Explore's hashtags are thumb-sized on a wide phone
On a 412px phone a hashtag's name and its week fit on one line, and the button that opens its feed was 40px tall; on
touch it is at least 44px, centred. The last finding of the full phone sweep (238 passed, 1 failed, the rest of the
suite unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 00:51:17 +02:00
thepraandClaude Opus 5.5 ed3bf57e5b Small screens, small things; CLAUDE.md on phones
On touch the "on the wall of" link gets a 44px hit area (it truncates, so padding), an event's venue address wraps,
and on a phone toasts sit above the round New post button. A table's scroll box and the administration's statistics
strip are marked as strips that scroll sideways on purpose (data-scroll-x), which the phone sweep allows. CLAUDE.md gains
a Phones section: width variants for layout and the pointer for hit sizes, touch.css, the 44px rule, the bottom sheets
(ResponsiveBottom, SheetOnNarrow, PhoneSheet), Back closing overlays (IDOverlayHost, OverlayService), ConfirmService,
what a tap does on touch, the feed on a phone; and the phone tests under Testing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 00:20:56 +02:00
thepraandClaude Opus 5.5 b66d7cf545 Tests click exactly what they mean
A card's time is a "Post" button now (its nerd stats open with a tap), so the poll test submits through the composer's
own submit button instead of the last button named Post; the feed-memory test opens a thread from the Expand button,
not from the first expand/ link, which on a desktop is now the phone-only Thread entry. The Back flow presses Back with
history.back(): a Back held to close an overlay loads nothing, which GoBackAsync waited for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 00:20:56 +02:00
thepraandClaude Opus 5.5 47ff60f57b The feed doesn't say "Empty" before its persona is known
On opening, the feed showed "Empty" until the session had been read and a persona chosen, then loaded: a flash of a
wrong answer on every start (and the end-to-end sign-in took it for a loaded feed). CascadingState says once the session
has been read (IsSessionLoaded), and until then a feed with no persona yet is loading; a login without any persona still
gets its empty feed. The persona-switch test waits for the sibling's home read with another token than the first
persona's, instead of any home read after the switch was asked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 00:20:56 +02:00
thepraandClaude Opus 5.5 18331531b8 The Sun's year is a pure function too, checked against the almanac
sun.js gains analemma(date, days): where the Sun is overhead at the same UTC time on each coming day, the figure-8 the
globe will draw. tools/globe/check.mjs checks the pure functions against almanac values with Node's own test runner
(dev only, nothing installed): the 2026 June solstice's declination, the March equinox, perihelion's distance, Turin's
longest day, the season in October, and the analemma spanning the tropics and the equation of time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:51:35 +02:00
thepraandClaude Opus 5.5 f1ac5eaad2 Back closes what is open
On a phone the Back button left the page under an open menu, sheet or dialog. An open overlay now says so (new
IDOverlayHost, cascaded by MainLayout; OverlayService keeps them newest last): DModal and DDropdown through the
cascade, so the D components stay free of app services, and the nav menu, PhoneSheet and the full-screen globe through
the service. MainLayout's location-changing handler lets Back close the newest overlay instead of leaving the page; a
link followed or the app's own navigation (signing out after its question, say) closes them all and goes on. Blazor
can't tell Back from the app's navigation, so a capture-phase popstate listener in main.js marks Back
(AppStatusService.TookBack). No history entries are pushed. Phone flow: Back closes the menu, a card's More sheet, the
composer and the full-screen globe one after another without leaving the feed, and with nothing open goes back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:49:49 +02:00
thepraandClaude Opus 5.5 5522fe4fbb Pictures open in the app
A tap on a picture opened its file in a new tab, leaving the app (and, on a phone, its place in the feed). It now opens
MediaViewer: the whole screen on a phone, a large dialog from md, the picture whole with its alt text under it, the
post's other pictures by the previous and next buttons or the arrow keys, and "Open original" for whoever wants the
file. Phone flow: a picture of a town post opens the viewer over the whole screen, which closes from its button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:44:22 +02:00
thepraandClaude Opus 5.5 7b1bfa71c0 On touch, a tap on a post opens its thread
On a phone the only way into a post's thread was a small Expand button (and now an entry in More), while a tap on the
post pinned it to a globe that is usually hidden there, outlining the card. Where the pointer can't hover, a tap on a
post that isn't on its links, controls or menus and doesn't end a text selection opens its thread through the card's
own thread link (main.js, data-thread on the card); cards are pinned to the globe with a mouse only. The globe's corner
stats have 12px text on touch. Phone flow: a tap in a post's margin opens its thread and pins nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:39:10 +02:00
thepraandClaude Opus 5.5 da8f4ffcab A thread's indented posts fit its column
The posts before and after the opened one are indented 1.5rem, and they kept their full width besides (w-full beat the
w-auto meant to undo it), so on a phone the thread scrolled sideways by the indent. They are capped at the column's
width less the indent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:36:03 +02:00
thepraandClaude Opus 5.5 89cef25def Nerd stats open with a tap
On a touch screen a nerd-stats tooltip showed only through the browser faking hover, unreliably, and couldn't be
scrolled. Where the pointer can't hover (AppStatusService.CanHover), a tap on a NerdTip now opens its rows in a sheet
from the bottom; the trigger is focusable, says it opens a dialog, and is at least 44px on touch. A closed tooltip takes
no room any more (daisyUI only made it transparent, and one past a card's edge made a phone's feed scroll sideways),
and on touch it never shows. The rows are a component of their own (NerdRows), at 12px on phones. A card's More menu
has a Details entry on phones that gathers the post's, its author's, its place's or CDN's and its counts' rows in one
sheet. A picture's tip doesn't open on a tap, the picture does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:36:03 +02:00
thepraandClaude Opus 5.5 765220059d A poll's options are thumb-sized, and floating buttons don't fail what scrolls under them
On touch a poll's options are at least 44px tall with 8px between them, and a long option wraps. The phone sweep
ignores a probe that lands on a fixed floating control (the round New post button covers the card under it by design)
instead of failing the target for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:31:07 +02:00
thepraandClaude Opus 5.5 dd38002682 Feed tabs say what they are
Below xl every feed tab but the selected one was a lone icon with no name, for a screen reader or a finger. DTab takes
an AccessibleName, which DTabs renders as the tab's label and tooltip, and FeedTabs names each tab; on a phone every
tab shows its name too. The strip is marked as one that scrolls sideways (data-scroll-x), says it holds the feeds, and
on a phone fades at the edge where more tabs wait (edge-fade-x, a scroll-driven animation where the browser has one);
the selected tab is brought into view when it changes (window.revealActive). Explore's tabs get the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:31:07 +02:00
thepraandClaude Opus 5.5 1c9773bab0 What can't be undone asks first
Deleting a post, taking it off the persona's wall, muting or blocking an account (from a card or a profile), leaving
a group, dropping a scheduled post and signing out acted at the first tap, a few pixels from their neighbours. They now
ask first: new ConfirmService (Ask returns whether the reader confirmed, as PublicNoticeService does) and ConfirmHost in
MainLayout, a sheet from the bottom on a phone and a dialog from md, with Cancel and the action named, in red when it
deletes or cuts off. Logout is a button that asks, then goes to /logout as before. The reversible ones (unmute,
unblock, removing a conversation from the list, a picture from the composer) don't ask; the inline confirms of lists,
filters and the login's deletion stay. The desktop tests that delete a reply, take a post off a wall or drop a
scheduled post confirm; a phone flow deletes a post after cancelling once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:28:22 +02:00
thepraandClaude Opus 5.5 931b7fcff1 The globe opens full screen; a phone held sideways keeps its feed
On a phone the globe is a banner above the feed; a tap on it now opens it full screen over everything, with a close
button (Escape closes it too), the focus moving to that button and back, and the feed underneath kept as it was but out
of reach (inert). The camera refits the new shape. Held sideways, a phone (a window at most 30rem tall, the new short:
variant) has no room for the banner: the banner and its toggle give way to a button that opens the globe full screen.
isMobileMedia, which picks the first visit's default, agrees with Tailwind's md now (it was off by one) and counts a
phone held sideways. GlobePanel tells its state (data-globe-state). Phone flows: the globe opens full screen and
closes, and a sideways phone keeps a feed at least 180px tall with no sideways scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:23:14 +02:00
thepraandClaude Opus 5.5 8f15d0d5c1 Phone flows: the menu, a dialog, a new post, a reply and a reaction by thumb
FlowTests drive the narrowest phone through what landed so far, each opened menu, sheet or dialog checked to be whole
on the screen with 44px targets (fit.js measures inside a scope now): the menu closes after a choice, an outside tap
and Escape; "Liked by" rises from the bottom and closes from its button; a new post is written in the sheet the round
button opens from a scrolled feed, with the words focused; a reply is written in a sheet; a reaction is picked from the
React sheet, through the one-time public notice, and taken back. Cells client.mobile.flow.*. The menu's dark-mode
toggle gets a 44px hit area and a name, and the public notice dialog marks itself for the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:19:59 +02:00
thepraandClaude Opus 5.5 071bcce4b0 Writing on a phone: the composer rises as a sheet
On a phone the composer opened at the top of the feed whether or not the feed was scrolled there, and a reply, quote
or edit opened inside its card's 228px column. New PhoneSheet shows them as a sheet from the bottom of the screen below
md, over a dimmed page, with a heading and a close button (a tap outside or Escape closes it too); from md they stay in
place as before. On a phone a new post starts from a round button under the thumb (the feed keeps room for it at the
bottom), and the tabs' Post button shows from md. Opening the composer scrolls the feed to its top, and the composer's
words take the focus (DTextarea.Element). Picking a suggested mention or hashtag keeps the focus, and the phone's
keyboard, in the words (DButton.KeepFocus, and the words are focused again). The character count sits under the words
instead of over their last line, and the visibility menu names each choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:15:54 +02:00
thepraandClaude Opus 5.5 b3bb2a28c3 A profile's Lists menu is never cut off
The profile card clipped its overflow, so the Lists menu (where a followed account is put into lists) was cut at the
card's edge at every width, and on a phone could also run past the screen's left side. The card no longer clips (its
header picture rounds the top corners itself), and on a phone the menu opens as a sheet from the bottom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:14:03 +02:00
thepraandClaude Opus 5.5 d2ab5e7b40 Explore's tabs no longer vanish under a long list
The tab strip sat in a flex column taller than the page, and a sideways-scrolling box may shrink to nothing there: on a
phone the five tabs had no height at all and couldn't be reached. It keeps its size now (flex-none). Found by the phone
sweep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:13:23 +02:00
thepraandClaude Opus 5.5 0b237b1951 A card's actions fit a phone
On a phone the card's action row was about 371px wide against a 228px text column, so the feed scrolled sideways and
More and Expand fell off the screen. Below md the author's picture moves from its own column into the card's header,
which gives the text, media and polls the whole width (288px at 360), and the actions are five equal cells: Reply,
Boost, Favourite, React and More. Bookmark and the thread are entries in More there; the downvotes, the counts icon and
Expand show from md. Counts are compacted on phones (1.2k, 12k, 1.2M, GenericExtensions.Compact). React and More open
as sheets from the bottom of the screen, the reactions as a grid of four. The author's name and handle stack and are
14px, the place badge is narrower, and "edited" and the booster's name have 44px hit areas; the booster's name opens
their profile in the app instead of their site. On touch the extra small buttons' text is 14px, and their hit area is
a full 44px (the ::after sits against the padding box, inside the border). Desktop keeps its row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:13:23 +02:00
thepraandClaude Opus 5.5 ffd4acd3d0 Phone tests: expired sign-ins, evidence, and probes that only judge what can be tapped
A saved sign-in whose session expired landed on the login page and every route its persona opened timed out; both the
desktop and the phone sign-in now see that and sign in afresh. A route that fails to open leaves its screenshot and
text, as a failed sign-in does. A target's probes stay inside what its scroller shows (a card half under the feed's
top edge was failed for the part out of sight), a failed probe names what it hit, and the element blamed for sideways
scroll is never one inside a nested clipping box, which can't widen the page. The desktop guard reads its probe sizes
as a list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:13:23 +02:00
thepraandClaude Opus 5.5 63f2517f5b Dropdowns open as sheets on phones, come into view, and take no room closed
A closed dropdown's content stayed displayed whenever it carried a display class (flex, grid), only invisible, so it
still took room: the React picker past a card's right edge made the whole feed scroll sideways on a phone (294px at
360px). A closed one is now not displayed at all. DDropdown moves the focus into its content on opening, which also
scrolls a menu cut off by the feed into view, and gives it back to its trigger after Escape or an outside tap. New
SheetOnNarrow makes the content a sheet from the bottom of the screen below md, over a dimmed page, at most 80dvh tall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 22:46:12 +02:00
thepraandClaude Opus 5.5 f1cb90ac03 The phone menu closes after a choice
On a phone the menu stayed open over the page after a link was tapped, and only the hamburger closed it. It now closes
on any navigation, on a persona switch, on a tap outside it (a dimmed backdrop) and on Escape; its height is capped at
the screen's (dvh) with its own scroll, and its width follows a narrow screen. The hamburger has a name ("Menu") and says
whether the menu is open. The header's logo is a link home and the persona's picture a 44px link to Settings (it was
30px). From md, the sidebar scrolls on its own when the window is short, as on a phone held sideways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 22:44:57 +02:00
thepraandClaude Opus 5.5 6731077191 Dialogs close from a button and rise from the bottom on phones
DModal draws a close button (✕, named by CloseText) beside its heading, in a header that stays in view while the box
scrolls; a long list of who liked a post left only the thin margins around the box to tap out of it. A new
ResponsiveBottom placement makes the box a sheet from the bottom below md and keeps it in the middle from md; the
report, liked-by and boosted-by, edit history and public notice dialogs use it. Boxes are at most 100dvh tall, so a
phone's browser bars don't cut them, and a bottom sheet leaves 3rem above itself and clears the home indicator. The
report dialog gets a Cancel button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 22:43:35 +02:00
thepraandClaude Opus 5.5 87f3f2402a Controls are thumb-sized on touch screens, and labels wrap
Under (pointer: coarse) only, so desktop keeps daisyUI's sizes: buttons, tabs, inputs and selects are 44px tall and
icon buttons and tabs 44px wide; extra small buttons and the badges that act are 36px with a transparent ::after that
gives them a 44px hit area, and the rows of reactions and hashtags leave 8px between them so those areas don't overlap.
Checkboxes and radios are 24px, toggles 28, range thumbs 28, and a toggle's or checkbox's label at least 44px tall. At
every width, the label of a checkbox, radio or toggle now wraps: daisyUI's .label never does, and long ones (Groups,
the profile editor, Settings) ran past a phone's edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 22:42:20 +02:00
thepraandClaude Opus 5.5 38336f9869 The page zooms, keeps its height on phones and isn't reloaded by a pull
The viewport no longer forbids zooming (maximum-scale=1, user-scalable=no kept text at 10-12px out of reach), and it
covers the screen with the keyboard resizing the content. The app is 100dvh tall instead of 100vh, so its bottom isn't
under a phone browser's bar, and a pull past the top no longer reloads it (overscroll-behavior-y). New Styles/touch.css
holds what a touch screen gets, under (pointer: coarse) only: for now field text at 16px, so iOS doesn't zoom into a
focused field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 22:40:28 +02:00
thepraandClaude Opus 5.5 63d83810d7 Phones in the end-to-end tests
The browser fixture opens phones from Playwright's own device presets (360x800, iPhone SE 375x667, Pixel 7 412x839,
and the Pixel 7 held sideways), with touch, a coarse pointer and reduced motion, and a phone session fails at once if
the page doesn't see a touch screen. Mobile/fit.js measures a page one screen at a time down its main scroller:
sideways scroll, boxes past the screen's sides, open menus and dialogs cut or outside the screen, targets without a
44px hit area (hit-tested at their centre and 21px around it, so a crowding neighbour fails them too) and control text
under 12px. RouteSweepTests runs every feed tab, threads of each kind the town holds, profiles, search, Explore,
lists, groups, filters, scheduled posts, settings and administration (every section opened) and the login page on the
three portrait phones, as client.mobile.fit.* and client.mobile.touch.* cells; screenshots, findings and a contact
sheet go to tests/e2e/out/mobile/. DesktopUnchangedTests holds desktop's control sizes. Category=Mobile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 22:40:28 +02:00
thepraandClaude Opus 5.5 88e55e671c Every field takes the boxes' radius
Inputs, selects and file inputs follow the textareas: --radius-box (.75rem) instead of daisyUI's --radius-field
(2rem), which stays the buttons', so a pill is something to press and a field is a box to fill. The reason sits once
above the input utility; a rounded-* class at the call site still wins (the composer's warning field keeps its joined
corners).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 21:45:05 +02:00
thepraandClaude Opus 5.5 b7ff917d8c Textareas take the boxes' radius
Every daisyUI field reads --radius-field, 2rem in our themes: a pill on a one-line input, but on a textarea several
lines tall the curve ate into its first and last lines. A textarea now takes --radius-box (.75rem, the cards'); it
changes the variable daisyUI reads, so a rounded-* class at the call site still wins (the composer keeps its 1.4rem).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 21:42:06 +02:00
thepraandClaude Opus 5.5 24748168c0 Controls lose the seam along their edges
daisyUI keeps a 1px border on its controls, which the neo layer makes transparent, and paints their gradient as a
background image. A background image starts at the padding box and repeats, so the border strip showed the gradient's
far end: a thin seam along each button's, field's and selector's edges, as if it sat in a second frame (most visible on
the nav menu's pills in the dark theme). The gradients are now painted from the border box, on buttons, inputs,
textareas, file inputs, checkboxes, radios, toggles and the neo-gradient utilities. Checked by rendering each control
in both themes at 4x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 21:39:38 +02:00
thepraandClaude Opus 5.5 511edf3591 The composer completes mentions and hashtags
Build / Build (push) Successful in 31s
Deploy / decepub.thepra.dev (push) Successful in 1m34s
A mention or hashtag being written at the end of the words is offered, once typing pauses, the accounts or tags
PrivaPub already knows (/api/v2/search, never resolving); a click writes it out. e2e: a town persona's handle,
begun, is offered and completed. CLAUDE.md also says that FEP-5624's moderated comments (PeerTube) show as pending
on a card, as GoToSocial's policies do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:47:51 +02:00
thepraandClaude Opus 5.5 658683f816 The persona's notification policy, and what it filtered
Settings has a Notifications section, also at /notifications/filtered: what notifications from people the persona does
not follow, people who do not follow it, new accounts, private mentions it did not ask for and limited accounts become
(accept, filter, drop), and the filtered ones, one request per account, let in (with that account from then on) or
dismissed. The Notifications tab says when requests wait. e2e: a stranger's like waits in a request reached from the
tab and comes back once let in; the policy and the stranger's permission are put back afterwards (Pasture.Mongo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:47:51 +02:00
thepraandClaude Opus 5.5 a0f07abf8c Reports, and who liked or boosted a post
Another's profile and posts can be reported from the client (/api/v1/reports): a category, words, and for a remote
account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. Who liked or boosted
a post opens from its card's menu (favourited_by, reblogged_by). e2e: a report from a Mastodon account's profile
reaches PrivaPub's moderators forwarded, and a post's likes and boosts list the persona who gave them; the admin
helper the tests share moves to Support.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:47:19 +02:00
thepraandClaude Opus 5.5 bb68ff8d5e An edited post's versions open from its card
The card marks an edited post with a button that opens its versions, newest first (/statuses/:id/history), each with
its time, warning and words; PrivaPub now has a remote post's earlier versions from Pleroma's formerRepresentations
too. The composer's e2e test opens the history after its edit. The wall test retries smuser's post while Smithereen
has not yet had the persona's Accept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 13:03:19 +02:00
thepraandClaude Opus 5.5 03824babbc A post on a wall says whose, and the persona takes posts off its own
PrivaPub hosts what a persona's followers write on its wall (Smithereen's, FEP-400e) and shows posts written on walls
elsewhere to their owners' followers. A card names the wall (privapub.wall, data-wall, linking the profile), and one
written on the persona's own wall is taken off from the card's menu, which PrivaPub tells as a Remove and Smithereen
applies as a deletion. WallTests checks it against Smithereen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 08:38:49 +02:00
thepraandClaude Opus 5.5 23dbc68fb4 Coming back to the feed shows it as it was left
Leaving the feed keeps it in Services/FeedMemory.cs (posts, waiting posts, cursors and scroll; ten minutes, for the same
feed of the same persona), so coming back from a thread or a profile shows it again at once, older pages included,
instead of reading it anew; newer posts come behind the "new" button and through the stream as before.
tests/e2e FeedMemoryTests goes to a thread and back without a new read of the home timeline; the whole suite passes
(123, 5 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 01:21:41 +02:00
thepraandClaude Opus 5.5 5a7e1804a9 Articles fold in a feed, with their cover, excerpt and a Read button
An Article or a Page with its title, or a titled post longer than a few paragraphs (WriteFreely sends its posts as Notes
with a name), shows its cover, its excerpt and a Read button in a feed, and its whole text on demand; the post opened
in its thread shows whole. A feed of Ghost, WordPress and WriteFreely long reads stays a feed. tests/e2e ArticleTests
checks it with a WriteFreely blog; the render matrix still passes (70).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 01:00:57 +02:00
thepraandClaude Opus 5.5 2f29872745 The globe looks at the feed's posts only when the feed hands them over
GlobePanel built a string of every located post's place on each of its renders, the stats overlay's once a second
included: with two thousand posts loaded, a hundred kilobytes of formatting every second while the page sat idle. It
now looks at the posts only when the feed hands them over, and compares a hash. The feed perf test (Category=Perf)
loads 2000 posts as before (49 s, against 55.6 s for 1740 in the baseline run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 23:17:48 +02:00
thepraandClaude Opus 5.5 da98addf76 Pinned posts on profiles, and pinning from the card
A profile shows the posts the account pins above its posts (pinned=true: for an account elsewhere, what it features,
which PrivaPub now keeps); the persona pins and unpins its own public and unlisted posts from the card's menu, and
PrivaPub tells its followers. tests/e2e/Actions/PinTests.cs pins from the card and finds the post first on the profile;
the whole e2e suite passes against the reseeded village (122, 4 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 23:04:31 +02:00
thepraandClaude Opus 5.5 a5178a67b1 The feed stays light at thousands of posts
A Perf test (tests/e2e/Perf/FeedPerfTests, Category=Perf) scrolls load0's
home, thousands of posts from SocialPub's load runs, to 2000 posts. Before:
100 cards took 12 s to reach, 1000 took 437 s, 2000 took 29 minutes; the
main thread was busy almost all of that time, and the page held 224 000 DOM
nodes and 920 MB of .NET heap. Every card re-rendered whenever the list did.

- A card re-renders only when its own events run or its render key changes
  (Content.RenderKey: the post, its status, notification, conversation,
  place and grouping, where it is shown, the persona and the connection).
  2000 cards then took 122 s.
- The list is a <Virtualize>: only the cards near the view are rendered.
  The page now holds about 3 000 DOM nodes and 145 MB however long the feed
  is, and each page of older posts costs the same (about 1.9 s in the test's
  scroll loop) at the 100th post as at the 1700th. The feed tells how many
  posts it holds in data-feed-count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 20:30:45 +02:00
thepraandClaude Opus 5.5 f9de55ffd5 A moved account's profile says where it went
PrivaPub now fills `moved` once a move is believed; the profile shows it, linking
the new account's profile. The e2e opens the account SocialPub's moves scenario
moved and finds the new one named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 18:39:56 +02:00
thepraandClaude Opus 5.5 228708c328 Settings: change the login's password, or delete the login
A Login section in Settings changes the root's password (the current one
given) and deletes the whole login, every persona with it, after its
password and an explicit "I understand"; the client then signs out. Both
use PrivaPub's existing /clientapi/user routes.

The e2e signs up a login of its own, changes its password and deletes it,
checking each against PrivaPub's login. Signing in now waits for either
the feed or an enabled persona chooser, so a login with one persona (which
goes on by itself) and a new persona's empty feed both sign in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 18:32:47 +02:00
thepraandClaude Opus 5.5 0bbab720a5 Groups: make, join, run and leave communities and circles
The Groups page (/groups, in the menu) is where a persona makes a community
or a circle (with its invitation code and an optional password), joins one
by invitation and leaves one. For the groups it runs it edits them, shows
the invitation, lets in or declines the requests to join, and takes members
out, through PrivaPub's new /clientapi/group/members, reject and remove.
The e2e makes a circle, joins it as another persona with the code, and has
the owner take that member out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 18:24:39 +02:00
thepraandClaude Opus 5.5 2024798732 Custom emoji, downvotes, blurhash and focal points on the cards
What other platforms send now shows as they meant it:
- custom emoji (a post's or account's :shortcode:) as their pictures in
  posts, names, content warnings, bios and fields: only the shortcodes the
  server listed, only http(s) pictures, never inside a tag, the shortcode
  kept as alt text;
- a threadiverse post's downvotes next to its favourites;
- blurhash placeholders until a picture loads (wwwroot/js/blurhash.js, a
  small decoder that watches the page), and a picture's focal point;
- an event's online link, participants and capacity, and whether it is
  tentative or cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 17:55:39 +02:00
thepraandClaude Opus 5.5 5e8fdc43af An event card joins and leaves the event
PrivaPub lets a persona join another server's event (owner decision
2026-10-05). The card of a remote event shows the persona's participation
(pending, accepted, rejected) with Join or Leave, except for events by
invitation or taken on another site. Joining is public on the event's
server, so the first join asks the persona once, as reactions and votes do,
remembered apart from them (PublicAct).

The e2e joins the event SocialPub's Mobilizon scenario keeps, checks the
participant on Mobilizon and PrivaPub's answer, and leaves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 15:19:07 +02:00
thepraandClaude Opus 5.5 6f8472bc9f A reply or boost awaiting its author's approval says so
GoToSocial lets an author approve replies, likes and boosts first. PrivaPub now asks that author and marks the
persona's reply or boost privapub.approval: pending until the answer, or rejected; the card shows which, with an
hourglass or a cross. One the policy shuts out is refused (422) and shown as the error it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 11:44:09 +02:00
thepraandClaude Opus 5.5 61631bb0a7 The composer says which language a post is in
A post went out in the reader's own interface language whatever it was
written in. The composer now has a language picker (each language named in
itself), starting at the persona's default from Settings, else the
interface's; a reply starts in its thread's language. The e2e poll test
picks Italian and finds it on PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 07:06:12 +02:00
thepraandClaude Opus 5.5 d7d836ba39 Settings, Administration and conversations; threads that fill in
Settings, which held only the theme, now belong to the persona too:
- its profile (update_credentials, a new PATCH in IHttpService): pictures,
  name, biography and fields as written, locked, bot, discoverable,
  indexable, and how its posts start (visibility, language, sensitive
  media, who may quote them); the composer starts at that visibility;
- the root's personas, each used in turn, and a new one made;
- the accounts it muted or blocked and the servers it blocked, each undone
  there, a server blocked from a pasted address or handle;
- its follows, lists, blocks, mutes, blocked servers and bookmarks exported
  and imported as the CSV files Mastodon writes (PortabilityService): an
  import resolves each row through PrivaPub, one at a time, and can stop.

Administration opens to moderators: reports, resolved there; and for
administrators the servers blocked for everyone and the federation
statistics (traffic by server, delivery success and latency, the crawler).

The Direct tab pages conversations, badges the unread ones, and a card
marks one read or takes it off the list; opening one reads it.

A remote thread is read again 4 and 15 seconds after it opens, since
PrivaPub then fetches the replies its servers hold. A thread its
community locked shows a lock instead of a reply button, reactions stay
visible below a content warning, and a searched handle opens its
account's profile whatever else matched.

The e2e tests cover each: the profile saved and read back, a persona made,
servers blocked and imported, follows exported, an instance block and the
statistics as the pasture's admin, a conversation read and removed, and a
reply only Mastodon held appearing in its thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 06:20:18 +02:00
thepraandClaude Opus 5.5 03f4f2ad53 E2E: a known gap skips its test, and reactions start clean
A test whose failure an open gap of the town's gaps.json names (a peer's
own bug, such as Misskey keeping its renote count after our Undo, G-0005)
is now skipped with the gap's id instead of failing the run; the report
still counts the cell an expected failure. The reaction test starts by
withdrawing a reaction a run cut short left behind, so one failure no
longer makes the next run fail too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 06:20:18 +02:00
thepraandClaude Opus 5.5 5c11727f58 Polls, quotes and edits from the composer
The composer can hold a poll instead of media (two to four choices, a
length, one choice or several), quote a post (offered on a card when the
server says the persona may quote it), and edit the persona's own post: its
words and warning come from /source, and saving keeps its media,
visibility and poll. A card's menu offers Edit and Quote, and now opens
sideways: upwards, the first card's menu was cut off by the top of the
feed. The e2e tests publish a poll, edit a post and quote it, each checked
on PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:42:42 +02:00
thepraandClaude Opus 5.5 a4d9831691 Notifications in groups, and what is unread
The Notifications tab reads grouped notifications (/api/v2/notifications):
likes and boosts of one post, or follows of one hour, are one card naming
both accounts of a pair, or the newest and how many others, with their
avatars. A notification the stream tells joins its group and waits on top.
The tab's badge counts what is unread; opening the tab moves the
notifications marker to the newest shown, which reads it on PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:35:23 +02:00
thepraandClaude Opus 5.5 963565b316 The feed hears new posts as they come
decePub opens PrivaPub's streaming API (one WebSocket for the persona, its
token given as the socket's protocol, never in a URL) and subscribes to the
stream of the feed in view: home, a list, local, federated, a hashtag or
notifications. New posts wait behind the "new" button as polled ones did;
a deletion or an edit applies at once. Polling stays for the feeds PrivaPub
does not stream and for any feed while the socket is down, which opens again
after a pause growing to a minute. The e2e test sees a post made elsewhere
in seconds and its deletion take the card away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:15:54 +02:00
thepraandClaude Opus 5.5 6d5cf17723 Posts for later, hashtags followed, and Explore
The composer's clock button gives a post a time (scheduled_at, at least five
minutes ahead; located and group posts have none), and /scheduled moves or
drops what waits. A hashtag's feed tab shows its week and follows it, so its
public posts come home. Explore (/explore) shows what PrivaPub says is
trending, hashtags, posts and links from public posts it holds, the
directory by activity or by joining, and the hashtags the persona follows.
DiscoveryService does the calls; the e2e tests check each against PrivaPub's
own answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:53:51 +02:00
thepraandClaude Opus 5.5 9a752d9288 Filters: made on /filters, applied by each card where it is
The persona's filters (PrivaPub's /api/v2/filters): made, given words and
whole-word rules, contexts, an action and an expiry on /filters (linked from
Settings), and deleted; posts filtered one by one can be taken out. Every
status says which filters it matches; each page tells its cards where they
are (home and lists, notifications, public feeds, the thread around the
opened post, profiles), and a card leaves its post out (hide), folds it
behind the filter's title with a way to show it anyway (warn), or blurs its
media (blur). The e2e test folds and then hides a post of the persona's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:10:27 +02:00
thepraandClaude Opus 5.5 e2491de16e Lists: made on /lists, read as a tab of the feed
A persona's lists (PrivaPub's /api/v1/lists): made, renamed, given a
replies policy and made exclusive on /lists, filled from the accounts it
follows (accounts/search?following=true), and read as the feed's Lists tab.
A followed account's profile has a Lists menu to put it in or take it out.
ListService does the calls; the e2e tests check each step against
PrivaPub's own answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 02:49:51 +02:00
thepraandClaude Opus 5.5 011bafb6af End-to-end tests against the pasture; profiles, follows, search, votes, reactions
tests/e2e (Playwright for .NET 1.61.0 on xunit v3, the Chromium 1228 already
cached, never downloaded, never in CI) runs the published app against
SocialPub's pasture and its town: run.sh publishes it with the Pasture
environment (wwwroot/appsettings.Pasture.json, published only then; the
deploy fails if it ever ships) and serves it as https://decepub.test. The
tests sign in through the real login page and persona chooser, check that
every request goes only to decepub.test and privapub.test with the right
credential on the right API, render one post of each kind from each
platform as its author wrote it, and check every action on the other
side of the wire (town.sh stored): favourites, boosts and replies with
their undoing, follows from a profile, votes, reactions. Each test is also
a cell of the town's report (out/client.jsonl). They found two PrivaPub
bugs fixed there (a blocked author's status answered null; sensitive
remote media invented a content warning) and a reply addressed to nobody.

New in the app, all checked by those tests:
- profiles at /account/{id}: header, bio, fields, counts, the account's
  posts as posts, posts and replies, or media, and follow, unfollow,
  cancel a request, mute and block (AccountService); post authors and
  notifications open them in the app;
- search at /search: accounts, posts and hashtags, a handle or an address
  resolved by PrivaPub;
- poll voting on the card, and emoji reactions shown and given with one
  tap; before a persona's first reaction or vote it is told once that
  both are public (PublicNoticeService, the owner's decision);
- follow requests answered from their notification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 01:23:18 +02:00
thepraandClaude Opus 5.5 0f68e74e38 The globe under the Sun of now or of a post, with borders and country names
Build / Build (push) Successful in 46s
Deploy / decepub.thepra.dev (push) Successful in 1m52s
The Sun lights the globe where it really is (wwwroot/js/sun.js, the Astronomical Almanac's low-precision solar
coordinates): the day map shaded by the Sun's height and the Earth–Sun distance, warm city lights on the night side,
and a twilight blend about 13° wide between them. It shows now, refreshed every 30 s, or the moment of the hovered,
focused or pinned post: a click on a card pins it, and the subsolar point travels there the shorter way round. The
panel's top corner shows that moment, the phase of the day and solar time at the post's place, and the season in its
hemisphere (or in both, for a post without a place). Its tooltip shows the subsolar point, declination, axial tilt,
distance, irradiance, day length and the next equinox or solstice.

Land borders are thin paths. Country names are in the reader's language and appear as the camera comes closer:
largest countries first, away from the globe's edge, never overlapping. They come from world-atlas 2.0.2 (Natural
Earth 1:50m), prepared by tools/globe/prepare-countries.py, and are vendored with their hashes in SOURCES.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 20:23:42 +02:00
thepraandClaude Opus 5.5 69d0a641fe The globe draws the poles as they are, and nothing while idle
The globe is a UV sphere: towards the poles its triangles narrow to
slivers, and texture coordinates interpolated across them pinched the
map into a star around each pole. Each pixel now computes its own
coordinates from its exact point on the sphere, with seam-safe mipmap
gradients; the map wraps horizontally and gets 8x anisotropic
filtering.

That showed a flaw in the day texture: NASA's mosaic has a lighter
strip at its east edge near the poles, a line from each pole once drawn
exactly. The day texture is now built from NASA's original Blue Marble
(December 2004) by tools/globe/prepare-day-texture.py, which blends
that strip into the map where both sides of the seam agree; same
4096x2048 size, provenance and hashes in SOURCES.md.

globe.gl redrew 60 times a second even when nothing moved; it now draws
only during camera flights, hover pulses, auto-rotation and the
reader's drags, so an idle globe costs no GPU time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 17:46:44 +02:00
thepraandClaude Opus 5.5 620cbef219 CDN-fronted servers grouped per CDN, and servers through time
Build / Build (push) Successful in 26s
Deploy / decepub.thepra.dev (push) Successful in 1m21s
A post whose server is behind a CDN has no place of its own: the globe
groups such posts per CDN in labels on a ring above the Arctic, and
hovering one draws its arc to the CDN's group and a ring where the
server was before the CDN. The card's CDN chip names the CDN, its
domain, how PrivaPub found it (published ranges, response headers or
network), the place before the CDN and the CDN's servers week by week
(/api/privapub/v1/cdns/:domain); the author's tooltip lists the server's
changes of place and CDN week by week (/instances/:host/history). Both
are read on the first hover and kept for the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 11:44:51 +02:00
thepraandClaude Opus 5.5 5ec70b3357 A closed dropdown no longer catches the pointer
A display utility on a dropdown's content (flex, grid) beats daisyUI's
display:none on a closed dropdown, so the content stayed invisible
(opacity 0) but in the way of the pointer. The post card's menu, which
opens upwards, covered the card's place and CDN chips and blocked their
tooltips. Closed dropdown content is now hidden from the pointer too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 11:44:51 +02:00
thepraandClaude Opus 5.5 88f78690ec CLAUDE.md: PrivaPub places every located server to its city
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 11:12:02 +02:00
thepraandClaude Opus 5.5 d552baa578 Document the PrivaPub wiring
CLAUDE.md: the two APIs and their credentials, the services, where a
post is placed, the feed page and the globe, nerd stats, running
against a local PrivaPub or the pasture, and that resx keys must not
differ only by case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 11:03:14 +02:00
thepraandClaude Opus 5.5 827ecb770e Wire the client to PrivaPub: one sign-in, real feeds, a globe, nerd stats
One sign-in for both of PrivaPub's APIs: the root logs in on /clientapi,
picks a persona, and each persona gets its own Mastodon token in
exchange for the JWT (PersonaExchange). HttpService picks credentials
and JSON by route (JWT and camelCase for /clientapi, the persona token
and snake_case for /api, nothing for /oauth), retries a refused persona
token once, and records every request for the nerd stats.

The feed page is three columns: navigation with a persona switcher, the
feed with a tab per kind (home, local, federated, nearby, communities
and circles, direct, notifications, favourites, bookmarks, hashtags),
and a 3D globe that looks at the spherical mean of the posts in view
and draws a pulse and an arc to a hovered one. Posts sit at their own
place, their event's venue, the reader (located posts) or their
author's server as PrivaPub publishes it. Cards favourite, boost,
bookmark, reply, mute, block and delete; the composer posts with a
content warning, visibility, media and alt text, or as a located post.
Older pages load as the end nears; newer ones are polled while visible.
Nerd stats in tooltips: provenance, author and server, place, media,
counts, paging, the globe's camera and frame rate.

The mock (Faker, MessagesService, the Message models and store) and the
OIDC template leftovers are gone. Strings in English and Italian.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 11:03:14 +02:00
thepraandClaude Opus 5.5 f41ebf2160 Vendor globe.gl and its Earth textures
globe.gl 2.46.2 (MIT, three.js r185 bundled) and the NASA Blue Marble
and Black Marble textures from three-globe 2.45.3, copied once from the
npm registry; SOURCES.md records versions, licences and sha256, and the
bundled libraries' notices are in LICENSES-bundled.txt. The service
worker leaves the folder out of the offline precache: the feed loads it
the first time it shows the globe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 11:03:14 +02:00
thepraandClaude Opus 5.5 b9040bbd80 Document the development rules imported from b2b.next
The new "Development rules" section covers the rules and the patterns that go
with them:
- the quick-reference table;
- CancellationToken and its exemptions;
- state initialisation;
- the page loading/offline pattern;
- early return with toasts;
- client service patterns, ErrorViewer, autocomplete values, localization,
  formatting and JSON;
- the steps to wire a feature to PrivaPub;
- what was deliberately not adopted, and why: the tw: prefix, server-only
  rules, b2b.next-specific features, and mandatory parameter docs.

The architecture notes now describe the component base, toasts, ErrorViewer,
HttpService, the mock data and the two cultures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 15:26:21 +02:00
thepraandClaude Opus 5.5 e888e899d4 Add Italian and the strings every UI key needs
AllStrings.resx was empty, so all 74 UI keys rendered as their own text. It now
holds every key the app uses, with English values. That includes:
- the members of the enums shown through Localizer[value.ToString()], such as
  FollowersOnly and PlainText;
- the new toast, HttpService, ErrorViewer and Settings strings.

AllStrings.it.resx has the same keys in Italian.

ErrorMessages.resx gains MaxLength, with its Designer property, and an Italian
sibling. MessageForm's [StringLength] referenced that key, which didn't exist,
so failing that validation threw at runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 15:26:21 +02:00
thepraandClaude Opus 5.5 95428430c0 Adopt b2b.next's client patterns: services, page state, feedback
Ported from the owner's b2b.next and adapted from MudBlazor to the daisyUI
components:
- LocalizableComponentBase provides CascadingState, Navigation, Toast,
  IsLoading/IsDefaultDisabled, WhenOnline/StartOnlinePolling, and after-render
  jobs that actually run.
- CascadingState starts with initialised state and polls the online status
  with a PeriodicTimer. It reports errors through ILoggingService plus a toast,
  and disposes what it holds.
- New pieces:
  - ToastService + ToastHost, the snackbar, with AddResponseError/Success/
    Warning/Exception in SUtility;
  - ErrorViewer, which shows the validation messages plus AdditionalError(s);
  - LoggingService, which writes to the console and the IndexedDB client log.
- HttpService is rebuilt and registered:
  - auth levels Get/Post/Delete, *Anon and *TotallyAnon;
  - retry with backoff;
  - classified failures (FailureCodes) instead of exceptions;
  - HTTP 410 on cancel;
  - the "default" client.
  ReadWebResult and DefaultReadFromJsonAsync read its responses.
- ClientJsonContext is source-generated, and SUtility.DefaultSerializer and
  Blazored.LocalStorage use it.
- WebResult gains a C# 14 IsInvalid extension property.

The code follows the imported rules:
- Every service and async helper takes a CancellationToken last.
- Primary constructors replace constructor-plus-fields.
- MessagesService and IStorage return Task<WebResult> / Task<List<T>>.
  MessagesService is still mock, building its results in memory; the seed
  feed and thread moved to Faker.
- Index and ExpandMessage follow the page pattern: an offline guard with
  WhenOnline, LoadData, and early returns with toasts on the results they used
  to ignore. ExpandMessage reloads on route change and shows "not found"
  instead of a null message.
- Model and state collections are [], and the fake identity defaults moved
  out of the models.
- Components:
  - no ?. chains over initialised state;
  - [Inject] instead of @inject;
  - IsDefaultDisabled on the actions;
  - autocomplete on every text input;
  - Localizer[key, args] instead of string.Format;
  - the localizer no longer leaks its inner _pLocalizer.
- Settings → General gains a language picker (English/Italiano), and
  SetDefaultCulture limits the culture to those two.

Bugs fixed along the way:
- A compose with untouched (null) content passed the empty check.
- File errors were joined with <br/> and rendered as markup.
- ClientLogs.ErrorMessage() threw on warnings.
- CoalescingStringLocalizer[key, args] showed a raw "{0}" for missing keys.
- AppStatusService exposed a call to a JS function that doesn't exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 15:26:21 +02:00
thepraandClaude Opus 5.5 ca060f25fb Document the vendored tools, the theme helpers and the auth leftovers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 12:43:07 +02:00
thepraandClaude Opus 5.5 483ed271e0 Serve the styleguide's images and links locally
The styleguide loaded 42 images from picsum.photos and linked demo items to
daisyui.com. It now uses six small SVG landscapes in wwwroot/imgs/styleguide/
(the diff demo greys one with the grayscale utility), and its demo links stay on
/styleguide.

It also gains a carousel demo with navigation and indicators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 12:43:07 +02:00
thepraandClaude Opus 5.5 d71040f262 Add carousel navigation and the mixed checkbox state, without script
These were left out because daisyUI does them with scripts or with links that
<base href="/"> breaks.

DCarousel:
- ShowNavigation adds previous/next links on each slide, and ShowIndicators adds
  a row of numbered links under the strip.
- The links point at the slides' ids under the current path, so Blazor treats
  them as same-page fragments and scrolls the strip.

DCheckbox:
- Indeterminate renders aria-checked="mixed", which daisyUI draws like
  :indeterminate.
- A click clears it through IndeterminateChanged.
- The neo inset well leaves the mixed state alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 12:43:07 +02:00
thepraandClaude Opus 5.5 96d1db1008 Fix logout, the client log store and empty name attributes
Logout:
- It navigated to "authenticatio/logout", a misspelled route.
- The correct template route, authentication/logout, would throw anyway. Its
  RemoteAuthenticatorView casts the AuthenticationStateProvider to the OIDC
  template's service, and the app registers TokenAuthStateProvider instead.
- It now signs out through TokenAuthStateProvider.LogoutAsync, as the code
  commented out in the page intended, and returns home. LoginDisplay's sign-out
  goes through it as well.
- This also drops the obsolete SignOutSessionStateManager, the build's last
  warning.

The IndexedDB schema never declared the ClientLogs store that IStorage.AddLog
writes to, so every client log write failed inside its try/catch. Version 2 adds
the store, keyed by the Id AddLog assigns.

DInputBase.NameAttribute renders no name instead of name="" when the form maps
no field names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 12:43:07 +02:00
thepraandClaude Opus 5.5 81b92e012b Make every theme setting reachable and the theme controls effective
With the default settings (follow the system, light grey) the nav's hue slider
and dark toggle did nothing visible, and no UI could leave that mode. The slider
also always wrote the light hue, even in dark mode.

PageSettings gains SetDarkMode, SetGray and SetHue, and CascadingState gains
UpdatePageSettings. They keep the flags consistent:
- choosing light or dark leaves system theming;
- picking a hue leaves grey;
- grey follows the variant shown.
theme.js reports which variant is showing (AppStatusService.IsDarkTheme), so the
nav toggle and slider edit that one, including the OS's choice in system mode.

Settings > General, which was empty, now edits every theme setting:
- follow the system;
- dark mode;
- grey;
- the light and dark hues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 12:43:07 +02:00
thepraandClaude Opus 5.5 a097338121 Vendor Tailwind and daisyUI in the repo; never reach github.com
Builds used to download the Tailwind binary and the daisyUI plugins from GitHub
releases. They are now committed in tools/tailwind/:
- the Tailwind 4.3.3 standalone binary for linux-x64, xz -9e compressed (28 MB);
- daisyui.mjs and daisyui-theme.mjs 5.7.47;
- daisyUI's llms.txt component reference;
- Tailwind's MIT license.

tools/tailwind.sh no longer touches the network. "prepare" checks every file
against its pinned sha256 and unpacks the binary into .tools/. MSBuild reruns it
only when the archive or the script changes. A build from scratch was checked
inside a network namespace with no network at all.

.gitattributes keeps the vendored files byte-exact, and Tailwind no longer scans
tools/, whose llms.txt names every daisyUI class.

Both workflows used actions/checkout, which a Gitea runner downloads from
github.com. They now git fetch the commit from this Gitea, as they already did
for SocialPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 12:43:07 +02:00
thepraandClaude Opus 5.5 e075c4040b Delete the SCSS/Bulma pipeline and document the new one
Nothing loads the old bundle any more, so it goes, along with everything that
built it and the csproj entries that pointed at it:
- SCSS/, including the vendored Bulma 0.9.4 sass;
- compilerconfig.json and bundleconfig.json;
- tailwind.config.js and terminal-script.txt;
- wwwroot/css/style.min.css, main.css, main.min.css and tailwind-override.css;
- wwwroot/vendor's bulma, tailwind and toggle-dark-light-mode CSS.
The unused Font Awesome and Open Iconic assets stay.

The deploy workflow now requires css/app.css and its .gz in the publish output.
The .gz only exists when the generated file was registered as a static web
asset, so the check catches a silent regression of that. It also checks for
js/theme.js.

CLAUDE.md, new on this branch, documents the repo:
- the no-Node Tailwind/daisyUI build;
- the theme runtime;
- the neo layer, including the cascade-layer rule its overrides must follow;
- the rules for the D components.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 12:08:52 +02:00
thepraandClaude Opus 5.5 3ad4f55b11 Move the app's markup to the D components and Tailwind 4
The pages and components now use the daisyUI wrappers and the neo-* utilities
instead of Bulma and the neomorph classes:
- buttons are DButton, with pressed states as Active;
- nav links are btn / btn-active;
- the filter and composer selects are DSelect with a prefix icon;
- the composer's fields are DInput and DTextarea;
- the message options and scope pickers are DDropdown, which now close on an
  outside click and on Escape;
- the settings and admin sections are DCollapse;
- the dark-mode switch is a DToggle with sun and moon icons;
- the hue slider is a DRange on the rainbow track.

DropdownButton, OpenDownContainer and LoadingData are gone. The loader is
DLoading, whose animation runs; the old one's keyframes were never defined.
Icons coloured with Bulma's has-text-* now get daisyUI's text-info, success,
warning and error, which had no rules before.

Tailwind 3 to 4 fixes:
- space-x/y become gap, which also handles the flex-col-reverse feeds;
- !rounded-lg becomes rounded-lg!;
- drop-shadow becomes drop-shadow-sm;
- text-md becomes text-base.
The thread page marks its message with Content's new Highlighted parameter
rather than a second shadow size.

neo.css: a select's prefix icon gets the room daisyUI leaves for a span.label,
and the inner select lets the well show through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 12:08:11 +02:00
thepraandClaude Opus 5.5 90e4b9200c Add D-prefixed Blazor wrappers for every daisyUI 5 component
Components/Daisy/ wraps all 69 components in daisyUI's llms.txt, grouped by
daisyUI's categories (Actions, DataDisplay, Navigation, Feedback, DataInput,
Layout, Mockup), with part components where daisyUI has repeating parts (DTab,
DMenuItem, DTimelineItem, ...).
- Each enum maps to whole literal class names in a switch, so Tailwind's scanner
  generates them. Nothing is concatenated.
- Value-holding fields derive from DInputBase<TValue>, Blazor's InputBase, so
  @bind-Value and EditForm validation work. Invalid fields get the component's
  *-error class.
- Open and active state lives in C# (@bind-IsOpen, @bind-Active) and is rendered
  with daisyUI's *-open modifiers. DDropdown adds an outside-click layer and
  Escape. No component uses JS interop.
- The one exception is DCalendar, which wraps the Cally web component, vendored
  as wwwroot/vendor/cally.js (0.9.2, MIT).

/styleguide (not linked) renders every component in all its colours, sizes and
states, with a theme and hue picker that previews without saving.

neo.css follows what the styleguide showed:
- select keeps daisyUI's arrow;
- skeleton-text isn't sunk;
- the phone mockup and dock are raised;
- active menu items and OTP digits are inset;
- a joined accordion is raised as one block.
The primary, secondary and accent colours are deeper, so they still read as
text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 12:01:42 +02:00
thepraandClaude Opus 5.5 8e03d12a3e Apply the theme from PageSettings in JS, before the first paint
wwwroot/js/theme.js now picks the theme. It sets data-theme (neo-light or
neo-dark, or none when the OS decides) and the hue and chroma variables on
<html>, using the same flag precedence as the old CascadingState branches. It
also keeps <meta name="theme-color"> in sync. Because it runs in <head> from the
settings saved in localStorage, a reload no longer flashes the default palette
before Blazor boots. With system theming it also follows the OS when the colour
scheme changes.

CascadingState loses its ~200 lines of generated <style> and <Meta> tags. It now
calls AppStatusService.ApplyTheme on load and whenever the settings change. The
--fa-* icon colour variables are gone; nothing read them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 11:29:31 +02:00
thepraandClaude Opus 5.5 5b5fd62233 Port the neumorphic theme to daisyUI themes and Tailwind utilities
Styles/theme.css defines two daisyUI themes, neo-light (default) and neo-dark
(prefersdark). Every colour is an hsl() of three runtime variables:
--neo-hue-light, --neo-hue-dark and --neo-chroma. One formula replaces the six
hard-coded branches CascadingState writes today. Chroma 0 gives the grey themes,
and the fallbacks match PageSettings' defaults.

Styles/neo.css ports neomorph.scss:
- neo-raised, neo-inset and neo-flat, with sizes neo-2xs, neo-xs, neo-sm and
  neo-md. The size variables don't inherit, and Tailwind variants replace the
  old -sm..-2xl classes, some of which were never compiled.
- The pressed and raised gradients, and the rainbow hue-slider track.
- @utility overrides that give the neumorphic look to daisyUI's buttons, fields,
  selectors and surfaces. They sit above daisyUI's own cascade layers, so each
  one leaves out the variants it must not touch: ghost, link, outline and the
  rest.

Styles/base.css carries over base.scss: scrollbars, selection colours, the
focus outline reset, and link underlines everywhere except on buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 11:28:38 +02:00
thepraandClaude Opus 5.5 3551da1b02 Build CSS with Tailwind 4.3.3 standalone and daisyUI 5.7.47
tools/tailwind.sh downloads the pinned Tailwind binary and daisyUI's .mjs
plugins into the gitignored .tools/tailwind/, checking each against a pinned
sha256, so neither developers nor CI need Node. MSBuild compiles
Styles/app.css into wwwroot/css/app.css (also gitignored) on every build,
skipping the step when no source changed. The output is added to Content before
ResolveProjectStaticWebAssets, so a clean-clone publish still gets its .gz/.br
copies and a service-worker-assets.js entry.

index.html now loads css/app.css, plus ionicons as its own stylesheet. The old
bundle stays in the tree until the markup is migrated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-03 11:24:51 +02:00
409 changed files with 39559 additions and 17025 deletions

No files matched your search

+4
View File
@@ -61,3 +61,7 @@
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain
# Vendored tools must stay byte-exact: tools/tailwind.sh checks their sha256.
tools/tailwind/** -text
*.xz binary
+10 -3
View File
@@ -10,9 +10,16 @@ jobs:
name: Build
runs-on: build
steps:
- uses: actions/checkout@v4
with:
path: decePubClient
# A plain fetch from this Gitea rather than actions/checkout, which the runner would download from github.com.
- name: Check out this commit
env:
READ_TOKEN: ${{ secrets.THEPRA_READ_TOKEN }}
run: |
rm -rf decePubClient
git init -q decePubClient
git -C decePubClient -c http.extraHeader="Authorization: token $READ_TOKEN" fetch -q --depth 1 \
"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" "$GITHUB_SHA"
git -C decePubClient checkout -q FETCH_HEAD
- name: Fetch PrivaPub's client models beside it
env:
+14 -4
View File
@@ -16,9 +16,16 @@ jobs:
name: decepub.thepra.dev
runs-on: build
steps:
- uses: actions/checkout@v4
with:
path: decePubClient
# A plain fetch from this Gitea rather than actions/checkout, which the runner would download from github.com.
- name: Check out this commit
env:
READ_TOKEN: ${{ secrets.THEPRA_READ_TOKEN }}
run: |
rm -rf decePubClient
git init -q decePubClient
git -C decePubClient -c http.extraHeader="Authorization: token $READ_TOKEN" fetch -q --depth 1 \
"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" "$GITHUB_SHA"
git -C decePubClient checkout -q FETCH_HEAD
- name: Fetch PrivaPub's client models beside it
env:
@@ -44,10 +51,13 @@ jobs:
- name: Assert the publish actually produced a site
run: |
W="$GITHUB_WORKSPACE/publish/wwwroot"
for f in index.html appsettings.json build.json _framework/blazor.webassembly.js css/style.min.css; do
for f in index.html appsettings.json build.json _framework/blazor.webassembly.js css/app.css css/app.css.gz js/theme.js; do
[ -e "$W/$f" ] || { echo "::error::publish output is missing $f"; exit 1; }
done
ls "$W/_framework" | grep -q '\.wasm$' || { echo "::error::no .wasm in _framework"; exit 1; }
for f in appsettings.Development.json appsettings.Pasture.json; do
[ ! -e "$W/$f" ] || { echo "::error::publish output holds $f, which points the app at a test server"; exit 1; }
done
echo "publish OK, $(du -sh "$W" | cut -f1)"
- name: Snapshot the live directory
+8
View File
@@ -363,3 +363,11 @@ MigrationBackup/
FodyWeavers.xsd
/.idea/.idea.decePubClient/.idea
/.idea/config
# Tailwind standalone + daisyUI downloads (tools/tailwind.sh) and the CSS they generate
.tools/
wwwroot/css/app.css
# the e2e tests: the published app they serve and the browser state they keep
tests/e2e/.publish/
tests/e2e/.state/
+8 -4
View File
@@ -10,18 +10,22 @@
}
else
{
<p role="alert">You are not authorized to access this resource.</p>
<p role="alert">@Localizer["You are not authorized to access this resource."]</p>
}
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="a" />
</Found>
<NotFound>
<Title>Not found</Title>
<Title>@Localizer["Not found"]</Title>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
<p role="alert">@Localizer["Sorry, there's nothing at this address."]</p>
</LayoutView>
</NotFound>
</Router>
</CascadingState>
</CascadingAuthenticationState>
</CascadingAuthenticationState>
@code {
[Inject] CoalescingStringLocalizer Localizer { get; set; }
}
+647
View File
@@ -0,0 +1,647 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
decePubClient is a Blazor WebAssembly PWA (.NET 10). It is meant as a Pleroma-FE-like client for **PrivaPub**, the
owner's ActivityPub server, whose repo is `thepra/SocialPub`. It is deployed as a static site at
https://decepub.thepra.dev, and its API base address is `https://privapub.thepra.dev`
(`AppConfiguration:ApiBaseAddress` in `wwwroot/appsettings.json`).
It talks to PrivaPub through both of its APIs with one sign-in (see **PrivaPub wiring** below). The feed page has three
columns: navigation, the feed with a tab per kind of feed, and a 3D globe that follows the posts in view. Nerd stats sit
in tooltips wherever there is data. Development builds point at a local PrivaPub on `http://127.0.0.1:6970`
(`wwwroot/appsettings.Development.json`, never published).
`Docs/Insomnia_APIs.json` is an old API collection for a localhost backend. Don't treat it as PrivaPub's API.
## Sibling repo dependency
The project references `../SocialPub/PrivaPub.ClientModels/PrivaPub.ClientModels.csproj`, so SocialPub must be
checked out next to this repo. CI clones it there. That project supplies:
- the shared DTOs;
- `Policies` (IsAdmin, IsUser, IsModerator);
- the `FieldsNameResource` and `ErrorsResource` localization resources.
Before wiring anything to the API, read `../SocialPub/CLAUDE.md`. PrivaPub's route names (`/peasants/...`, `/mouth`,
`/anus`, ...) are deliberate. Never "correct" them. The API surface is the Mastodon client API plus PrivaPub's private
`/clientapi` and `/api/privapub/v1`.
To run against a local PrivaPub: start a mongod, run SocialPub's `PrivaPub` with the Development command in its
CLAUDE.md on `127.0.0.1:6970` (`Registrations__Mode=Open` lets `/clientapi/user/signup` create a root), then create
personas with `/clientapi/avatar/private/insert`. Federated posts come from SocialPub's pasture
(`tools/pasture/run.sh up gts akkoma`, then `tools/pasture/interop.sh gts akkoma`), whose API is `127.0.0.1:6971`.
Neither DB-IP nor PrivaPub locate private addresses, so for the globe set `RemoteInstance.Geo` in mongo and
`Statistics:Geo:Self` in PrivaPub's configuration.
## Commands
```sh
dotnet build decePubClient.csproj # what CI runs (-c Release) on every push to master and on PRs
dotnet run # dev server: https://localhost:7046 / http://localhost:5046
dotnet publish decePubClient.csproj -c Release -o publish # static site lands in publish/wwwroot
```
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter.
## Testing: end to end against the pasture
`tests/e2e` (Playwright for .NET 1.61.0 on xunit v3) runs the published app in Chromium against SocialPub's pasture: a
private fediverse of PrivaPub and its peers (GoToSocial, Mastodon, Misskey, Sharkey, Akkoma, Lemmy) seeded with a fake
community, the town (see `../SocialPub/CLAUDE.md`, "The town"). Never in CI: it needs podman and the pasture.
```sh
../SocialPub/tools/pasture/run.sh up gts mastodon misskey sharkey akkoma lemmy # PASTURE_PORT if 6971 is taken
../SocialPub/tools/pasture/town.sh seed village
tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentName=Pasture, serves https://decepub.test, runs
```
- **The app under test is the deploy's own build** with the environment baked in: Blazor on .NET 10 ignores the
`Blazor-Environment` header, so `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is chosen at publish
time and published only then; the deploy fails if it ever ships.
- Chromium resolves `*.test` to Caddy on `127.0.0.1:6443` (`--host-resolver-rules`) and ignores the pasture CA. The
tests use the Chromium headless shell 1228 cached in `~/.cache/ms-playwright` and never install a browser.
- **Guards on every test** (`Support/Browser.cs`): the app talks only to its own site and to PrivaPub, a root JWT never
goes to `/api` nor a persona token to `/clientapi`, no 5xx, no Blazor error bar.
- **The tests ask the town** (`Support/Pasture.cs`) which accounts exist and what each object holds (`town.sh expect`),
and check the other side of the wire through `town.sh stored`. Each render and action test is also a cell of the
town's report (`out/client.jsonl`).
- Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. The feed renders
only the cards near the view, so a test looks for recent cards, and counts what is loaded by `data-feed-count`.
- **Phones** (`Mobile/`, `Category=Mobile`): `Browser.Open(state, Phone)` opens Playwright's own device presets
(shipped in its package): 360×800, iPhone SE (3rd gen), Pixel 7, and the Pixel 7 held sideways, with touch, a coarse
pointer and reduced motion; a phone session fails at once if the page doesn't see a touch screen.
`SignIn.As(browser, persona, phone)` reuses the desktop sign-in, and any saved sign-in that has expired is renewed.
- `RouteSweepTests` measures every page, one screen at a time down its main scroller (`Mobile/fit.js`). It checks:
- sideways scroll, unless the strip is marked `data-scroll-x`;
- boxes past the screen's sides;
- open menus, sheets and dialogs that are cut or outside the screen;
- targets without a 44px hit area: hit-tested at their centre and 21px around it, so a crowding neighbour fails
them too. Inline links in post text, native media controls and disabled controls are exempt, a fixed floating
control covering a target doesn't count, and `data-touch-exempt` exempts one explicitly;
- control text under 12px.
Screenshots, findings and a contact sheet go to `tests/e2e/out/mobile/<run>/`. Cells are `client.mobile.fit.*` and
`client.mobile.touch.*`, with the phone as the ref.
- `FlowTests` drives the narrowest phone through the menu, a dialog, a new post, a reply, a reaction, a deletion, the
full-screen globe, a phone held sideways, a tap into a thread, a picture and the Back button (`client.mobile.flow.*`).
- `DesktopUnchangedTests` holds desktop's control sizes.
- **The globe** (`Render/GlobeTests.cs`): the instruments are built, with the Sun drawn over its subsolar point.
- On desktop, a located post written from Milano is pinned, and the globe is photographed in both themes and again
turned to the subsolar point.
- On a phone, the banner draws little, full screen draws everything with the camera moved back, and closing returns
to the banner.
- An idle globe draws no frame.
Photos and stats go to `tests/e2e/out/globe/`.
- **Restore** (`Category=Restore`, in `Actions/BackupsTests.cs`): a real restore of the pasture from Administration. It
ends every session, the town's too, so it runs only when asked (`--filter "Category=Restore"`, never next to other
tests) and signs the town in again (`town.sh renew privapub`). Normal runs use `--filter "Category!=Perf&Category!=Restore"`.
- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests`
scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time,
DOM nodes, JS and .NET heaps and long tasks to `perf.json`.
- The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it.
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
`Styles/app.css` is compiled into `wwwroot/css/app.css` by the Tailwind CSS standalone binary with the daisyUI
plugin. `app.css` is gitignored and regenerated on every build.
**Nothing in the build or the CI may download from github.com.** Tools live in the repo, and the CSS build runs
without network access. NuGet restore still uses nuget.org.
- `tools/tailwind/` holds the pinned tools:
- Tailwind 4.3.3 as `tailwindcss-linux-x64.xz`;
- daisyUI 5.7.47 (`daisyui.mjs`, `daisyui-theme.mjs`);
- daisyUI's component reference, `daisyui-llms.txt`.
- `.gitattributes` keeps these files byte-exact.
- `tools/tailwind.sh prepare` checks them against the sha256 values pinned in the script, and unpacks the binary into
the gitignored `.tools/tailwind/`. It needs `xz`.
- Only linux-x64 is vendored.
- The script's header explains how to upgrade or add a platform.
- The `TailwindCompile` target in the csproj runs it, so `dotnet build` / `publish` are enough, in CI as well. Debug
builds use `--optimize`, Release builds `--minify`. Pass `-p:SkipTailwind=true` to skip it.
- `IncludeTailwindOutput` adds the generated file to `Content` before static web assets are resolved. Without that, a
clean-clone publish would ship `app.css` without its `.gz`/`.br` copies and outside `service-worker-assets.js`.
The deploy workflow checks for `css/app.css.gz`.
- `bash tools/tailwind.sh watch` rebuilds the CSS live next to `dotnet watch`.
- Tailwind scans every file `.gitignore` doesn't exclude (minus `tools/`, `wwwroot/vendor/`, `Docs/`), and only
generates the classes it finds written out **whole**. Never build a class name by concatenation or interpolation (`"btn-" + size`, `$"btn-{size}"`). Write
every variant as a literal, e.g. in a switch arm.
Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is loaded as its own stylesheet.
## Theme and neumorphism
- `Styles/theme.css` holds two daisyUI themes, `neo-light` (default) and `neo-dark` (`prefersdark`). Every colour
is an `hsl()` of `--neo-hue-light` / `--neo-hue-dark` / `--neo-chroma` (0 for the grey themes).
- `wwwroot/js/theme.js` sets those variables and `data-theme` on `<html>` from `PageSettings`. It runs in `<head>`
from localStorage, so there is no flash before Blazor boots. After that, CascadingState calls it through
`AppStatusService.ApplyTheme` whenever the settings change. With `PreferSystemTheming` it leaves `data-theme` off,
and the OS decides.
- Change the theme flags through `PageSettings.SetDarkMode` / `SetGray` / `SetHue` and
`CascadingState.UpdatePageSettings`, which keep the flags consistent:
- choosing light or dark leaves system theming;
- picking a hue leaves grey.
- `AppStatusService.IsDarkTheme()` tells which variant is showing.
- Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones.
The other sections of Settings belong to the persona (see **Settings** under PrivaPub wiring).
- `Styles/neo.css` holds the neumorphic layer:
- `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
old `neomorph is-nxsmall` classes);
- `neo-gradient(-pressed)` and `neo-hue-track`;
- `@utility <daisy-component>` overrides that give btn, input, select, card, … the neo look. daisyUI puts its rules
in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the
daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo
shadow with `shadow-none!`.
- Selectors are an inset well; a checked checkbox is filled and raised like a primary button (daisyUI fills one only
for a colour variant, and without one drew a tick on nothing), and a checked radio's dot is the primary colour. A
file input's own button (Choose File, Browse…) is raised like the app's buttons.
- Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
every collapse disappears.
## Phones
Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized targets. The rules:
- **Width and pointer are separate.**
- Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`).
- How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and
`pointer-coarse:` at a call site.
- A mouse matches none of it, so desktop keeps daisyUI's sizes (`Mobile/DesktopUnchangedTests` holds them).
- A window at most 30rem tall (a phone held sideways) is the `short:` variant.
- **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets
daisyUI's own size variables:
- buttons, tabs, inputs, selects and file inputs are 44px tall;
- extra small buttons and the badges that act are 36px, with a transparent `::after` that reaches 44 (5px around
the padding box);
- checkboxes and radios are 24px, toggles and range thumbs 28;
- field text is 16px, so iOS doesn't zoom into a focused field.
Its `touch-target` utility gives a small link or button a 44px hit area. It can't be used on something that clips
its own overflow (a `truncate`d name takes `pointer-coarse:py-3.5` instead), nor on a daisyUI tooltip, which draws
its arrow with `::after` (NerdTip takes minimum sizes).
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`). The
phone sweep fails anything less.
- **Overlays on a phone rise from the bottom.**
- `DModal Placement="ResponsiveBottom"` is a sheet below md and a centred dialog from md. Every DModal has a visible
close button (`ShowClose`, named by `CloseText`).
- `DDropdown SheetOnNarrow` turns its content into a bottom sheet.
- `Components/PhoneSheet.razor` shows what opens in place on a desktop (the composer, a reply, a quote, an edit) as a
sheet on a phone.
- All are capped by `dvh`, and the app itself is `h-dvh`.
- **The Back button closes the newest open overlay** instead of leaving the page.
- Overlays register through `IDOverlayHost` (`Components/Daisy`, cascaded from `MainLayout`):
- `DModal` and `DDropdown` through the cascade;
- the nav menu, `PhoneSheet` and the full-screen globe through `Services/OverlayService.cs`.
- `main.js` marks a real Back press (a capture-phase `popstate`), since Blazor can't tell it from the app's own
navigation, which closes every overlay and goes on.
- No history entries are pushed.
- **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in
`MainLayout`): deleting a post, taking it off a wall, muting, blocking, leaving a group, dropping a scheduled post and
signing out.
- **On a touch screen:**
- a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are
pinned to the globe with a mouse only;
- a tap on a nerd-stats trigger opens its rows in a sheet (`NerdTip`, `AppStatusService.CanHover`);
- pictures open in `Components/MediaViewer.razor`.
- **The feed on a phone:**
- a card's actions are five equal cells (Reply, Boost, Favourite, React, More). Bookmark, the thread and the nerd
stats' Details are in More, and the author's picture is in the header;
- a new post starts from a floating button;
- the globe's banner opens full screen, and a phone held sideways opens it full screen from the tabs;
- the feed tabs show their names and fade at the edge where more wait (`edge-fade-x`, `data-scroll-x`).
## daisyUI Blazor components (`Components/Daisy/`)
Every daisyUI component has a `D`-prefixed wrapper (`DButton`, `DModal`, `DDropdown`, `DInput`, ...), grouped by
daisyUI's categories into folders whose namespaces are imported in `_Imports.razor`. `/styleguide` (not linked) renders
all of them in every theme. When you add or change one:
- Map each enum to its classes in a switch with whole literal class names.
- Value-holding fields derive from `DInputBase<TValue>`, Blazor's `InputBase`, so `@bind-Value` and EditForm
validation work. Everything else derives from `DComponentBase` (`Class`, pass-through attributes, `Cx(...)`).
- Open/active state lives in C# (`@bind-IsOpen`, `@bind-Active`) and is rendered with daisyUI's `*-open` modifier
classes. No JS interop.
- What daisyUI does with scripts has a script-free equivalent here: `DCheckbox.Indeterminate` renders
`aria-checked="mixed"`, and `DCarousel`'s navigation links to the slides' ids, which Blazor scrolls to.
- `DCalendar` is the one exception: it wraps the vendored Cally web component (`wwwroot/vendor/cally.js`).
- Images and links in `/styleguide` stay local (`wwwroot/imgs/styleguide/`).
- Don't name a parameter after an HTML attribute (`Title`, `Style`, `Id`, `Label`, ...). Blazor matches attributes
to parameters without regard to case, so the parameter would swallow the attribute.
- Components don't use `Localizer` or `CascadingState`; their user-visible strings are parameters.
## Architecture
**Startup (`Program.cs`).**
- DI registration happens here.
- The named `HttpClient` "default" uses `ApiBaseAddress` as its base address. It falls back to the host's base
address when that setting is empty.
- `SetDefaultCulture()` (`Extensions/ExtensionMethods.cs`) runs before the app starts. It picks the culture from the
saved `PublicCacheData`, or on a first visit from the browser language.
- Only English (`en-GB`) and Italian (`it-IT`) have resources; any other language gets English.
- Settings → General changes the language, saves it, and reloads the app.
**`LayerComponents/CascadingState.razor`** wraps the router in `App.razor`. It is the app-wide state object. It handles:
- `PublicCacheData` (page settings: theme colours, dark/light/system theming, language), persisted in localStorage;
- the theme, applied through `AppStatusService.ApplyTheme` (`wwwroot/js/theme.js`);
- `IsOnline` / `IsOffline`, polled every 10 seconds;
- the session: `IsSignedIn`, `Personas` (the root's), `PersonaId` and `Persona` (its Mastodon `Account`), with
`LoadSession`, `SwitchPersona` and `SignOut`. A persona change re-renders every subscriber; pages compare
`PersonaId` and reload what belongs to the persona. The JWT is renewed hourly when needed;
- `ProcessError` / `ProcessWarning`, which log through `ILoggingService` and show a toast;
- `LogFromJs`, which JS calls back into through a `DotNetObjectReference`.
**Components and pages** inherit `LocalizableComponentBase`, the equivalent of b2b.next's `WidmannComponentBase`. It
supplies:
- `CascadingState`, `Localizer`, `Navigation` and `Toast`;
- `IsLoading`, which starts true, and `IsDefaultDisabled`;
- `AfterRenderAsyncJobs`, run once after the next render;
- `WhenOnline` / `StartOnlinePolling`.
`PagesBase` is an empty subclass of it that nothing uses. Never redeclare the base's members in a component.
**UI feedback.**
- `Services/ToastService.cs` is the snackbar: the SUtility extensions `AddResponseError(WebResult)` (silent on
410/cancelled; connectivity failures show as warnings), `AddResponseError(string)`, `AddResponseSuccess` (closes
after 5 s), `AddWarning` and `AddException`. `Components/ToastHost.razor`, placed in `MainLayout`, shows the
messages.
- `Components/ErrorViewer.razor` shows in-page errors: the cascading `EditContext`'s validation messages,
`AdditionalError` and `AdditionalErrors`.
- `ConfirmService` asks before what can't be undone (see **Phones**).
**HTTP (`Services/IHttpService.cs`).**
- Routes are constants in `Models/APIs.cs` (b2b.next naming: `GET_`/`POST_`/`DELETE_`, `_M_` for routes built from
parameters), relative to `ApiBaseAddress`.
- The route picks the credentials: `clientapi/` sends the root's JWT, `api/` the current persona's Mastodon token
(exchanged when missing, exchanged once more and retried when PrivaPub refuses it), `oauth/` nothing.
- It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`,
`SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload
can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt.
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
PrivaPub redirects it).
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
`HttpClient`'s own is off.
- Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
`{"error": ...}`) and bodies with `DefaultReadFromJsonAsync` / `MastodonReadFromJsonAsync`; a body that doesn't fit
comes back as the default and is written to the browser console. `PageCursors()` reads `max_id`/`min_id` from `Link`.
- Every request PrivaPub answers is recorded in `RequestStats` (method, route template, status, duration, size, rate
limit headers; never a body or a token), for the nerd stats.
- PrivaPub's `WebResult` gets `IsInvalid` from `Extensions/WebResultExtensions.cs`, a C# 14 extension property.
**PrivaPub wiring.**
- **One sign-in** (`Services/AuthService.cs`, `Pages/Login.razor`): the root signs in on `/clientapi/user/login`, then
each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`,
RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the
persona tokens; logout revokes every persona token, then forgets the session and IndexedDB.
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
threads, mute, block, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and
leaving remote events through `/api/privapub/v1/statuses/:id/join|leave`; located and group posts go through
`/clientapi/post/insert`, and a located post's author deletes it through `/clientapi/post/delete`, its card's one action), `AccountService` (profiles, relationships, follow,
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
`FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and
following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService`
(`/clientapi/moderator` and `/clientapi/admin`), `MediaService` (uploads), `GroupService`, `InstanceService` and
`ProvenanceService`.
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll
vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice,
with its own `PublicAct`: joining an event (owner decision 2026-10-05) is asked once more, since the event's server
shows who takes part. An event card shows the persona's participation (`privapub.event.participation`: pending,
accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`).
- **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:`
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag), a threadiverse
post's downvotes next to its favourites, blurhash placeholders until a picture loads (`wwwroot/js/blurhash.js`
paints any `[data-blurhash]`: a picture's until it loads, again when its source changes, and for good behind the
sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and
status. A picture, video or sound that fails to load says "Media unavailable" with "Open original"
(`data-media-unavailable`), and a gifv plays only on a tap under `prefers-reduced-motion`.
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's
author and a notification's account open the profile in the app; "Open original" is the only way to the remote site.
A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the
persona pins and unpins its own public and unlisted posts from the card's menu.
An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one),
linking the new account's profile.
Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words,
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona.
Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`).
- **Walls** (Smithereen's, FEP-400e): a post written on someone's wall says whose (`privapub.wall`, `data-wall`, linking
that profile); one written on the persona's own wall is taken off from the card's menu (`DELETE` on the status, which
PrivaPub turns into a `Remove` that Smithereen takes for a deletion). Only the persona's followers may write there.
A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing.
- **Groups** (`Pages/Groups.razor` at `/groups`): the persona's communities and circles (`GroupService`, `/clientapi/group`):
made (a circle unless it is a community, with an invitation code and an optional password), joined by invitation and
left; the ones it runs edited, their invitation shown, the requests to join let in or declined, members taken out.
Only a group's owner and moderators see who is in it.
- **Lists** (`Pages/Lists.razor` at `/lists`): made, renamed, given a replies policy and made exclusive (their members
leave the home feed) there, filled from the accounts the persona follows; a followed account's profile has a Lists
menu too. A list is read as the feed's Lists tab (`FeedFilter.ListId`). Lists never leave PrivaPub.
- **Filters** (`Pages/Filters.razor` at `/filters`, linked from Settings): PrivaPub marks every status with the filters it
matches (`Status.Filtered`), whatever their context; a page tells each card where it is (`Content.FilterIn`: home and
lists, notifications, public feeds, threads around the opened post, profiles; none for favourites, bookmarks,
conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs
its media (blur).
- **Articles** fold in a feed (`Content.IsFoldedArticle`): an Article or a Page with its title, or a titled post longer
than a few paragraphs (WriteFreely sends Notes with a name), shows its cover, its excerpt and a Read button; the
post opened in its thread shows whole.
- **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`); a post can hold
a poll instead of media.
- **Media are uploaded as soon as they are picked** (`Services/MediaService.cs`, `Models/UploadMedia.cs`):
- each is streamed from the browser's file, and only a 320 px thumbnail (`RequestImageFileAsync`) is kept to show it;
- each shows its state (`data-upload-state`: uploading, processing, ready, or failed with Retry);
- audio and video PrivaPub answers 202 for are followed (`GET /api/v1/media/:id`, 206 until ready);
- the ids are kept, so a post that fails never uploads its media again;
- the types and sizes accepted are PrivaPub's own (`/api/v2/instance`, `MediaService.Limits`);
- a description changed after the upload is PUT before the post.
- **Alt text:** publishing a picture without a description asks once (`data-alt-reminder`, "Publish anyway"), unless
the reader turned `PageSettings.RemindAltText` off in Settings → General.
- **Edits send the whole post, as Mastodon wants it:** its words and warning from `/source`, its own language and
sensitive flag, the media it keeps or adds (`media_ids`; removing them all is `[]`), and the descriptions changed
(`media_attributes`). Its visibility and poll stay.
- A new post's media are sensitive when the persona's "always mark media as sensitive" (`source.sensitive`) says so.
A mention or hashtag being written at the end of the words is offered what PrivaPub already knows
(`/api/v2/search`, never resolving), once typing pauses.
An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub
keeps a remote post's earlier versions from its edits and from Pleroma's `formerRepresentations`).
- **Threads** (`Pages/ExpandMessage.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies
from their servers (its FEP-7888 `context`, else `replies`), so the page reads the thread again after 4 and 15 seconds
and adds what was found, in the thread's order.
- **Interaction policies** (GoToSocial's, and FEP-5624's `canReply` on a PeerTube video whose comments are moderated): a
reply or boost whose target's author must approve it shows so on its card (`privapub.approval`: pending, then nothing
once approved, or rejected); one the policy shuts out is refused by PrivaPub (422) and shown as an error.
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed,
or the whole of it deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`;
the client then signs out), the persona's profile (`ProfileEditor`:
`update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file,
and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot,
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
resolves each row through PrivaPub and acts one row at a time; it can be stopped. Above it, `PersonaArchivePanel`
(`ArchiveService`, `/clientapi/persona/{id}/archive`; owner decision 2026-10-07): the persona's whole archive made on
PrivaPub in Mastodon's layout and downloaded through a link; and an archive (its own, or Mastodon's) uploaded in pieces
by `js/transfer.js` and imported into it, the parts chosen (likes, located and scheduled posts off unless ticked),
with progress, a report and Stop. Imported posts are delivered to no one.
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list
of users, and adding an admin endpoint waits for the owner, so the Users section stays empty.
- **Backups** (`Components/Admin/BackupsPanel.razor`, `/clientapi/admin/backups`; owner decision 2026-10-07): the server's
backups (nightly, before each deploy, made here, uploaded), with what runs and the last restore's report.
- A download asks for the password and gives a link good for ten minutes, a plain anchor, so the browser saves the tar
to disk.
- An upload never passes through .NET's memory: `wwwroot/js/transfer.js` PUTs `file.slice` pieces at the offset PrivaPub
has, and carries on from what PrivaPub says after a refused or lost piece.
- A restore asks for the password and the server's host typed out, and says what it means. PrivaPub stops, restores as
it starts again and ends every session, so the page shows it restarting until the ended session sends it to sign in.
- **Notification policy** (`Components/Settings/NotificationPolicyEditor.razor`, in Settings and at `/notifications/filtered`):
what notifications from people the persona does not follow, people who do not follow it, new accounts, private
mentions it did not ask for and limited accounts become (accept, filter, drop; `/api/v2/notifications/policy`), and the
filtered ones, one request per account, let in (with that account from then on) or dismissed. The Notifications tab
links there while requests wait.
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them.
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
it holds), the directory, and the hashtags the persona follows. A hashtag's feed tab follows it too.
- **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from
`PrivaPub.ClientModels`.
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
its event's venue, the reader for a located post, its author's server (to 0.1°, or a point in its country from
`Helpers/CountryPoints.cs` when DB-IP located it only to a country), or, for a CDN-fronted server, its CDN's group
(`LocationSource.Cdn`: no point, a `CdnKey` and the place `Before` the CDN). Server descriptions are cached a day in
localStorage (`ServerInfos`); a server's weekly history (`/instances/:host/history`) and a CDN's page (`/cdns/:key`)
are read on the first hover and kept for the session. PrivaPub's own host is the signed-in persona's account host,
since the API may be reached under another name.
- **The feed page** (`Pages/Index.razor`): `FeedTabs`, `Content` (the card, which does its own actions), `Composer`,
`GlobePanel`. Leaving it keeps the feed in `Services/FeedMemory.cs` (its posts, waiting posts, cursors and scroll,
ten minutes, for the same feed of the same persona), so coming back from a thread or a profile shows it again at once
without reading it anew. Older pages load as the end comes near (`wwwroot/js/feed.js`). Newer ones come through PrivaPub's
streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL)
for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit
it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible.
- **At volume:** the list is a `<Virtualize>` (only the cards near the view are rendered), and a card re-renders only
when its own events run or its render key changes (`Content.RenderKey`: the post, its status, notification,
conversation, place, grouping, where it is shown, the persona and the connection). Anything that changes what a
card shows from outside it must replace one of those objects or be added to the key; mutating one in place leaves the
card as it was. Without these a feed took seven minutes to reach a thousand cards and held 900 MB at two thousand.
- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in
`wwwroot/vendor/globe/` with its textures and `countries.json` (`SOURCES.md` has versions, licences and hashes; the
service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an
arc to a hovered post, honours `prefers-reduced-motion`, and draws only while something moves (idle, it costs no GPU
time; `wake()` before anything that animates).
- **Map.** Each pixel computes its own map coordinates from its point on the sphere (`sharpen`), so the poles don't
pinch; the maps wrap horizontally and get 8× anisotropic filtering (`keepSharp`). The day texture is NASA's, its
seam repaired by `tools/globe/prepare-day-texture.py` (dev-only, needs Pillow).
- **Sun.** `wwwroot/js/sun.js` (pure functions of a date: the subsolar point, Earth–Sun distance, seasons, a place's
elevation, phase of the day, solar time and day length) lights the globe in the shader: the day map shaded by the
Sun's height and distance, city lights on the night side, a twilight blend between. It shows now (refreshed every
30 s), or the moment of the hovered, focused or pinned post (`data-post-at` on the card; a click pins a card,
`data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment and its phase at the panel's
top and keeps the Sun's numbers as screen-reader text (`aria-describedby`). The theme changes only the atmosphere and
the points.
- **Instruments** (`wwwroot/js/instruments.js`; owner decision 2026-10-06: the nerd stats drawn where they belong, not
a table). The globe has no tooltip. What it draws:
- the Sun: its ray and bead over the subsolar point, the day/night line and the twilights, the declination, the
noon and midnight meridian, and its analemma at this hour to the next equinox or solstice;
- a focused post's place: its horizon, its Sun's height and its day lit on its latitude;
- the tropics, the polar circles and the CDN ring;
- the view's centre and spread, and rings around the posts in view;
- a small orrery in the top left corner, re-anchored to the camera before each render.
The numbers are chips in a DOM overlay (`.globe-instruments`, `aria-hidden`), placed after each frame globe.gl renders
(`scene.onAfterRender`), clear of each other, the moment's caption, the orrery and the legend. The frame rate is a
ring around the globe; what is left is a legend in the bottom left. Geometry changes only inside a wake globe.js
already made, so an idle globe still draws nothing.
- three.js's classes come from objects globe.gl made (`classesOf`), never `window.THREE`, since that would mix two
copies. three.js sets its `is*` flags on instances, so the lookup checks instances. globe.gl adds its globe on its
first update, after `new Globe()`, so the parts are built before the first render that finds it. Without the
classes, only the ring and the legend remain.
- A canvas under 320px tall (a phone's banner) gets the day/night line and the subsolar point only; full screen
gets everything.
- **Countries.** Land borders as thin paths, and names placed by `placeLabels`: largest countries first, once they
span enough pixels, away from the globe's edge and clear of each other, in the reader's language
(`Intl.DisplayNames`). `countries.json` is built by `tools/globe/prepare-countries.py` from world-atlas (Natural
Earth). The label renderer owns `display`, so labels hide through `visibility`.
- CDN-fronted posts are labels per CDN on a ring at 68°N, never a place; hovering one draws its arc there and a ring
where the server was before the CDN. The posts' places reach it in one call per feed change; cards are matched by
`data-post-id`.
- **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on
hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last
request. The globe's are its instruments (see **The globe**), with no tooltip and no poll.
**Localization (`Services/CoalescingStringLocalizer.cs`).**
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
`ErrorsResource`.
- A missing key renders as the key itself, formatted with its arguments, so a typo fails silently.
- The class lives in namespace `collAnon.Client.Services`, a leftover from another project.
- `Resources/ErrorMessages.resx` is read by `[Required]` / `[StringLength]` through `ErrorMessageResourceType`. A new key
needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
**Client storage** comes in two layers:
- **localStorage** (Blazored.LocalStorage, camelCase JSON) holds `AuthData`, `PublicCacheData`, `FeedFilter` and the
server cache `ServerInfos`, keyed by `nameof(Type)`.
- **IndexedDB** (DnetIndexedDb, database `data`) is reached through `IStorage`. Its schema is declared in
`GenericExtensions.AddIndexedDb()`, and you must bump `WithVersion` when you change it.
- The schema is at version 3: the `ClientLogs` store that `IStorage.AddLog` writes (version 2 added it; version 3 left
the mock feed's `Message` store out).
**Auth.** `TokenAuthStateProvider` holds `AuthData` (localStorage, cached in memory) and builds the auth state from
it: the root's name, its policies as claims, and the current persona. Pages that need a session carry
`[Authorize]`; `RedirectToLogin` sends the reader to `/login?returnUrl=…`.
**JS interop.**
- `wwwroot/main.js` defines `window.*` helpers. `Services/AppStatusService.cs` calls them, mostly through the
synchronous `IJSInProcessRuntime`.
- Each helper reports its errors back through `window.logFromJs`.
- The other scripts (IndexedDB, HeadElement, rxjs) are loaded by `<script>` tags in `wwwroot/index.html`; the feed's
modules (`js/feed.js`, `js/globe.js`) are imported on demand through `IJSObjectReference`.
**PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version.
## Development rules
These rules are adapted from the owner's b2b.next project. They are enforced by review: the build doesn't check them,
so follow them in every change.
| Area | Rule |
|---|---|
| All | `var` everywhere; uninitialised locals are `var x = default(T);` |
| All | Primary constructors for DI (`class Foo(IBar bar)`), not constructor + fields |
| All | Every async method takes a `CancellationToken` as its last parameter |
| All | Early return on error: guard, handle, `return`; never if/else for success vs failure |
| Models / state | Lists and arrays `= []`, never null; no `= string.Empty` / `""` defaults; explicit enum defaults |
| Components | No `@{ }` blocks in markup: locals become `@code` members |
| Components | `[Inject]` in `@code`, never `@inject` |
| Components | Disable controls with `IsDefaultDisabled` (plus local conditions), never bare offline state |
| Components | Every text/number input declares `autocomplete` |
| Services | `Task<WebResult>` for mutations, `Task<List<T>>` (`[]` on error) for lists, `Task<T>` with a default for values |
| Localization | Every user-facing string through `Localizer`, its key in **both** `AllStrings.resx` and `AllStrings.it.resx` |
**CancellationToken.** It goes on every async method in services, `CascadingState` and helpers, defaulting to
`default`; pass it on to what you call. Exempt: Blazor lifecycle overrides, `[JSInvokable]` methods, framework overrides
(`GetAuthenticationStateAsync`), and private UI handlers bound from markup.
**State initialisation.** Initialise page and component state (`= new()`, `= []`) and bind it directly. No passthrough
getters and no `?.` chains over state that can't be null:
```csharp
List<FeedPost> posts = []; // DO
@posts.Count // DO
@(posts?.Count ?? 0) // DON'T
```
`= ""` is allowed only as a documented exception, with the reason in a comment (e.g. a decorative image's `Alt`).
**Pages that load data.** Pages fetch in `LoadData` and keep lifecycle concerns in the lifecycle method:
```csharp
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
```
- Reuse the base `IsLoading`; don't add per-section loading flags.
- A component that binds `IsDefaultDisabled` must set `IsLoading = false` once it is ready, even if it loads nothing;
otherwise its controls stay disabled.
- Never wrap a service call in try/catch. Services already catch and log, and return an invalid `WebResult`. Branch on
`result.IsInvalid` instead:
```csharp
var result = await StatusService.Delete(Status.Id);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["Message deleted."]);
await OnDeleted.InvokeAsync(Post);
```
**Client services** follow b2b.next's two patterns (see its `StoreService`):
- Create `var result = new WebResult();`, call `httpService` inside a try/catch, and on a non-success status
`result = await response.Content.ReadWebResult(response.StatusCode, localizer, ct)`. Log with `ILoggingService`.
- Return `result` (mutations) or `[]` (lists), and set `result.Data` on success.
- POST bodies are JSON payloads for `/clientapi` and the Mastodon API (snake_case DTOs), forms for `/oauth`.
**In-page errors.** `ErrorViewer` takes `AdditionalError` for one message and `AdditionalErrors` for a list. Never
join a list into one string with `<br/>` or `\n`.
**Autocomplete values:**
- `autocomplete="off"` for search, filter and free text;
- `new-password` when setting a password;
- `one-time-code` for OTP (DOtp sets it);
- `username` and `current-password` on the login form, so password managers fill it;
- inside `Components/Daisy`, attributes pass through from the caller.
**Localization:**
- Keys are English source strings. Grep both resx files for an existing key before adding one; match keys exactly.
- Never add a key that differs from an existing one only by case ("users" next to "Users"): resource lookups collide
on them and one of the two silently shows the other's text. Reuse the existing key.
- Use `Localizer[key, args]`, never `string.Format(Localizer[key], …)`. Don't write `.Value`.
- Enum members shown through `Localizer[value.ToString()]` are keys too: when you add a member, add its key.
- Language names in the language picker stay in their own language.
**Formatting.** Date formats come from `SUtility.dateTimeFormat` / `timeFormat` / `dateTimeFormatExt`. Relative times
and sizes come from `GetPassedTime` / `GetFileSize` (`Extensions/GenericExtensions.cs`). No inline format strings for
display.
**JSON.** Register every `/clientapi` or localStorage type in `ClientJsonContext.cs` (camelCase: `theme.js` reads
`pageSettings` straight from localStorage), every Mastodon API type in `MastodonJsonContext.cs` (snake_case).
**Wiring a feature to PrivaPub:**
1. For `/clientapi`, add the request/response DTOs to `PrivaPub.ClientModels` in the SocialPub repo: `View*` for
responses, `*Request` for requests, `*Form` for forms with validation. Lists `= []`, no string defaults. For the
Mastodon API, add the shape to `Models/Mastodon/`.
2. Register them in `ClientJsonContext` or `MastodonJsonContext`, and the route in `Models/APIs.cs`.
3. Add a client service method that calls `IHttpService` against the server's route, following the patterns above.
4. Wire the page with early returns, toasts and/or an `ErrorViewer`.
**Not adopted from b2b.next, on purpose:**
- **The `tw:` Tailwind prefix.** b2b.next needs it next to MudBlazor's CSS; here daisyUI is the only component CSS.
- **Server-only rules:** no 5XX responses, the controller pattern, `IOptionsMonitor`, data access and caching.
- **b2b.next-specific features:** Excel import/export, password policy, legacy porting rules, the `{culture}` route
placeholder (PrivaPub's routes are absolute `/clientapi/...`), and the release/API-status worker.
- **Mandatory `/// <summary>` on every `[Parameter]`.** Document only what isn't obvious from the name.
## Deployment
Both workflows check the repo out with a plain `git fetch` from this Gitea, not `actions/checkout`, because the runner
would download that action from github.com. Never add a `uses:` step.
`.gitea/workflows/deploy.yml` runs on a `v*` tag or a manual dispatch, on a self-hosted runner that is also the web
host. The steps:
1. Publish the site.
2. Write `build.json` (commit, ref, time).
3. Snapshot the live directory to `/var/backups/decepub.thepra.dev`.
4. `rsync --delete` into `/var/www/decepub.thepra.dev`.
5. Check that `build.json` serves the new commit and that a deep client route returns 200 (the SPA fallback). Otherwise,
roll back to the snapshot.
The nginx vhost and headers are in `deploy/nginx/` (it sends `X-Robots-Tag: noindex, nofollow`), and
`deploy/max/setup.sh` is the one-time root setup on the server. If you add a file that must never be cached, add it to the `expires -1` locations in the vhost.
## Conventions
- Indent with tabs.
- `Nullable` is disabled and `ImplicitUsings` is enabled.
- Global Razor usings are in `_Imports.razor`.
- Styling uses the D components, Tailwind utilities and the `neo-*` utilities. Use `gap-*` rather than `space-x/y-*`
on flex containers; the feeds use `flex-col-reverse`, which `space-*` gets wrong.
- `SUtility.IfTrueThen(cond, "class")` is the helper for conditional classes.
+73
View File
@@ -0,0 +1,73 @@
using System.Text.Json.Serialization;
using decePubClient.Models;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.ClientModels.Group;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar;
namespace decePubClient;
/// <summary>
/// Source-generated JSON metadata for the client's own types, used first by SUtility.DefaultSerializer (HTTP) and by
/// Blazored.LocalStorage (Program.cs). Register every type that crosses HTTP or is stored in localStorage here; types
/// missing from it still work through the reflection fallback. Names stay camelCase: wwwroot/js/theme.js reads
/// PublicCacheData.pageSettings straight from localStorage.
/// </summary>
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
IgnoreReadOnlyProperties = true)]
[JsonSerializable(typeof(PublicCacheData))]
[JsonSerializable(typeof(PageSettings))]
[JsonSerializable(typeof(AuthData))]
[JsonSerializable(typeof(FeedFilter))]
[JsonSerializable(typeof(PublicActionNotices))]
[JsonSerializable(typeof(ServerInfo))]
[JsonSerializable(typeof(List<ServerInfo>))]
[JsonSerializable(typeof(UploadMedia))]
[JsonSerializable(typeof(ClientLogs))]
[JsonSerializable(typeof(ClientLogException))]
[JsonSerializable(typeof(ValidationErrors))]
[JsonSerializable(typeof(WebResult))]
[JsonSerializable(typeof(LoginForm))]
[JsonSerializable(typeof(UserPasswordForm))]
[JsonSerializable(typeof(RemoveSelfForm))]
[JsonSerializable(typeof(JwtUser))]
[JsonSerializable(typeof(ViewAvatar))]
[JsonSerializable(typeof(List<ViewAvatar>))]
[JsonSerializable(typeof(InsertAvatarForm))]
[JsonSerializable(typeof(ViewGroup))]
[JsonSerializable(typeof(List<ViewGroup>))]
[JsonSerializable(typeof(ViewGroupMember))]
[JsonSerializable(typeof(List<ViewGroupMember>))]
[JsonSerializable(typeof(InsertGroupForm))]
[JsonSerializable(typeof(UpdateGroupForm))]
[JsonSerializable(typeof(JoinGroupForm))]
[JsonSerializable(typeof(GroupMembershipForm))]
[JsonSerializable(typeof(ViewPost))]
[JsonSerializable(typeof(List<ViewPost>))]
[JsonSerializable(typeof(InsertPostForm))]
[JsonSerializable(typeof(DeletePostForm))]
[JsonSerializable(typeof(ViewReport))]
[JsonSerializable(typeof(List<ViewReport>))]
[JsonSerializable(typeof(DomainBlockForm))]
[JsonSerializable(typeof(ViewDomainBlock))]
[JsonSerializable(typeof(List<ViewDomainBlock>))]
[JsonSerializable(typeof(ViewStatisticsOverview))]
[JsonSerializable(typeof(ViewHostsPage))]
[JsonSerializable(typeof(ViewCrawler))]
[JsonSerializable(typeof(ViewBackups))]
[JsonSerializable(typeof(ViewBackup))]
[JsonSerializable(typeof(BackupPasswordForm))]
[JsonSerializable(typeof(RestoreBackupForm))]
[JsonSerializable(typeof(ViewBackupTicket))]
[JsonSerializable(typeof(ViewBackupUpload))]
[JsonSerializable(typeof(ViewPersonaArchive))]
[JsonSerializable(typeof(ImportPersonaArchiveForm))]
[JsonSerializable(typeof(ViewArchiveTicket))]
[JsonSerializable(typeof(ViewArchiveUpload))]
public partial class ClientJsonContext : JsonSerializerContext
{
}
-108
View File
@@ -1,108 +0,0 @@
@inherits LocalizableComponentBase
<section class="flex flex-col space-y-4">
@if (isPosting)
{
<div class="block p-3 md:p-4 neomorph rounded-xl is-nxsmall">
<MessageUpsertForm OnMessageSubmit="OnMessageSubmit" />
</div>
}
<div class="flex justify-between space-x-3">
<EditForm Model="Filters">
<div class="field flex flex-row space-x-3">
@if (OnTimelineChanged.HasDelegate)
{
<div class="control has-icons-left">
<span class="icon is-left">
<i class="@Filters.TimelineType.GetTimelineTypeIcon()"></i>
</span>
<span class="select is-rounded neoSelect">
<InputSelect TValue="TimelineType" Value="Filters.TimelineType"
ValueChanged="async v => await OnTimelineChange(v)" ValueExpression="() => Filters.TimelineType">
@foreach (var timelineType in Enum.GetValues<TimelineType>())
{
<option value="@timelineType">@Localizer[timelineType.ToString()]</option>
}
</InputSelect>
</span>
</div>
}
@if (OnTimeSortingChanged.HasDelegate)
{
<div class="control has-icons-left">
<span class="icon is-left">
<i class="ion-md-funnel"></i>
</span>
<span class="select is-rounded neoSelect">
<InputSelect TValue="TimeSortingType" Value="Filters.TimeSortingType"
ValueChanged="async v => await OnTimeSortChange(v)" ValueExpression="() => Filters.TimeSortingType">
@foreach (var timeSortingType in Enum.GetValues<TimeSortingType>())
{
<option value="@timeSortingType">@Localizer[timeSortingType.ToString()]</option>
}
</InputSelect>
</span>
</div>
}
</div>
</EditForm>
@if (OnMessageSubmit.HasDelegate)
{
<div>
<button class="button is-rounded @SUtility.IfTrueThen(isPosting, "neoBtnInsetPlain", "neoBtn")"
@onclick="OpenCloseMessageForm">
<span class="icon is-left">
<i class="ion-md-create"></i>
</span>
<span>@Localizer["Post"]</span>
</button>
</div>
}
else
{
<div></div>
}
</div>
</section>
@code {
[CascadingParameter] CascadingState CascadingState { get; set; }
[Parameter] public EventCallback<MessageForm> OnMessageSubmit { get; set; }
[Parameter] public EventCallback<TimelineType> OnTimelineChanged { get; set; }
[Parameter] public EventCallback<TimeSortingType> OnTimeSortingChanged { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; }
bool isPosting { get; set; } = false;
ActionBarFilter Filters { get; set; } = new();
protected override async Task OnInitializedAsync()
{
var filters = await LocalStorage.GetItemAsync<ActionBarFilter>(nameof(ActionBarFilter));
if (filters == default)
await LocalStorage.SetItemAsync(nameof(ActionBarFilter), Filters);
else
Filters = filters;
}
void OpenCloseMessageForm()
{
isPosting = !isPosting;
}
async Task OnTimelineChange(TimelineType timelineType)
{
Filters.TimelineType = timelineType;
await LocalStorage.SetItemAsync(nameof(ActionBarFilter), Filters);
await OnTimelineChanged.InvokeAsync(Filters.TimelineType);
}
async Task OnTimeSortChange(TimeSortingType timeSorting)
{
Filters.TimeSortingType = timeSorting;
await LocalStorage.SetItemAsync(nameof(ActionBarFilter), Filters);
await OnTimeSortingChanged.InvokeAsync(Filters.TimeSortingType);
}
}
+448
View File
@@ -0,0 +1,448 @@
@inherits LocalizableComponentBase
@implements IAsyncDisposable
@* The server's backups (/clientapi/admin/backups; owner decision 2026-10-07): made nightly, before each deploy and here,
downloaded as one tar through a link good for ten minutes, uploaded again in pieces straight from the browser
(js/transfer.js, never through .NET's memory), and restored: PrivaPub stops, restores the backup as it starts again,
and everyone signs in again. A restore never undoes what protects. *@
<div class="flex flex-col gap-3 w-full" data-backups>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (restarting is not null)
{
<DAlert Color="DAlertColor.Warning" data-backup-restarting>
<span>@Localizer["PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back.", restarting]</span>
</DAlert>
}
else
{
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || backups.Running is not null || busy)" OnClick="BackUp" data-backup-now>
<i class="ion-md-archive"></i> @Localizer["Back up now"]
</DButton>
@if (backups.Running is { } running)
{
<span class="inline-flex items-center gap-2 text-sm" data-backup-running>
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
@(running == "restore" ? Localizer["A restore is running."] : Localizer["A backup is running."])
</span>
}
<span class="ml-auto text-xs opacity-70">@Localizer["{0} free on the server's disk", backups.FreeBytes.GetFileSize(Localizer)]</span>
</div>
@if (backups.Waiting is { } waiting)
{
<DAlert Color="@(waiting.Error is null ? DAlertColor.Warning : DAlertColor.Error)" data-backup-waiting>
<span>
@Localizer["The restore of {0}, asked for by {1}, is {2}.", waiting.Backup, waiting.RequestedBy, Localizer[waiting.State]]
@if (waiting.Error is { Length: > 0 } error)
{
<br /><span class="text-xs">@error</span>
}
</span>
</DAlert>
}
@if (backups.LastRestore is { } last)
{
<div class="flex flex-col gap-1 p-3 rounded-xl neo-inset neo-2xs text-sm" data-last-restore>
@if (last.Abandoned)
{
<p><b>@Localizer["The last restore, of {0}, was abandoned; nothing changed.", last.Backup]</b></p>
<p class="text-xs">@last.Error</p>
}
else
{
<p>
<b>@Localizer["Restored {0}", last.Backup]</b>
<span class="text-xs opacity-70" title="@last.RestoredAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@last.RestoredAt.GetPassedTime(Localizer)</span>
<span class="text-xs opacity-70">@Localizer["asked for by {0}", last.RequestedBy]</span>
</p>
<p class="text-xs">@Localizer["{0} documents in {1} collections; {2} media files brought back, {3} missing.", last.Documents, last.Collections, last.MediaRestored, last.MediaMissing]</p>
<p class="text-xs">@Localizer["Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts.", last.RootsDeleted, last.PersonasDeleted, last.GroupsDeleted, last.PostsDeleted, last.ProtectiveKept]</p>
@if (last.RootsTombstoned.Count + last.PersonasTombstoned.Count + last.GroupsTombstoned.Count > 0)
{
<p class="text-xs" data-last-restore-tombstoned>
@Localizer["Made after the backup, now deleted with their names kept: {0}.", string.Join(", ", last.RootsTombstoned.Concat(last.PersonasTombstoned).Concat(last.GroupsTombstoned))]
</p>
}
<p class="text-xs opacity-70">@Localizer["{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}.", last.PostsGone, last.MediaTrashed, last.SessionsEnded, last.PreRestore]</p>
}
</div>
}
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
<p class="text-sm">@Localizer["Upload a backup downloaded from here (a .tar file):"]</p>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DFileInput Size="DSize.Sm" Accept=".tar,application/x-tar" OnChange="Picked" class="flex-1 min-w-48" disabled="@(IsDefaultDisabled || uploading)"
data-backup-upload />
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || uploading || pickedName is null)" OnClick="Upload" data-backup-upload-start>
<i class="ion-md-cloud-upload"></i> @Localizer["Upload"]
</DButton>
</div>
@if (uploading)
{
<DProgress Value="@uploadedPercent" Max="100" Color="DColor.Primary" data-backup-upload-progress />
<p class="text-xs opacity-70">@Localizer["{0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</p>
}
</div>
@if (backups.Backups.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["No backup yet."]</p>
}
@foreach (var backup in backups.Backups)
{
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-backup="@backup.Id">
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<b class="min-w-0 truncate" title="@backup.Id">@backup.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</b>
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@KindColor(backup.Kind)">@KindName(backup.Kind)</DBadge>
<span class="text-xs opacity-70">@backup.CreatedAt.GetPassedTime(Localizer)</span>
<span class="text-xs">@((backup.Bytes + backup.MediaBytes).GetFileSize(Localizer))</span>
</div>
<p class="text-xs opacity-70">
@Localizer["{0} documents in {1} collections", backup.Documents, backup.Collections];
@(backup.DbOnly
? Localizer["{0} media files listed, kept by the nightly backups", backup.MediaFiles]
: Localizer["{0} media files ({1})", backup.MediaFiles, backup.MediaBytes.GetFileSize(Localizer)])
@if (backup.MediaMissing > 0)
{
<span>; @Localizer["{0} missing", backup.MediaMissing]</span>
}
@if (!backup.Consistent)
{
<span>; @Localizer["not read at one instant"]</span>
}
</p>
@if (backup.RootsDeletedSince > 0)
{
<p class="text-xs">@Localizer["{0} logins were deleted since; a restore deletes them again.", backup.RootsDeletedSince]</p>
}
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="@IsDefaultDisabled" OnClick="() => AskDownload(backup)" data-backup-download>
<i class="ion-md-download"></i> @Localizer["Download"]
</DButton>
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(IsDefaultDisabled || backup.NotRestorable is not null || backups.Running is not null || backups.Waiting is not null)"
OnClick="() => AskRestore(backup)" data-backup-restore>
<i class="ion-md-refresh"></i> @Localizer["Restore"]
</DButton>
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || busy)" OnClick="() => Delete(backup)" data-backup-delete>
<i class="ion-md-trash"></i> @Localizer["Delete"]
</DButton>
</div>
</div>
}
}
</div>
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(downloading is not null)" IsOpenChanged="open => { if (!open) CloseDownload(); }"
CloseText="@Localizer["Close"]" data-backup-download-dialog>
<Heading>@Localizer["Download a backup"]</Heading>
<ChildContent>
@if (ticket is null)
{
<p class="text-sm">@Localizer["It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:"]</p>
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password />
<ErrorViewer AdditionalError="@dialogError" />
}
else
{
<p class="text-sm">@Localizer["The link works for ten minutes, for this backup only."]</p>
<a class="btn btn-primary btn-sm self-start" href="@ticket" data-backup-link>
<i class="ion-md-download"></i> @Localizer["Download {0}", ticketSize.GetFileSize(Localizer)]
</a>
}
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="CloseDownload" data-backup-dialog-close>@Localizer["Close"]</DButton>
@if (ticket is null)
{
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password))" OnClick="Download" data-backup-confirm>
@Localizer["Get the link"]
</DButton>
}
</Actions>
</DModal>
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(restoring is not null)" IsOpenChanged="open => { if (!open) CloseRestore(); }"
CloseText="@Localizer["Close"]" data-backup-restore-dialog>
<Heading>@Localizer["Restore the backup of {0}?", restoring?.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]</Heading>
<ChildContent>
<div class="flex flex-col gap-2 text-sm">
<p>@Localizer["PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again."]</p>
<p>@Localizer["What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names."]</p>
@if (restoring?.RootsDeletedSince > 0)
{
<p>@Localizer["{0} logins were deleted since; a restore deletes them again.", restoring.RootsDeletedSince]</p>
}
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password />
<DInput TValue="string" @bind-Value="typedHost" Immediate="true" autocomplete="off" maxlength="253"
placeholder="@Localizer["Type {0} to confirm", backups.Host ?? ""]" data-backup-host />
<ErrorViewer AdditionalError="@dialogError" />
</div>
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="CloseRestore">@Localizer["Cancel"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Error"
Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password) || !string.Equals(typedHost?.Trim(), backups.Host, StringComparison.OrdinalIgnoreCase))"
OnClick="Restore" data-backup-confirm>
@Localizer["Restore"]
</DButton>
</Actions>
</DModal>
@code {
[Inject] AdminService AdminService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
[Inject] AppConfiguration Configuration { get; set; }
[Inject] IJSRuntime JS { get; set; }
const string UploadInput = "input[data-backup-upload]";
static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3);
ViewBackups backups = new();
bool busy;
string restarting;
ViewBackup downloading;
ViewBackup restoring;
string password;
string typedHost;
string ticket;
long ticketSize;
string dialogError;
string pickedName;
bool uploading;
long uploaded;
long uploadSize;
double uploadedPercent;
IJSObjectReference transfer;
DotNetObjectReference<BackupsPanel> self;
CancellationTokenSource polling;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
backups = await AdminService.Backups() ?? backups;
if (backups.Running is not null || backups.Waiting is not null)
Poll();
}
// while something runs, the list is read again every few seconds; a restore ends the session, and with it this page
void Poll()
{
if (polling is not null)
return;
polling = new();
var token = polling.Token;
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(PollEvery);
while (await timer.WaitForNextTickAsync(token))
{
var read = await AdminService.Backups(token);
if (read is not null)
backups = read;
await InvokeAsync(StateHasChanged);
if (restarting is null && read is not null && read.Running is null && read.Waiting is null)
break;
}
polling = null;
}, token);
}
static DColor KindColor(string kind) => kind switch
{
"nightly" => DColor.Neutral,
"manual" => DColor.Primary,
"pre-deploy" => DColor.Info,
"pre-restore" => DColor.Warning,
"uploaded" => DColor.Accent,
_ => DColor.Neutral
};
string KindName(string kind) => kind switch
{
"nightly" => Localizer["Nightly"],
"manual" => Localizer["Made by hand"],
"pre-deploy" => Localizer["Before a deploy"],
"pre-restore" => Localizer["Before a restore"],
"uploaded" => Localizer["Uploaded"],
_ => kind
};
async Task BackUp()
{
busy = true;
var result = await AdminService.BackUp();
busy = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
backups.Running = "backup";
Toast.AddResponseSuccess(Localizer["The backup has started."]);
Poll();
}
async Task Delete(ViewBackup backup)
{
if (!await Confirm.Ask(Localizer["Delete this backup?"], Localizer["Its files are deleted; the server itself is untouched."], Localizer["Delete"], true))
return;
busy = true;
var result = await AdminService.DeleteBackup(backup.Id);
busy = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
backups.Backups = [.. backups.Backups.Where(b => b.Id != backup.Id)];
}
void AskDownload(ViewBackup backup)
{
(password, ticket, dialogError) = (null, null, null);
downloading = backup;
}
void CloseDownload() => (downloading, password, ticket, dialogError) = (null, null, null, null);
async Task Download()
{
busy = true;
var result = await AdminService.BackupTicket(downloading.Id, password);
busy = false;
if (result.IsInvalid)
{
dialogError = result.ErrorMessage;
return;
}
var granted = (ViewBackupTicket)result.Data;
ticket = new Uri(ApiBase, granted.Url).AbsoluteUri;
ticketSize = granted.Bytes;
password = null;
dialogError = null;
}
void AskRestore(ViewBackup backup)
{
(password, typedHost, dialogError) = (null, null, null);
restoring = backup;
}
void CloseRestore() => (restoring, password, typedHost, dialogError) = (null, null, null, null);
async Task Restore()
{
busy = true;
var asked = restoring;
var result = await AdminService.RestoreBackup(asked.Id, new RestoreBackupForm { Password = password, Host = typedHost?.Trim() });
busy = false;
if (result.IsInvalid)
{
dialogError = result.ErrorMessage;
return;
}
CloseRestore();
restarting = asked.Id;
Poll();
}
Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress);
void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null;
async Task Upload()
{
transfer ??= await JS.InvokeAsync<IJSObjectReference>("import", "./js/transfer.js");
self ??= DotNetObjectReference.Create(this);
var started = await AdminService.StartBackupUpload();
if (started.IsInvalid)
{
Toast.AddResponseError(started);
return;
}
var upload = (ViewBackupUpload)started.Data;
(uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0);
var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_AdminBackupUpload(upload.Id)).AbsoluteUri;
var sent = await transfer.InvokeAsync<UploadOutcome>("upload", UploadInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self);
if (!sent.Ok)
{
uploading = false;
await AdminService.CancelBackupUpload(upload.Id);
Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]);
return;
}
var finished = await AdminService.FinishBackupUpload(upload.Id);
uploading = false;
if (finished.IsInvalid)
{
Toast.AddResponseError(finished);
return;
}
var backup = (ViewBackup)finished.Data;
backups.Backups = [.. backups.Backups.Append(backup).OrderByDescending(b => b.CreatedAt)];
pickedName = null;
Toast.AddResponseSuccess(Localizer["The backup {0} was uploaded.", backup.Id]);
}
[JSInvokable]
public Task Progress(long received, long total)
{
(uploaded, uploadSize) = (received, total);
uploadedPercent = total == 0 ? 100 : 100.0 * received / total;
return InvokeAsync(StateHasChanged);
}
sealed class UploadOutcome
{
public bool Ok { get; set; }
public int Status { get; set; }
public string Error { get; set; }
}
public async ValueTask DisposeAsync()
{
polling?.Cancel();
self?.Dispose();
if (transfer is null)
return;
try
{
await transfer.DisposeAsync();
}
catch (JSDisconnectedException)
{
}
}
}
+138
View File
@@ -0,0 +1,138 @@
@inherits LocalizableComponentBase
@* The reports moderators handle (/clientapi/moderator/reports): from someone here (never said who) or from another
server, about an account and some of its posts; resolved here. *@
<div class="flex flex-col gap-3 w-full" data-reports>
<DToggle LabelText="@Localizer["Show the resolved ones"]" Value="resolved" ValueChanged="ShowResolved" ValueExpression="() => resolved" />
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (reports.Count == 0)
{
<p class="text-sm opacity-70">@(resolved ? Localizer["No resolved report."] : Localizer["No open report."])</p>
}
@foreach (var report in reports)
{
<article class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-report-id="@report.ID">
<div class="flex flex-wrap items-center gap-2">
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@CategoryName(report.Category)</DBadge>
@if (report.Forwarded)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Forwarded"]</DBadge>
}
<span class="text-xs opacity-70" title="@report.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)">
@report.CreatedAt.GetPassedTime(Localizer)
</span>
<span class="flex-1"></span>
@if (!report.IsResolved)
{
<DButton Size="DSize.Sm" Color="DColor.Success" Disabled="@(IsDefaultDisabled || busy.Contains(report.ID))" OnClick="() => Resolve(report)"
data-report-resolve>
<i class="ion-md-checkmark"></i><span>@Localizer["Resolve"]</span>
</DButton>
}
else if (report.ResolvedAt is { } resolvedAt)
{
<span class="text-xs opacity-70">@Localizer["Resolved {0}", resolvedAt.GetPassedTime(Localizer)]</span>
}
</div>
<p class="text-sm">
<b>@Localizer["About"]</b>
@if (report.TargetAccountId is { Length: > 0 } targetId)
{
<a class="link link-hover break-all" href="@($"account/{Uri.EscapeDataString(targetId)}")">@report.TargetActorURI</a>
}
else
{
<span class="break-all">@report.TargetActorURI</span>
}
</p>
<p class="text-sm">
<b>@Localizer["From"]</b>
<span class="break-all">@(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)</span>
</p>
@if (report.Comment is { Length: > 0 } comment)
{
<p class="text-sm whitespace-pre-line rounded-lg neo-inset neo-2xs p-2">@comment</p>
}
@if (report.PostIds.Count > 0 || report.ObjectURIs.Count > 0)
{
<div class="flex flex-wrap gap-2 text-xs">
@foreach (var postId in report.PostIds)
{
<a class="link" href="@($"expand/{Uri.EscapeDataString(postId)}")">@Localizer["Post {0}", postId]</a>
}
@* a report from another server names posts by their ids there when we hold none of them *@
@if (report.PostIds.Count == 0)
{
@foreach (var uri in report.ObjectURIs)
{
<span class="break-all opacity-70">@uri</span>
}
}
</div>
}
</article>
}
</div>
@code {
[Inject] AdminService AdminService { get; set; }
List<ViewReport> reports = [];
bool resolved;
HashSet<string> busy = [];
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => reports = await AdminService.Reports(resolved);
async Task ShowResolved(bool value)
{
resolved = value;
IsLoading = true;
await LoadData();
IsLoading = false;
}
string CategoryName(string category) => category switch
{
"spam" => Localizer["Spam"],
"legal" => Localizer["Illegal"],
"violation" => Localizer["Breaks the rules"],
_ => Localizer["Other"]
};
async Task Resolve(ViewReport report)
{
busy.Add(report.ID);
var result = await AdminService.Resolve(report.ID);
busy.Remove(report.ID);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
reports.Remove(report);
Toast.AddResponseSuccess(Localizer["Report resolved."]);
}
}
+122
View File
@@ -0,0 +1,122 @@
@inherits LocalizableComponentBase
@* Servers blocked for everyone here (/clientapi/admin/domainblocks): suspended (nothing from or to them) or silenced
(kept out of the public feeds), their media refused or not, with a comment for the public and one for the admins. *@
<div class="flex flex-col gap-3 w-full" data-server-blocks>
<EditForm Model="form" OnValidSubmit="Block" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
<DataAnnotationsValidator />
<div class="flex flex-wrap gap-2 items-center">
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="form.Domain" Immediate="true" autocomplete="off" maxlength="253"
placeholder="@Localizer["A server, e.g. example.social"]" data-server-block-domain>
<Prefix><i class="ion-md-globe"></i></Prefix>
</DInput>
<DToggle LabelText="@Localizer["Suspend (otherwise silence)"]" @bind-Value="form.Suspend" />
<DToggle LabelText="@Localizer["Refuse its media"]" @bind-Value="form.RejectMedia" />
</div>
<DInput TValue="string" @bind-Value="form.PublicComment" Immediate="true" autocomplete="off" maxlength="1000"
placeholder="@Localizer["Why, for everyone to read"]" />
<DInput TValue="string" @bind-Value="form.PrivateComment" Immediate="true" autocomplete="off" maxlength="1000"
placeholder="@Localizer["Why, for the admins only"]" />
<ErrorViewer />
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" class="self-end"
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(form.Domain))">
@Localizer["Block server"]
</DButton>
</EditForm>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (blocks.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["No server is blocked."]</p>
}
@foreach (var block in blocks)
{
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-server-block="@block.Domain">
<div class="flex flex-wrap items-center gap-2">
<b class="flex-1 min-w-0 truncate">@block.Domain</b>
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@(block.Severity == "Suspend" ? DColor.Error : DColor.Warning)">
@(block.Severity == "Suspend" ? Localizer["Suspended"] : Localizer["Silenced"])
</DBadge>
@if (block.RejectMedia)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Media refused"]</DBadge>
}
<span class="text-xs opacity-70" title="@block.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)">@block.CreatedAt.GetPassedTime(Localizer)</span>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy.Contains(block.Domain))" OnClick="() => Unblock(block)">
@Localizer["Unblock"]
</DButton>
</div>
@if (block.PublicComment is { Length: > 0 } publicComment)
{
<p class="text-xs">@publicComment</p>
}
@if (block.PrivateComment is { Length: > 0 } privateComment)
{
<p class="text-xs opacity-70"><i class="ion-md-lock"></i> @privateComment</p>
}
</div>
}
</div>
@code {
[Inject] AdminService AdminService { get; set; }
List<ViewDomainBlock> blocks = [];
DomainBlockForm form = new();
bool isSaving;
HashSet<string> busy = [];
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => blocks = await AdminService.ServerBlocks();
async Task Block()
{
isSaving = true;
var result = await AdminService.BlockServer(form);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
var block = (ViewDomainBlock)result.Data;
blocks = [.. blocks.Where(b => b.Domain != block.Domain).Append(block).OrderBy(b => b.Domain, StringComparer.Ordinal)];
form = new();
Toast.AddResponseSuccess(Localizer["{0} is blocked.", block.Domain]);
}
async Task Unblock(ViewDomainBlock block)
{
busy.Add(block.Domain);
var result = await AdminService.UnblockServer(block.Domain);
busy.Remove(block.Domain);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
blocks.Remove(block);
}
}
+209
View File
@@ -0,0 +1,209 @@
@inherits LocalizableComponentBase
@* PrivaPub's federation statistics (/clientapi/admin/statistics): the last days at a glance, the servers exchanged with
(by traffic, failures, latency or name, 50 a page) and how far the crawler got. Counted per server, never per account. *@
<div class="flex flex-col gap-4 w-full" data-statistics>
<div class="flex flex-wrap gap-2 items-end">
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Period"]</span>
<DSelect TValue="int" Size="DSize.Sm" Value="days" ValueChanged="ChangeDays" ValueExpression="() => days">
@foreach (var period in Periods)
{
<option value="@period">@Localizer["{0} days", period]</option>
}
</DSelect>
</label>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Servers by"]</span>
<DSelect TValue="string" Size="DSize.Sm" Value="sort" ValueChanged="ChangeSort" ValueExpression="() => sort">
<option value="traffic">@Localizer["Traffic"]</option>
<option value="failures">@Localizer["Failures"]</option>
<option value="latency">@Localizer["Latency"]</option>
<option value="host">@Localizer["Name"]</option>
</DSelect>
</label>
</div>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (overview is null)
{
<p class="text-sm opacity-70">@Localizer["The statistics could not be read."]</p>
}
else
{
<DStats class="w-full overflow-x-auto neo-raised neo-xs" data-statistics-overview data-scroll-x>
<DStat>
<Heading>@Localizer["Deliveries that arrived"]</Heading>
<ChildContent>@(overview.DeliverySuccess is { } success ? $"{success * 100:0.#}%" : "—")</ChildContent>
<Desc>@Localizer["p50 {0} ms, p95 {1} ms", Ms(overview.DeliveryLatencyMs.P50), Ms(overview.DeliveryLatencyMs.P95)]</Desc>
</DStat>
<DStat>
<Heading><span class="capitalize">@Localizer["servers"]</span></Heading>
<ChildContent>@overview.Hosts.Active</ChildContent>
<Desc>@Localizer["{0} known", overview.Hosts.Known]</Desc>
</DStat>
<DStat>
<Heading>@Localizer["Accounts seen"]</Heading>
<ChildContent>@overview.Accounts</ChildContent>
<Desc>@Localizer["ledger {0} written, {1} dropped", overview.Ledger.Written, overview.Ledger.Dropped]</Desc>
</DStat>
</DStats>
<div class="flex flex-wrap gap-4 text-xs">
@foreach (var (title, counts) in Outcomes)
{
<div class="flex flex-col gap-1 min-w-40">
<b class="text-sm">@title</b>
@foreach (var (outcome, count) in counts.OrderByDescending(c => c.Value))
{
<span class="flex justify-between gap-4 tabular-nums"><span>@outcome</span><span>@count</span></span>
}
</div>
}
</div>
@if (hosts is not null)
{
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-statistics-hosts>
<thead>
<tr>
<th>@Localizer["server"]</th>
<th>@Localizer["software"]</th>
<th class="text-right">@Localizer["In"]</th>
<th class="text-right">@Localizer["Out"]</th>
<th class="text-right">@Localizer["Refused"]</th>
<th class="text-right">@Localizer["Failures"]</th>
<th class="text-right">p50</th>
<th>@Localizer["Delivery"]</th>
</tr>
</thead>
<tbody>
@foreach (var host in hosts.Hosts)
{
<tr data-statistics-host="@host.Host">
<td class="max-w-48 truncate">@host.Host</td>
<td>@host.Software @host.Version</td>
<td class="text-right tabular-nums">@host.In</td>
<td class="text-right tabular-nums">@host.Out</td>
<td class="text-right tabular-nums">@host.Refused</td>
<td class="text-right tabular-nums">@host.Failures</td>
<td class="text-right tabular-nums">@Ms(host.LatencyP50)</td>
<td>
@if (host.Delivery?.UnavailableUntil is { } until && until > DateTime.UtcNow)
{
<DBadge Size="DSize.Xs" Color="DColor.Error" title="@until.ToLocalTime().ToString(SUtility.dateTimeFormat)">@Localizer["paused"]</DBadge>
}
else if (host.Delivery?.ConsecutiveFailures > 0)
{
<DBadge Size="DSize.Xs" Color="DColor.Warning">@Localizer["{0} failing", host.Delivery.ConsecutiveFailures]</DBadge>
}
</td>
</tr>
}
</tbody>
</DTable>
@if (hosts.Total > PageSize)
{
<DPagination Size="DSize.Xs" Count="@((hosts.Total + PageSize - 1) / PageSize)" Current="page" CurrentChanged="ChangePage"
PreviousAriaLabel="@Localizer["Previous page"]" NextAriaLabel="@Localizer["Next page"]" />
}
<p class="text-xs opacity-70">@hosts.Attribution</p>
}
@if (crawler is not null)
{
<div class="flex flex-col gap-1 text-xs" data-statistics-crawler>
<b class="text-sm">@Localizer["Crawler"]</b>
<span>
@(crawler.Enabled ? Localizer["on"] : Localizer["off"]) ·
@Localizer["{0} servers touched, {1} only crawled, {2} due, {3} refuse robots", crawler.Touched, crawler.Crawled, crawler.Due, crawler.RobotsDisallowed]
</span>
@foreach (var host in crawler.Recent)
{
<span class="flex flex-wrap gap-2">
<span class="truncate max-w-48">@host.Host</span>
<span class="opacity-70">@host.Software</span>
@if (host.LastCrawledAt is { } crawled)
{
<span class="opacity-70">@crawled.GetPassedTime(Localizer)</span>
}
@if (host.CrawlError is { Length: > 0 } crawlError)
{
<span class="text-error">@crawlError</span>
}
</span>
}
</div>
}
}
</div>
@code {
[Inject] AdminService AdminService { get; set; }
const int PageSize = 50;
static readonly int[] Periods = [1, 7, 30, 90];
ViewStatisticsOverview overview;
ViewHostsPage hosts;
ViewCrawler crawler;
int days = 30;
string sort = "traffic";
int page = 1;
(string Title, Dictionary<string, long> Counts)[] Outcomes =>
[
(Localizer["Received"], overview.Inbound),
(Localizer["Delivered"], overview.Outbound),
(Localizer["Channels"], overview.Channels)
];
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
overview = await AdminService.Overview(days);
hosts = await AdminService.Hosts(days, sort, page);
crawler = await AdminService.Crawler();
}
static string Ms(int? value) => value?.ToString() ?? "—";
async Task ChangeDays(int value)
{
days = value;
page = 1;
await LoadData();
}
async Task ChangeSort(string value)
{
sort = value;
page = 1;
hosts = await AdminService.Hosts(days, sort, page);
}
async Task ChangePage(int value)
{
page = value;
hosts = await AdminService.Hosts(days, sort, page);
}
}
+709
View File
@@ -0,0 +1,709 @@
@using System.Text.RegularExpressions
@inherits LocalizableComponentBase
@* A new post or a reply, as the current persona. Through the Mastodon API it is plain text with a content warning, a
visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of
this device's position and never federated; a group post goes to the group. Both of those go through /clientapi.
A Mastodon API post can wait for a time instead (scheduled_at, at least five minutes ahead); /scheduled lists those.
It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words,
warning, media and their descriptions change; its visibility and poll stay). A file is uploaded as soon as it is
picked, streamed from the browser's file, and shows its state (uploading, processing, ready, failed with Retry); its
id is kept, so a post that fails never uploads it again. What the server takes (types, sizes) comes from it. *@
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
<DataAnnotationsValidator />
@if (form.HasContentWarning)
{
<DInput TValue="string" class="w-full rounded-t-[1.4rem] rounded-b-lg" maxlength="500" autocomplete="off"
placeholder="@Localizer["Content warning"]" @bind-Value="form.SpoilerText">
<Prefix><i class="ion-md-warning text-warning"></i></Prefix>
</DInput>
}
<div class="flex flex-col gap-1">
<DTextarea @ref="textarea" TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
placeholder="@Placeholder" rows="3" data-compose />
@* under the words, not over their last line *@
<span class="self-end text-xs opacity-50 tabular-nums">@ContentLength/@ComposeForm.MaxContentLength</span>
</div>
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
{
<div class="flex flex-wrap gap-2" data-suggestions>
@foreach (var account in suggestedAccounts)
{
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
@* alt="": the handle is next to it. *@
<img alt="" class="h-4 w-4 flex-none rounded-full object-cover" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="truncate">@("@" + account.Acct)</span>
</DButton>
}
@foreach (var tag in suggestedTags)
{
<DButton Size="DSize.Xs" KeepFocus="true" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
}
</div>
}
@if (Quoting is not null)
{
<Content Post="FeedPost.Of(Quoting)" Compact="true" CssContainer="opacity-90" />
}
@if (form.HasPoll)
{
<div class="flex flex-col gap-2 rounded-xl p-3 neo-inset neo-2xs" data-poll-editor>
@for (var i = 0; i < form.PollOptions.Count; i++)
{
var index = i;
<div class="flex items-center gap-2">
<input type="text" class="input input-sm flex-1" maxlength="100" autocomplete="off" placeholder="@Localizer["Choice {0}", index + 1]"
value="@form.PollOptions[index]" @onchange="@((ChangeEventArgs e) => form.PollOptions[index] = e.Value?.ToString())" />
@if (form.PollOptions.Count > 2)
{
<DButton Size="DSize.Xs" OnClick="() => form.PollOptions.RemoveAt(index)" title="@Localizer["Remove"]"><i class="ion-md-close"></i></DButton>
}
</div>
}
<div class="flex flex-wrap items-center gap-2">
@if (form.PollOptions.Count < 4)
{
<DButton Size="DSize.Xs" OnClick="() => form.PollOptions.Add(string.Empty)"><i class="ion-md-add"></i><span>@Localizer["Add a choice"]</span></DButton>
}
<DSelect TValue="int" Size="DSize.Xs" class="w-auto" @bind-Value="form.PollExpiresIn" aria-label="@Localizer["Poll length"]">
@foreach (var (seconds, label) in PollLengths)
{
<option value="@seconds">@label</option>
}
</DSelect>
<label class="label text-xs">
<input type="checkbox" class="checkbox checkbox-xs" checked="@form.PollMultiple" @onchange="@((ChangeEventArgs e) => form.PollMultiple = e.Value is true)" />
<span>@Localizer["Several choices"]</span>
</label>
</div>
</div>
}
@if (form.Media.Count > 0)
{
<div class="flex flex-col gap-2" data-compose-media>
@foreach (var media in form.Media)
{
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs" data-upload-state="@media.State.ToString().ToLowerInvariant()">
@if (media.Preview is { Length: > 0 } preview)
{
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
}
else
{
<i class="text-2xl @(media.ContentType?.StartsWith("video/") == true ? "ion-md-videocam" : media.ContentType?.StartsWith("audio/") == true ? "ion-md-musical-notes" : "ion-md-image")"></i>
}
<div class="flex flex-col flex-1 min-w-0 gap-1">
<p class="text-xs break-all"><b>@media.FileName</b>@(media.Size > 0 ? $" · {media.Size.GetFileSize(Localizer)}" : null)</p>
<p class="inline-flex items-center gap-2 text-xs">
@switch (media.State)
{
case UploadState.Uploading:
<DLoading Size="DSize.Xs" LabelText="@Localizer["Uploading"]" /><span>@Localizer["Uploading"]</span>
break;
case UploadState.Processing:
<DLoading Size="DSize.Xs" LabelText="@Localizer["Processing"]" /><span>@Localizer["Processing"]</span>
break;
case UploadState.Failed:
<i class="ion-md-alert text-error"></i><span class="text-error break-words">@media.Error</span>
@if (media.File is not null)
{
<DButton Size="DSize.Xs" OnClick="() => Retry(media)" data-upload-retry>@Localizer["Retry"]</DButton>
}
break;
default:
<i class="ion-md-checkmark text-success"></i><span>@Localizer["Ready"]</span>
break;
}
</p>
<DTextarea TValue="string" @bind-Value="media.AltText" @bind-Value:after="() => altTextConfirmed = false" Size="DSize.Sm"
class="@(AskingAltText && Undescribed(media) ? "w-full rounded-lg! textarea-warning" : "w-full rounded-lg!")" autocomplete="off" maxlength="1500"
placeholder="@Localizer["Alternative text"]" rows="1" data-alt-text />
</div>
<DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@Localizer["Delete"]">
<i class="ion-md-trash text-base text-error"></i>
</DButton>
</div>
}
</div>
@if (AskingAltText)
{
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" class="text-sm" data-alt-reminder>
<span>@Localizer["{0} of the pictures have no description, which people who can't see them rely on.", form.Media.Count(Undescribed)]</span>
<div class="flex flex-wrap gap-2">
<DButton Size="DSize.Sm" OnClick="() => askingAltText = false">@Localizer["Describe them"]</DButton>
<DButton Size="DSize.Sm" OnClick="PublishAnyway" data-alt-publish-anyway>@Localizer["Publish anyway"]</DButton>
</div>
</DAlert>
}
}
@if (form.IsLocated)
{
<div class="flex flex-col gap-1 rounded-xl p-3 neo-inset neo-2xs text-xs">
<span class="inline-flex gap-2 items-center">
<i class="ion-md-locate text-primary"></i>
@if (form.Latitude is null)
{
<span>@Localizer["Asking this device for its position…"]</span>
}
else
{
<span>@Localizer["Shown only to people within {0} km of here, never sent to other servers.", form.RangeKm.ToString("0")]</span>
}
</span>
<DRange TValue="double" Size="DSize.Xs" Min="1" Max="50" Step="1" @bind-Value="form.RangeKm" />
</div>
}
<ErrorViewer AdditionalError="@error" AdditionalErrors="fileErrors" />
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
@if (!form.IsLocated && GroupId is null && Editing is null)
{
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" SheetOnNarrow="true"
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]"
aria-label="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
<Trigger>
<i class="@form.MessageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
</Trigger>
<ChildContent>
@foreach (var messageType in Enum.GetValues<MessageType>())
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)">
<i class="@messageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
<span>@Localizer[messageType.ToString()]</span>
</DButton>
}
</ChildContent>
</DDropdown>
}
@if (!form.IsLocated && GroupId is null && Editing is null)
{
@* the language the post is written in, as each language names itself *@
<DSelect TValue="string" Size="DSize.Sm" class="w-auto" @bind-Value="form.Language" title="@Localizer["Language"]" data-compose-language>
@foreach (var (languageCode, languageName) in Languages.All)
{
<option value="@languageCode">@languageName</option>
}
</DSelect>
}
<DButton Size="DSize.Sm" Active="form.HasContentWarning" OnClick="() => form.HasContentWarning = !form.HasContentWarning" title="@Localizer["Content warning"]">
<i class="ion-md-warning text-base"></i>
</DButton>
@if (!form.IsLocated && GroupId is null && !form.HasPoll)
{
@* A label styled as a button opens the hidden file input. *@
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
<InputFile accept="@AcceptedFileTypes" class="hidden" multiple="@(form.MediaType is MediaType.Images)" OnChange="OnFileChange"
disabled="@(form.Media.Count >= MaximumFileCount)" data-attach />
<i class="ion-md-attach text-base"></i>
@if (form.Media.Count != 0)
{
<span>+@form.Media.Count</span>
}
</label>
<DSelect TValue="MediaType" Size="DSize.Sm" class="w-auto" Value="form.MediaType" ValueChanged="v => form.MediaType = v"
ValueExpression="() => form.MediaType" disabled="@(form.Media.Count > 0)">
<Prefix><i class="@form.MediaType.GetMediaTypeIcon()"></i></Prefix>
<ChildContent>
@foreach (var mediaType in new[] { MediaType.Images, MediaType.Video, MediaType.Audio })
{
<option value="@mediaType">@Localizer[mediaType.ToString()]</option>
}
</ChildContent>
</DSelect>
}
@if (!form.IsLocated && GroupId is null && Editing is null && form.Media.Count == 0)
{
<DButton Size="DSize.Sm" Active="form.HasPoll" OnClick="() => form.HasPoll = !form.HasPoll" title="@Localizer["Poll"]" data-poll-toggle>
<i class="ion-md-stats text-base"></i>
</DButton>
}
@if (InReplyTo is null && GroupId is null && Editing is null && Quoting is null)
{
<DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]">
<i class="ion-md-locate text-base"></i>
</DButton>
}
@if (!form.IsLocated && GroupId is null && Editing is null)
{
<DButton Size="DSize.Sm" Active="form.IsScheduled" OnClick="ToggleScheduled" title="@Localizer["Publish later"]" data-schedule>
<i class="ion-md-time text-base"></i>
</DButton>
}
</div>
<DButton Size="DSize.Sm" Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isPublishing || (form.IsLocated && form.Latitude is null)">
@if (isPublishing)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@SubmitLabel</span>
<i class="@(form.IsScheduled ? "ion-md-time" : "ion-md-send")"></i>
</DButton>
</div>
@if (form.IsScheduled && !form.IsLocated && GroupId is null)
{
<div class="flex flex-wrap items-center gap-2 text-sm">
<label class="flex flex-wrap items-center gap-2">
<span>@Localizer["Publish it on"]</span>
<DInput TValue="string" type="datetime-local" Size="DSize.Sm" class="w-auto" @bind-Value="form.ScheduledAt" autocomplete="off"
min="@DateTime.Now.AddMinutes(6).ToString("yyyy-MM-ddTHH:mm")" data-schedule-at />
</label>
<a class="link text-xs" href="scheduled">@Localizer["Scheduled posts"]</a>
</div>
}
</EditForm>
@code {
[Inject] StatusService StatusService { get; set; }
[Inject] MediaService MediaService { get; set; }
[Inject] AccountService AccountService { get; set; }
[Inject] ILoggingService Logger { get; set; }
[Inject] IJSRuntime JS { get; set; }
/// <summary>The status this answers; the reply starts with its visibility (never wider) and content warning.</summary>
[Parameter] public Status InReplyTo { get; set; }
/// <summary>The community or circle the post is for.</summary>
[Parameter] public string GroupId { get; set; }
/// <summary>The post this one quotes (shown under the words).</summary>
[Parameter] public Status Quoting { get; set; }
/// <summary>The persona's own post being edited: its words come from /source, and saving replaces them.</summary>
[Parameter] public Status Editing { get; set; }
/// <summary>The published post, to show it; a located or group post comes back as null (it is not on the Mastodon API).</summary>
[Parameter] public EventCallback<FeedPost> OnPublished { get; set; }
ComposeForm form = new();
List<string> fileErrors = [];
// what the words end with while a mention or a hashtag is being written: its sign and what follows it
static readonly Regex Typing = new(@"(?:^|\s)([@#])([\p{L}\p{N}_][\p{L}\p{N}_.\-]*(?:@[\p{L}\p{N}.\-]*)?)$", RegexOptions.Compiled);
List<Account> suggestedAccounts = [];
List<StatusTag> suggestedTags = [];
// the newest lookup: an older one's answer comes too late to show
int suggesting;
string error;
bool isPublishing;
int ContentLength => form.Content?.Length ?? 0;
// what is being written, once typing pauses, among the accounts and hashtags this server knows
async Task Typed()
{
var typed = Typing.Match(form.Content ?? "");
var lookup = ++suggesting;
if (!typed.Success || typed.Groups[2].Length < 2)
{
suggestedAccounts = [];
suggestedTags = [];
return;
}
await Task.Delay(300);
if (lookup != suggesting)
return;
var hashtags = typed.Groups[1].Value == "#";
var found = await AccountService.Suggest(typed.Groups[2].Value, hashtags);
if (lookup != suggesting)
return;
suggestedAccounts = hashtags ? [] : [.. found.Accounts.Take(5)];
suggestedTags = hashtags ? [.. found.Hashtags.Take(5)] : [];
}
// the mention or hashtag being written, written out
void Complete(string written)
{
var typed = Typing.Match(form.Content ?? "");
suggesting++;
suggestedAccounts = [];
suggestedTags = [];
if (!typed.Success)
return;
form.Content = form.Content[..typed.Groups[1].Index] + written + " ";
focusPending = true;
}
string Placeholder => Editing is not null ? Localizer["Edit your post"]
: Quoting is not null ? Localizer["Say something about it"]
: InReplyTo is null ? Localizer["What's happening?"]
: Localizer["Reply to {0}", "@" + InReplyTo.Account?.Acct];
string SubmitLabel => Editing is not null ? Localizer["Save"]
: form.IsScheduled ? Localizer["Schedule"]
: Quoting is not null ? Localizer["Quote"]
: Localizer[InReplyTo is null ? "Post" : "Reply"];
(int Seconds, string Label)[] PollLengths =>
[
(300, Localizer["5 minutes"]), (1800, Localizer["30 minutes"]), (3600, Localizer["1 hour"]), (21600, Localizer["6 hours"]),
(86400, Localizer["1 day"]), (259200, Localizer["3 days"]), (604800, Localizer["7 days"])
];
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
// what the server takes: its types and sizes (/api/v2/instance), its defaults until they are read
MediaConfiguration limits = new();
// before publishing pictures without a description, asked once (the reader's setting); "Publish anyway" goes on
bool askingAltText;
bool altTextConfirmed;
bool AskingAltText => askingAltText && form.Media.Any(Undescribed);
static bool Undescribed(UploadMedia media) => media.ContentType?.StartsWith("image/") == true && string.IsNullOrWhiteSpace(media.AltText);
long MaximumFileSize(string type) => type.StartsWith("image/") ? limits.ImageSizeLimit : limits.VideoSizeLimit;
bool OfKind(string type) => form.MediaType switch
{
MediaType.Images => type.StartsWith("image/"),
MediaType.Video => type.StartsWith("video/"),
_ => type.StartsWith("audio/")
};
// the server's types of the kind chosen, and the extensions a file picker may know them by
string AcceptedFileTypes => string.Join(",", limits.SupportedMimeTypes.Where(OfKind).Concat(Extensions.Where(e => OfKind(e.Value)).Select(e => e.Key)));
static readonly Dictionary<string, string> Extensions = new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
[".avif"] = "image/avif", [".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".mov"] = "video/quicktime", [".webm"] = "video/webm",
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/ogg", [".wav"] = "audio/wav",
[".flac"] = "audio/flac", [".m4a"] = "audio/mp4"
};
// what a file is: the browser's word, else its extension's
static string TypeOf(IBrowserFile file) => file.ContentType is { Length: > 0 } type ? type.ToLowerInvariant()
: Extensions.GetValueOrDefault(Path.GetExtension(file.Name) ?? string.Empty);
// a new post starts in the language the persona chose in Settings, else in the reader's
string DefaultLanguage => CascadingState.Persona?.Source?.Language is { Length: > 0 } chosen ? chosen
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
// a new post starts with the visibility the persona chose in Settings
MessageType DefaultVisibility => CascadingState.Persona?.Source?.Privacy switch
{
"private" => MessageType.FollowersOnly,
"unlisted" => MessageType.Unlisted,
_ => MessageType.Public
};
DTextarea<string> textarea;
// the words take the focus when the composer opens, and again after a suggestion is picked
bool focusPending = true;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!focusPending)
return;
focusPending = false;
await textarea.Element.FocusAsync();
}
protected override async Task OnInitializedAsync()
{
form.GroupId = GroupId;
form.MessageType = DefaultVisibility;
form.Language = DefaultLanguage;
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
form.QuotedStatusId = Quoting?.Id;
limits = await MediaService.Limits();
if (Editing is not null)
{
var source = await StatusService.Source(Editing.Id);
form.Content = source?.Text;
form.HasContentWarning = source?.SpoilerText is { Length: > 0 };
form.SpoilerText = source?.SpoilerText;
// the post as it is: its language, its sensitive flag and its media, which the edit sends back
form.Language = Editing.Language ?? form.Language;
form.Sensitive = Editing.Sensitive;
form.Media = [.. Editing.MediaAttachments.Select(m => new UploadMedia
{
Id = m.Id,
State = UploadState.Ready,
Posted = true,
AltText = m.Description,
SentAltText = m.Description,
Preview = m.Type is "image" or "gifv" or "video" ? m.PreviewUrl : null,
ContentType = m.Type switch { "image" => "image/jpeg", "video" or "gifv" => "video/mp4", "audio" => "audio/mpeg", _ => null },
FileName = Path.GetFileName(m.Url ?? string.Empty)
})];
if (form.Media.FirstOrDefault()?.ContentType is { } kind)
form.MediaType = kind.StartsWith("video/") ? MediaType.Video : kind.StartsWith("audio/") ? MediaType.Audio : MediaType.Images;
}
IsLoading = false;
if (InReplyTo is null)
return;
form.InReplyToId = InReplyTo.Id;
form.MessageType = InReplyTo.Visibility switch
{
"direct" => MessageType.Direct,
"private" => MessageType.FollowersOnly,
"unlisted" => MessageType.Unlisted,
_ => MessageType.Public
};
// a reply is written in its thread's language, as a rule
form.Language = InReplyTo.Language is { Length: 2 } language && Languages.All.Any(l => l.Code == language) ? language : form.Language;
form.HasContentWarning = InReplyTo.SpoilerText is { Length: > 0 };
form.SpoilerText = InReplyTo.SpoilerText;
var mentions = InReplyTo.Mentions.Select(m => m.Acct).Prepend(InReplyTo.Account?.Acct)
.Where(acct => acct is not null && acct != CascadingState.Persona?.Acct).Distinct().Select(acct => "@" + acct);
form.Content = string.Join(' ', mentions) is { Length: > 0 } prefix ? prefix + " " : null;
}
void SetMessageType(MessageType messageType)
{
form.MessageType = messageType;
form.IsScopeOptionsOpen = false;
}
// a scheduled post starts an hour ahead, which the reader moves as it likes
void ToggleScheduled()
{
form.IsScheduled = !form.IsScheduled;
if (form.IsScheduled && string.IsNullOrEmpty(form.ScheduledAt))
form.ScheduledAt = DateTime.Now.AddHours(1).ToString("yyyy-MM-ddTHH:mm");
}
async Task ToggleLocated()
{
form.IsLocated = !form.IsLocated;
if (!form.IsLocated || form.Latitude is not null)
return;
var position = await JS.InvokeAsync<GeoPosition>("feedPosition");
if (position?.Error is { Length: > 0 } positionError)
{
form.IsLocated = false;
error = Localizer["This device did not give its position: {0}", positionError];
return;
}
form.Latitude = position?.Latitude;
form.Longitude = position?.Longitude;
}
async Task OnFileChange(InputFileChangeEventArgs eventArgs)
{
fileErrors = [];
if (eventArgs.FileCount + form.Media.Count > MaximumFileCount)
{
fileErrors.Add(Localizer["The maximum number of files accepted is {0}, but {1} were supplied.", MaximumFileCount, form.Media.Count + eventArgs.FileCount]);
return;
}
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
{
var type = TypeOf(file);
if (type is null || !limits.SupportedMimeTypes.Contains(type) || !OfKind(type))
{
fileErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
continue;
}
if (file.Size > MaximumFileSize(type))
{
fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
file.Size.GetFileSize(Localizer), MaximumFileSize(type).GetFileSize(Localizer)]);
continue;
}
// the post's at once, and uploading (a publish meanwhile waits for it); its small picture follows
var media = new UploadMedia { File = file, FileName = file.Name, ContentType = type, Size = file.Size };
form.Media.Add(media);
altTextConfirmed = false;
Start(media);
if (type.StartsWith("image/"))
media.Preview = await Thumbnail(file);
}
}
// a small picture of it, which the browser makes (nothing of the whole file stays in memory); none when it can't
async Task<string> Thumbnail(IBrowserFile file)
{
try
{
var small = await file.RequestImageFileAsync("image/jpeg", 320, 320);
await using var stream = small.OpenReadStream(2 * 1024 * 1024);
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer);
return $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
}
catch (Exception ex)
{
await Logger.ProcessWarning($"No preview of {file.Name}: {ex.Message}", $"{nameof(Composer)}.{nameof(Thumbnail)}");
return default;
}
}
void Start(UploadMedia media)
{
media.Cancel = new();
media.State = UploadState.Uploading;
media.Error = default;
media.Done = Upload(media, media.Cancel.Token);
}
// uploads it, then follows what the server still processes (audio and video) until it is ready or refused
async Task Upload(UploadMedia media, CancellationToken cancellationToken)
{
var uploaded = await MediaService.Upload(media, MaximumFileSize(media.ContentType), cancellationToken);
if (cancellationToken.IsCancellationRequested)
return;
if (uploaded.IsInvalid)
{
media.State = UploadState.Failed;
media.Error = uploaded.ErrorMessage;
await InvokeAsync(StateHasChanged);
return;
}
var attachment = (MediaAttachment)uploaded.Data;
media.Id = attachment.Id;
media.SentAltText = attachment.Description;
media.State = attachment.Url is null ? UploadState.Processing : UploadState.Ready;
await InvokeAsync(StateHasChanged);
while (media.State == UploadState.Processing && !cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(2000, cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
var processed = await MediaService.Get(media.Id, cancellationToken);
if (cancellationToken.IsCancellationRequested)
return;
if (processed.IsInvalid)
{
media.State = UploadState.Failed;
media.Error = processed.ErrorMessage;
}
else if (processed.Data is MediaAttachment)
media.State = UploadState.Ready;
await InvokeAsync(StateHasChanged);
}
}
// a failed upload again, from the same file (the browser can still read it unless files were picked since)
void Retry(UploadMedia media) => Start(media);
void Remove(UploadMedia media)
{
media.Cancel?.Cancel();
form.Media.Remove(media);
}
// the reader chose to publish its pictures without descriptions
async Task PublishAnyway()
{
altTextConfirmed = true;
askingAltText = false;
await Publish();
}
public override async ValueTask DisposeAsync()
{
foreach (var media in form.Media)
media.Cancel?.Cancel();
await base.DisposeAsync();
}
async Task Publish()
{
error = default;
if (string.IsNullOrWhiteSpace(form.Content) && form.Media.Count == 0)
{
error = Localizer["Missing content, either message or media"];
return;
}
if (form.HasPoll && form.PollOptions.Count(o => !string.IsNullOrWhiteSpace(o)) < 2)
{
error = Localizer["A poll needs two choices at least."];
return;
}
if (!altTextConfirmed && CascadingState.PublicCacheData.PageSettings.RemindAltText && form.Media.Any(Undescribed))
{
askingAltText = true;
return;
}
// what is still uploading or processing is waited for
if (form.Media.Any(m => m.State is UploadState.Uploading or UploadState.Processing))
{
isPublishing = true;
await Task.WhenAll(form.Media.Select(m => m.Done));
isPublishing = false;
}
if (Editing is not null)
{
isPublishing = true;
var edited = await StatusService.Edit(Editing.Id, form);
isPublishing = false;
if (edited.IsInvalid)
{
error = edited.ErrorMessage;
return;
}
Toast.AddResponseSuccess(Localizer["Saved."]);
await OnPublished.InvokeAsync(FeedPost.Of((Status)edited.Data));
return;
}
if (CascadingState.PersonaId is null)
{
error = Localizer["Choose a persona first."];
return;
}
if (form.IsScheduled && form.ScheduledAtUtc is not { } at)
{
error = Localizer["Choose when to publish it."];
return;
}
if (form.ScheduledAtUtc < DateTime.UtcNow.AddMinutes(5))
{
error = Localizer["A post is scheduled at least five minutes ahead."];
return;
}
isPublishing = true;
var result = await StatusService.Publish(form, CascadingState.PersonaId);
isPublishing = false;
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
if (result.Data is ScheduledStatus scheduled)
{
Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", scheduled.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]);
form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage };
await OnPublished.InvokeAsync(null);
return;
}
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
var published = result.Data as FeedPost;
form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage };
await OnPublished.InvokeAsync(published);
}
sealed class GeoPosition
{
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public double? Accuracy { get; set; }
public string Error { get; set; }
}
}
+40
View File
@@ -0,0 +1,40 @@
@inherits LocalizableComponentBase
@implements IDisposable
@* The question ConfirmService asks before what can't be undone, in MainLayout: a sheet from the bottom on a phone, a
dialog in the middle from md. Closing it in any way answers no. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Confirm.IsAsking" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-confirm>
<Heading>@Confirm.Heading</Heading>
<ChildContent>
@if (Confirm.Text is { Length: > 0 } text)
{
<p class="text-sm">@text</p>
}
</ChildContent>
<Actions>
<DButton class="max-md:flex-1" OnClick="() => Confirm.Answer(false)" data-confirm-no>@Localizer["Cancel"]</DButton>
<DButton class="max-md:flex-1" Color="@(Confirm.Danger ? DColor.Error : DColor.Primary)" OnClick="() => Confirm.Answer(true)" data-confirm-yes>
@Confirm.ConfirmText
</DButton>
</Actions>
</DModal>
@code {
[Inject] ConfirmService Confirm { get; set; }
protected override void OnInitialized()
{
Confirm.Changed += Refresh;
IsLoading = false;
}
void Refresh() => InvokeAsync(StateHasChanged);
void Closed(bool open)
{
if (!open)
Confirm.Answer(false);
}
public void Dispose() => Confirm.Changed -= Refresh;
}
+1328 -222
View File
File diff suppressed because it is too large. Load diff
+77
View File
@@ -0,0 +1,77 @@
@inherits DComponentBase
@* daisyUI Button: https://daisyui.com/components/button/ . Renders an <a> when Href is set. *@
@if (Href is null)
{
<button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick"
@onmousedown:preventDefault="KeepFocus">
@ChildContent
</button>
}
else
{
<a @attributes="AdditionalAttributes" href="@(Disabled ? null : Href)" class="@CssClass" role="button"
aria-disabled="@(Disabled ? "true" : null)" tabindex="@(Disabled ? "-1" : null)" @onclick="OnClick">
@ChildContent
</a>
}
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DColor Color { get; set; }
[Parameter] public DButtonVariant Variant { get; set; }
[Parameter] public DSize Size { get; set; }
[Parameter] public DButtonShape Shape { get; set; }
/// <summary>Pressed look (btn-active); the neo theme renders it inset.</summary>
[Parameter] public bool Active { get; set; }
[Parameter] public bool Disabled { get; set; }
[Parameter] public string Type { get; set; } = "button";
[Parameter] public string Href { get; set; }
[Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; }
/// <summary>Pressing it doesn't take the focus from where it is (a chip that writes into a focused textarea).</summary>
[Parameter] public bool KeepFocus { get; set; }
string CssClass => Cx("btn", ColorClass, VariantClass, SizeClass, ShapeClass,
Active ? "btn-active" : null, Disabled && Href is not null ? "btn-disabled" : null, Class);
string ColorClass => Color switch
{
DColor.Neutral => "btn-neutral",
DColor.Primary => "btn-primary",
DColor.Secondary => "btn-secondary",
DColor.Accent => "btn-accent",
DColor.Info => "btn-info",
DColor.Success => "btn-success",
DColor.Warning => "btn-warning",
DColor.Error => "btn-error",
_ => null
};
string VariantClass => Variant switch
{
DButtonVariant.Outline => "btn-outline",
DButtonVariant.Dash => "btn-dash",
DButtonVariant.Soft => "btn-soft",
DButtonVariant.Ghost => "btn-ghost",
DButtonVariant.Link => "btn-link",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "btn-xs",
DSize.Sm => "btn-sm",
DSize.Lg => "btn-lg",
DSize.Xl => "btn-xl",
_ => null
};
string ShapeClass => Shape switch
{
DButtonShape.Wide => "btn-wide",
DButtonShape.Block => "btn-block",
DButtonShape.Square => "btn-square",
DButtonShape.Circle => "btn-circle",
_ => null
};
}
+20
View File
@@ -0,0 +1,20 @@
namespace decePubClient.Components.Daisy.Actions;
public enum DButtonVariant
{
Default,
Outline,
Dash,
Soft,
Ghost,
Link
}
public enum DButtonShape
{
Default,
Wide,
Block,
Square,
Circle
}
+123
View File
@@ -0,0 +1,123 @@
@inherits DComponentBase
@implements IDisposable
@* daisyUI Dropdown: https://daisyui.com/components/dropdown/ . The open state lives in C# (@bind-IsOpen):
dropdown-open shows the content and dropdown-close keeps the focus-within rule from showing it while closed.
While open, a transparent full-screen layer under the content closes it on an outside click; Escape closes it
too. With Hover the CSS hover rule opens it instead. Opening moves the focus into the content (Blazor's FocusAsync),
which also scrolls a menu cut off by its scroller into view; Escape or an outside click gives it back to the trigger.
SheetOnNarrow makes the content a sheet rising from the bottom of a phone's screen (below md), over a dimmed page. *@
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
<div @ref="trigger" role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
@onclick="Toggle">
@Trigger
</div>
@if (IsOpen)
{
<div class="@(SheetOnNarrow ? "fixed inset-0 z-[998] cursor-default max-md:bg-black/40" : "fixed inset-0 z-[998] cursor-default")"
aria-hidden="true" @onclick="Close"></div>
}
<div @ref="content" tabindex="-1" class="@Cx("dropdown-content z-[999]", SheetOnNarrow ? Sheet : null, ContentClass)"
data-sheet="@(SheetOnNarrow ? "" : null)" @onclick="OnContentClick">
@ChildContent
</div>
</div>
@code {
[Parameter] public RenderFragment Trigger { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
[Parameter] public DDropdownPlacement Placement { get; set; }
[Parameter] public DDropdownAlign Align { get; set; }
/// <summary>Open on hover (dropdown-hover) as well as on click.</summary>
[Parameter] public bool Hover { get; set; }
/// <summary>Close after any click inside the content, e.g. on a menu item.</summary>
[Parameter] public bool CloseOnContentClick { get; set; }
[Parameter] public string TriggerClass { get; set; }
[Parameter] public string ContentClass { get; set; }
/// <summary>Below md, the content is a sheet at the bottom of the screen instead of hanging from the trigger.</summary>
[Parameter] public bool SheetOnNarrow { get; set; }
const string Sheet = "max-md:fixed max-md:inset-x-0 max-md:bottom-0 max-md:top-auto max-md:m-0 max-md:w-full max-md:max-h-[80dvh] "
+ "max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-b-none max-md:translate-none max-md:scale-100 "
+ "max-md:p-3 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))]";
/// <summary>Where it says it is open, so the Back button closes it (cascaded by the app; none, none said).</summary>
[CascadingParameter] IDOverlayHost Overlays { get; set; }
ElementReference trigger;
ElementReference content;
IDisposable overlay;
bool focusContent;
bool focusTrigger;
string CssClass => Cx("dropdown", PlacementClass, AlignClass,
IsOpen ? "dropdown-open" : Hover ? "dropdown-hover" : "dropdown-close", Class);
string PlacementClass => Placement switch
{
DDropdownPlacement.Top => "dropdown-top",
DDropdownPlacement.Left => "dropdown-left",
DDropdownPlacement.Right => "dropdown-right",
_ => null
};
string AlignClass => Align switch
{
DDropdownAlign.Center => "dropdown-center",
DDropdownAlign.End => "dropdown-end",
_ => null
};
protected override void OnParametersSet() => TrackOverlay();
void TrackOverlay()
{
if (IsOpen && overlay is null)
overlay = Overlays?.Open(() => InvokeAsync(async () =>
{
await SetOpen(false, true);
StateHasChanged();
}));
else if (!IsOpen && overlay is not null)
{
overlay.Dispose();
overlay = null;
}
}
public void Dispose() => overlay?.Dispose();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (focusContent)
{
focusContent = false;
await content.FocusAsync();
}
if (focusTrigger)
{
focusTrigger = false;
await trigger.FocusAsync();
}
}
Task Toggle() => SetOpen(!IsOpen, IsOpen);
Task Close() => SetOpen(false, true);
// (a menu item may open a dialog, which takes the focus itself: the trigger doesn't take it back)
Task OnContentClick() => CloseOnContentClick ? SetOpen(false, false) : Task.CompletedTask;
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false, true) : Task.CompletedTask;
async Task SetOpen(bool isOpen, bool giveFocusBack)
{
focusContent = isOpen && !IsOpen;
focusTrigger = !isOpen && IsOpen && giveFocusBack;
IsOpen = isOpen;
TrackOverlay();
await IsOpenChanged.InvokeAsync(isOpen);
}
}
@@ -0,0 +1,18 @@
namespace decePubClient.Components.Daisy.Actions;
/// <summary>Which side of the trigger the content opens on; Bottom is daisyUI's default.</summary>
public enum DDropdownPlacement
{
Bottom,
Top,
Left,
Right
}
/// <summary>How the content lines up with the trigger along that side; Start is daisyUI's default.</summary>
public enum DDropdownAlign
{
Start,
Center,
End
}
+53
View File
@@ -0,0 +1,53 @@
@inherits DComponentBase
@* daisyUI FAB: https://daisyui.com/components/fab/ . Opening is daisyUI's own :focus-within rule (there is no
open-state class): the trigger is the focusable first child, and focus moving out of the FAB closes it. The order
of the children matters to daisyUI: trigger, then Close or MainAction, then the speed-dial items (ChildContent,
e.g. DButton Size="DSize.Lg" Shape="DButtonShape.Circle", optionally inside a <div> with a text label). *@
<div @attributes="AdditionalAttributes" class="@CssClass">
<div tabindex="0" role="button" aria-label="@LabelText" class="@Cx("btn btn-lg btn-circle", ColorClass, TriggerClass)">
@Trigger
</div>
@if (Close is not null)
{
<div class="fab-close">@Close</div>
}
@if (MainAction is not null)
{
<div class="fab-main-action">@MainAction</div>
}
@ChildContent
</div>
@code {
/// <summary>Content of the button shown while closed, usually an icon.</summary>
[Parameter] public RenderFragment Trigger { get; set; }
/// <summary>Accessible name of the trigger (aria-label), since it usually holds only an icon.</summary>
[Parameter] public string LabelText { get; set; }
[Parameter] public DColor Color { get; set; } = DColor.Primary;
[Parameter] public string TriggerClass { get; set; }
/// <summary>Replaces the trigger while open; clicking it closes the FAB. Not focusable, e.g. a text label and a
/// &lt;span class="btn btn-circle btn-lg"&gt;.</summary>
[Parameter] public RenderFragment Close { get; set; }
/// <summary>Replaces the trigger while open with a main action, e.g. a label and a button.</summary>
[Parameter] public RenderFragment MainAction { get; set; }
/// <summary>The speed-dial items.</summary>
[Parameter] public RenderFragment ChildContent { get; set; }
/// <summary>Arrange the items in a quarter circle (fab-flower) instead of a column.</summary>
[Parameter] public bool Flower { get; set; }
string CssClass => Cx("fab", Flower ? "fab-flower" : null, Class);
string ColorClass => Color switch
{
DColor.Neutral => "btn-neutral",
DColor.Primary => "btn-primary",
DColor.Secondary => "btn-secondary",
DColor.Accent => "btn-accent",
DColor.Info => "btn-info",
DColor.Success => "btn-success",
DColor.Warning => "btn-warning",
DColor.Error => "btn-error",
_ => null
};
}
+127
View File
@@ -0,0 +1,127 @@
@inherits DComponentBase
@implements IDisposable
@* daisyUI Modal: https://daisyui.com/components/modal/ . Always rendered; the open state lives in C# (@bind-IsOpen)
and shows as modal-open. On opening, the box takes the focus (Blazor's FocusAsync) so that Escape, handled on the
root, closes it; the backdrop button stretches over the area outside the box and closes it on click, and a visible
close button (ShowClose, named by CloseText) sits beside the heading, which stays in view while the box scrolls. *@
<div @attributes="AdditionalAttributes" role="dialog" aria-modal="true" aria-labelledby="@(Heading is null ? null : uid)"
class="@CssClass" @onkeydown="OnKeyDown">
<div @ref="box" tabindex="-1" class="@Cx("modal-box focus:outline-none", BoxClass)">
@if (Heading is not null || ShowClose)
{
<div class="sticky top-0 z-10 flex items-start gap-3 bg-base-100">
@if (Heading is not null)
{
<h3 id="@uid" class="flex-1 min-w-0 text-lg font-bold">@Heading</h3>
}
else
{
<span class="flex-1"></span>
}
@if (ShowClose)
{
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@CloseText" title="@CloseText"
@onclick="Close" data-modal-close>
<span aria-hidden="true">✕</span>
</button>
}
</div>
}
@ChildContent
@if (Actions is not null)
{
<div class="modal-action">
@Actions
</div>
}
</div>
@if (CloseOnBackdrop)
{
<div class="modal-backdrop">
<button type="button" @onclick="Close">@CloseText</button>
</div>
}
</div>
@code {
[Parameter] public RenderFragment Heading { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public RenderFragment Actions { get; set; }
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
[Parameter] public DModalPlacement Placement { get; set; }
/// <summary>Close on a click outside the box (renders the modal-backdrop).</summary>
[Parameter] public bool CloseOnBackdrop { get; set; } = true;
/// <summary>Name of the close button and of the invisible backdrop button, read by screen readers.</summary>
[Parameter] public string CloseText { get; set; } = "close";
/// <summary>A visible close button beside the heading.</summary>
[Parameter] public bool ShowClose { get; set; } = true;
[Parameter] public string BoxClass { get; set; }
/// <summary>Where it says it is open, so the Back button closes it (cascaded by the app; none, none said).</summary>
[CascadingParameter] IDOverlayHost Overlays { get; set; }
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
IDisposable overlay;
ElementReference box;
bool wasOpen;
bool focusPending;
string CssClass => Cx("modal", PlacementClass, IsOpen ? "modal-open" : null, Class);
string PlacementClass => Placement switch
{
DModalPlacement.Top => "modal-top",
DModalPlacement.Bottom => "modal-bottom",
DModalPlacement.Start => "modal-start",
DModalPlacement.End => "modal-end",
DModalPlacement.ResponsiveBottom => "modal-bottom md:modal-middle",
_ => null
};
protected override void OnParametersSet()
{
if (IsOpen && !wasOpen)
focusPending = true;
wasOpen = IsOpen;
TrackOverlay();
}
void TrackOverlay()
{
if (IsOpen && overlay is null)
overlay = Overlays?.Open(() => InvokeAsync(async () =>
{
await SetOpen(false);
StateHasChanged();
}));
else if (!IsOpen && overlay is not null)
{
overlay.Dispose();
overlay = null;
}
}
public void Dispose() => overlay?.Dispose();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!focusPending)
return;
focusPending = false;
await box.FocusAsync();
}
Task Close() => SetOpen(false);
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false) : Task.CompletedTask;
async Task SetOpen(bool isOpen)
{
IsOpen = isOpen;
wasOpen = isOpen;
TrackOverlay();
await IsOpenChanged.InvokeAsync(isOpen);
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace decePubClient.Components.Daisy.Actions;
/// <summary>Where the box sits on the screen; Middle is daisyUI's default.</summary>
public enum DModalPlacement
{
Middle,
Top,
Bottom,
Start,
End,
/// <summary>A sheet rising from the bottom below md (phones), in the middle from md.</summary>
ResponsiveBottom
}
+35
View File
@@ -0,0 +1,35 @@
@inherits DComponentBase
@* daisyUI Swap: https://daisyui.com/components/swap/ . A label around daisyUI's hidden checkbox: clicking anywhere on
it toggles the checkbox, whose state is bound to Active (@bind-Active); On shows while checked, Off otherwise. *@
<label @attributes="AdditionalAttributes" class="@CssClass">
<input type="checkbox" checked="@Active" disabled="@Disabled" aria-label="@LabelText" @onchange="OnChange" />
<div class="swap-on">@On</div>
<div class="swap-off">@Off</div>
</label>
@code {
[Parameter] public RenderFragment On { get; set; }
[Parameter] public RenderFragment Off { get; set; }
[Parameter] public bool Active { get; set; }
[Parameter] public EventCallback<bool> ActiveChanged { get; set; }
[Parameter] public DSwapEffect Effect { get; set; }
[Parameter] public bool Disabled { get; set; }
/// <summary>Accessible name of the checkbox (aria-label), for swaps that show only icons.</summary>
[Parameter] public string LabelText { get; set; }
string CssClass => Cx("swap", EffectClass, Disabled ? "cursor-not-allowed opacity-50" : null, Class);
string EffectClass => Effect switch
{
DSwapEffect.Rotate => "swap-rotate",
DSwapEffect.Flip => "swap-flip",
_ => null
};
async Task OnChange(ChangeEventArgs e)
{
Active = e.Value is true;
await ActiveChanged.InvokeAsync(Active);
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace decePubClient.Components.Daisy.Actions;
public enum DSwapEffect
{
None,
Rotate,
Flip
}
@@ -0,0 +1,27 @@
@inherits DComponentBase
@* daisyUI Theme controller: https://daisyui.com/components/theme-controller/ . While checked, the page uses the theme
named in Value (daisyUI's :root:has(.theme-controller:checked) rule). The app itself sets data-theme from C#, so
this exists for completeness. Style it with class="toggle", "checkbox", "radio", "btn" and so on. For a radio
group, pass the same name attribute to each and set Checked from one shared value: a radio raises no change event
when another one unchecks it. *@
<input @attributes="AdditionalAttributes" type="@(Radio ? "radio" : "checkbox")" value="@Value" class="@CssClass"
checked="@Checked" @onchange="OnChange" />
@code {
/// <summary>A daisyUI theme name, e.g. "neo-dark".</summary>
[Parameter] public string Value { get; set; }
[Parameter] public bool Checked { get; set; }
[Parameter] public EventCallback<bool> CheckedChanged { get; set; }
/// <summary>Render a radio instead of a checkbox.</summary>
[Parameter] public bool Radio { get; set; }
string CssClass => Cx("theme-controller", Class);
async Task OnChange(ChangeEventArgs e)
{
// A checkbox reports its checked state; a radio only reports its value when it becomes checked.
Checked = Radio || e.Value is true;
await CheckedChanged.InvokeAsync(Checked);
}
}
+27
View File
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Components;
namespace decePubClient.Components.Daisy;
/// <summary>
/// Base of the daisyUI wrappers. A consumer's <c>class</c> lands in <see cref="Class"/> and is appended after the
/// component's own classes; every other unmatched attribute is passed through to the root element.
/// </summary>
public abstract class DComponentBase : ComponentBase
{
[Parameter] public string Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; }
protected static string Cx(params string[] classes) => DCx.Join(classes);
}
public static class DCx
{
/// <summary>
/// Joins the non-empty class names. Every daisyUI class handed to it must be written out whole somewhere in the
/// source, e.g. a switch arm <c>DColor.Primary => "btn-primary"</c>, never assembled like <c>"btn-" + color</c>:
/// Tailwind generates only the classes it finds in .razor and .cs files.
/// </summary>
public static string Join(params string[] classes) =>
string.Join(' ', classes.Where(c => !string.IsNullOrWhiteSpace(c)));
}
+45
View File
@@ -0,0 +1,45 @@
using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
namespace decePubClient.Components.Daisy;
/// <summary>
/// Base of the daisyUI form fields. It is Blazor's <see cref="InputBase{TValue}"/>, so <c>@bind-Value</c> and
/// EditForm validation work as with InputText; it also works outside an EditForm, as long as the value is bound.
/// A consumer's <c>class</c> stays in AdditionalAttributes, and <see cref="InputBase{TValue}.CssClass"/> adds the
/// EditContext's valid/invalid/modified markers to it.
/// </summary>
public abstract class DInputBase<TValue> : InputBase<TValue>
{
/// <summary>Whether the bound field currently has validation messages.</summary>
protected bool IsInvalid => EditContext != null && EditContext.GetValidationMessages(FieldIdentifier).Any();
/// <summary>The attributes for the inner input element, without class (that goes on the styled element).</summary>
protected IReadOnlyDictionary<string, object> InputAttributes =>
AdditionalAttributes?.Where(a => a.Key != "class").ToDictionary(a => a.Key, a => a.Value);
/// <summary>
/// The name attribute, or null so none is rendered. InputBase.NameAttributeValue is "" outside a form that maps
/// field names, which would group every radio and checkbox on the page.
/// </summary>
protected string NameAttribute => string.IsNullOrEmpty(NameAttributeValue) ? null : NameAttributeValue;
protected static string Cx(params string[] classes) => DCx.Join(classes);
protected override string FormatValueAsString(TValue value) =>
BindConverter.FormatValue(value, CultureInfo.InvariantCulture)?.ToString();
protected override bool TryParseValueFromString(string value, out TValue result, out string validationErrorMessage)
{
if (BindConverter.TryConvertTo(value, CultureInfo.InvariantCulture, out result))
{
validationErrorMessage = null;
return true;
}
validationErrorMessage = $"The {DisplayName ?? FieldIdentifier.FieldName} field is not valid.";
return false;
}
}
+34
View File
@@ -0,0 +1,34 @@
namespace decePubClient.Components.Daisy;
// Modifiers shared by many components. Each component maps them to its own class names in a switch whose arms are
// whole literals (DColor.Primary => "btn-primary"), so Tailwind finds them. The first member is the default and
// maps to no class.
public enum DColor
{
None,
Neutral,
Primary,
Secondary,
Accent,
Info,
Success,
Warning,
Error
}
/// <summary>daisyUI's sizes; <see cref="Md"/> is the default and renders no class.</summary>
public enum DSize
{
Md,
Xs,
Sm,
Lg,
Xl
}
public enum DDirection
{
Horizontal,
Vertical
}
@@ -0,0 +1,46 @@
@inherits DComponentBase
@* daisyUI Accordion: https://daisyui.com/components/accordion/ . Instead of daisyUI's radio group, the open item is an
index in C# (@bind-Active, -1 = none). Each DAccordionItem registers itself through the cascading value and renders a
DCollapse that is open only while its index is Active. The cascading value isn't fixed, so every item re-renders
whenever the accordion does. *@
<div @attributes="AdditionalAttributes" class="@CssClass">
<CascadingValue Value="this">
@ChildContent
</CascadingValue>
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
/// <summary>Index of the open item, in registration order; -1 when all are closed.</summary>
[Parameter] public int Active { get; set; } = -1;
[Parameter] public EventCallback<int> ActiveChanged { get; set; }
[Parameter] public DCollapseIcon Icon { get; set; }
/// <summary>Join the items into one block (join join-vertical, join-item).</summary>
[Parameter] public bool Joined { get; set; }
readonly List<DAccordionItem> items = [];
string CssClass => Cx(Joined ? "join join-vertical rounded-box neo-raised" : "flex flex-col gap-2", Class);
internal int IndexOf(DAccordionItem item) => items.IndexOf(item);
internal void Add(DAccordionItem item)
{
items.Add(item);
StateHasChanged();
}
internal void Remove(DAccordionItem item)
{
items.Remove(item);
StateHasChanged();
}
internal async Task Select(int index)
{
Active = index;
await ActiveChanged.InvokeAsync(index);
StateHasChanged();
}
}
@@ -0,0 +1,32 @@
@inherits DComponentBase
@implements IDisposable
@* One item of a DAccordion: a DCollapse (so collapse + neo-raised, keyboard and aria-expanded come from there) whose
open state is the accordion's Active index. Opening it closes the others; clicking it while open closes it. *@
<DCollapse @attributes="AdditionalAttributes" IsOpen="@IsOpen" IsOpenChanged="OnIsOpenChanged" Icon="@Parent.Icon"
Disabled="@Disabled" HeaderClass="@HeaderClass" ContentClass="@ContentClass" Header="@Header"
Class="@Cx(Parent.Joined ? "join-item shadow-none!" : null, Class)">
@ChildContent
</DCollapse>
@code {
[CascadingParameter] DAccordion Parent { get; set; }
[Parameter] public RenderFragment Header { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public bool Disabled { get; set; }
[Parameter] public string HeaderClass { get; set; }
[Parameter] public string ContentClass { get; set; }
bool IsOpen => Parent.Active >= 0 && Parent.Active == Parent.IndexOf(this);
protected override void OnInitialized()
{
if (Parent is null)
throw new InvalidOperationException($"{nameof(DAccordionItem)} must be inside a {nameof(DAccordion)}.");
Parent.Add(this);
}
Task OnIsOpenChanged(bool isOpen) => Parent.Select(isOpen ? Parent.IndexOf(this) : -1);
public void Dispose() => Parent?.Remove(this);
}
+51
View File
@@ -0,0 +1,51 @@
@inherits DComponentBase
@* daisyUI Aura: https://daisyui.com/components/aura/ . ChildContent must be exactly one element (a btn, card, …).
The light takes the text colour (Color, or any text-* in Class); a bg-* sets its background and duration-* its
speed. daisyUI advises at most one aura per page. *@
<div @attributes="AdditionalAttributes" class="@CssClass">
@ChildContent
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DAuraVariant Variant { get; set; }
/// <summary>Colour of the light, as text-*; the child inherits it unless it sets its own text colour.</summary>
[Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; }
string CssClass => Cx("aura", VariantClass, ColorClass, SizeClass, Class);
string VariantClass => Variant switch
{
DAuraVariant.Dual => "aura-dual",
DAuraVariant.Rainbow => "aura-rainbow",
DAuraVariant.Holo => "aura-holo",
DAuraVariant.Gold => "aura-gold",
DAuraVariant.Silver => "aura-silver",
DAuraVariant.Glow => "aura-glow",
_ => null
};
string ColorClass => Color switch
{
DColor.Neutral => "text-neutral",
DColor.Primary => "text-primary",
DColor.Secondary => "text-secondary",
DColor.Accent => "text-accent",
DColor.Info => "text-info",
DColor.Success => "text-success",
DColor.Warning => "text-warning",
DColor.Error => "text-error",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "aura-xs",
DSize.Sm => "aura-sm",
DSize.Lg => "aura-lg",
DSize.Xl => "aura-xl",
_ => null
};
}
@@ -0,0 +1,12 @@
namespace decePubClient.Components.Daisy.DataDisplay;
public enum DAuraVariant
{
Default,
Dual,
Rainbow,
Holo,
Gold,
Silver,
Glow
}
@@ -0,0 +1,37 @@
@inherits DComponentBase
@* daisyUI Avatar: https://daisyui.com/components/avatar/ . Class goes on the inner frame, where daisyUI puts the size
and shape (w-16 rounded-full, mask mask-squircle) and, for a placeholder, the colours (bg-neutral
text-neutral-content); the other attributes go on the outer .avatar. Without Src the frame shows ChildContent. *@
<div @attributes="AdditionalAttributes" class="@CssClass">
<div class="@Class">
@if (Src is not null)
{
<img src="@Src" alt="@Alt" />
}
else
{
@ChildContent
}
</div>
</div>
@code {
[Parameter] public string Src { get; set; }
// "" on purpose (the documented exception to "no string defaults"): an empty alt marks a decorative image.
[Parameter] public string Alt { get; set; } = "";
/// <summary>Shown in the frame when there is no Src, e.g. initials with Placeholder.</summary>
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DAvatarStatus Status { get; set; }
/// <summary>Centre ChildContent in the frame (avatar-placeholder); give the frame a bg-* in Class.</summary>
[Parameter] public bool Placeholder { get; set; }
string CssClass => Cx("avatar", StatusClass, Placeholder ? "avatar-placeholder" : null);
string StatusClass => Status switch
{
DAvatarStatus.Online => "avatar-online",
DAvatarStatus.Offline => "avatar-offline",
_ => null
};
}
@@ -0,0 +1,8 @@
namespace decePubClient.Components.Daisy.DataDisplay;
public enum DAvatarStatus
{
None,
Online,
Offline
}
@@ -0,0 +1,11 @@
@inherits DComponentBase
@* daisyUI Avatar group: https://daisyui.com/components/avatar/ . Overlap the DAvatar children with a negative
space-x in Class, e.g. -space-x-6. *@
<div @attributes="AdditionalAttributes" class="@Cx("avatar-group", Class)">
@ChildContent
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
}
+44
View File
@@ -0,0 +1,44 @@
@inherits DComponentBase
@* daisyUI Badge: https://daisyui.com/components/badge/ . Without ChildContent it is an empty dot. *@
<span @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</span>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DColor Color { get; set; }
[Parameter] public DBadgeVariant Variant { get; set; }
[Parameter] public DSize Size { get; set; }
string CssClass => Cx("badge", ColorClass, VariantClass, SizeClass, Class);
string ColorClass => Color switch
{
DColor.Neutral => "badge-neutral",
DColor.Primary => "badge-primary",
DColor.Secondary => "badge-secondary",
DColor.Accent => "badge-accent",
DColor.Info => "badge-info",
DColor.Success => "badge-success",
DColor.Warning => "badge-warning",
DColor.Error => "badge-error",
_ => null
};
string VariantClass => Variant switch
{
DBadgeVariant.Outline => "badge-outline",
DBadgeVariant.Dash => "badge-dash",
DBadgeVariant.Soft => "badge-soft",
DBadgeVariant.Ghost => "badge-ghost",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "badge-xs",
DSize.Sm => "badge-sm",
DSize.Lg => "badge-lg",
DSize.Xl => "badge-xl",
_ => null
};
}
@@ -0,0 +1,10 @@
namespace decePubClient.Components.Daisy.DataDisplay;
public enum DBadgeVariant
{
Default,
Outline,
Dash,
Soft,
Ghost
}
+66
View File
@@ -0,0 +1,66 @@
@inherits DComponentBase
@* daisyUI Card: https://daisyui.com/components/card/ . Figure is wrapped in <figure> before the body (after it with
FigureLast); the body renders only when Heading, ChildContent or Actions is set. Give it a background in Class
(bg-base-100). *@
<div @attributes="AdditionalAttributes" class="@CssClass">
@if (Figure is not null && !FigureLast)
{
<figure class="@FigureClass">@Figure</figure>
}
@if (Heading is not null || ChildContent is not null || Actions is not null)
{
<div class="@Cx("card-body", BodyClass)">
@if (Heading is not null)
{
<h2 class="card-title">@Heading</h2>
}
@ChildContent
@if (Actions is not null)
{
<div class="@Cx("card-actions", ActionsClass)">@Actions</div>
}
</div>
}
@if (Figure is not null && FigureLast)
{
<figure class="@FigureClass">@Figure</figure>
}
</div>
@code {
[Parameter] public RenderFragment Figure { get; set; }
[Parameter] public RenderFragment Heading { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public RenderFragment Actions { get; set; }
[Parameter] public DCardVariant Variant { get; set; }
[Parameter] public DSize Size { get; set; }
/// <summary>Figure beside the body instead of above it (card-side).</summary>
[Parameter] public bool Side { get; set; }
/// <summary>Figure as the background behind the body (image-full).</summary>
[Parameter] public bool ImageFull { get; set; }
/// <summary>Put the figure after the body: at the bottom, or on the right with Side.</summary>
[Parameter] public bool FigureLast { get; set; }
[Parameter] public string FigureClass { get; set; }
[Parameter] public string BodyClass { get; set; }
/// <summary>Extra classes on card-actions, e.g. justify-end.</summary>
[Parameter] public string ActionsClass { get; set; }
string CssClass => Cx("card", VariantClass, SizeClass, Side ? "card-side" : null, ImageFull ? "image-full" : null, Class);
string VariantClass => Variant switch
{
DCardVariant.Border => "card-border",
DCardVariant.Dash => "card-dash",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "card-xs",
DSize.Sm => "card-sm",
DSize.Lg => "card-lg",
DSize.Xl => "card-xl",
_ => null
};
}
@@ -0,0 +1,8 @@
namespace decePubClient.Components.Daisy.DataDisplay;
public enum DCardVariant
{
Default,
Border,
Dash
}
@@ -0,0 +1,80 @@
@inherits DComponentBase
@* daisyUI Carousel: https://daisyui.com/components/carousel/ . A scroll-snap strip of DCarouselItem children; the
user scrolls or swipes it. Give each item w-full for one slide at a time. ShowNavigation (previous/next on every
slide) and ShowIndicators (numbered buttons under the strip) are links to the slides' ids, as in daisyUI's examples:
they keep the current path, so Blazor treats them as same-page fragments and scrolls the slide into view. *@
<CascadingValue Value="this" IsFixed="true">
@if (ShowIndicators)
{
<div class="flex flex-col gap-2">
@Strip
<div class="flex w-full justify-center gap-2">
@for (var i = 0; i < items.Count; i++)
{
<a href="@Link(i)" class="btn btn-xs" aria-label="@($"{SlideText} {i + 1}")">@(i + 1)</a>
}
</div>
</div>
}
else
{
@Strip
}
</CascadingValue>
@code {
[Inject] NavigationManager NavigationManager { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DCarouselSnap Snap { get; set; }
[Parameter] public DDirection Direction { get; set; }
/// <summary>Previous/next buttons on every slide.</summary>
[Parameter] public bool ShowNavigation { get; set; }
/// <summary>A row of numbered buttons under the strip, one per slide.</summary>
[Parameter] public bool ShowIndicators { get; set; }
[Parameter] public string PreviousText { get; set; } = "Previous slide";
[Parameter] public string NextText { get; set; } = "Next slide";
[Parameter] public string SlideText { get; set; } = "Slide";
readonly List<DCarouselItem> items = [];
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
RenderFragment Strip => @<div @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</div>;
string CssClass => Cx("carousel", SnapClass, Direction == DDirection.Vertical ? "carousel-vertical" : null, Class);
string SnapClass => Snap switch
{
DCarouselSnap.Center => "carousel-center",
DCarouselSnap.End => "carousel-end",
_ => null
};
internal int Count => items.Count;
internal int IndexOf(DCarouselItem item) => items.IndexOf(item);
internal string SlideId(int index) => $"{uid}-{index + 1}";
/// <summary>The current page plus the slide's fragment; the index wraps around.</summary>
internal string Link(int index)
{
var path = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
var hash = path.IndexOf('#');
if (hash >= 0)
path = path[..hash];
return $"{path}#{SlideId((index % items.Count + items.Count) % items.Count)}";
}
internal void Add(DCarouselItem item)
{
items.Add(item);
StateHasChanged();
}
internal void Remove(DCarouselItem item)
{
items.Remove(item);
StateHasChanged();
}
}
@@ -0,0 +1,9 @@
namespace decePubClient.Components.Daisy.DataDisplay;
/// <summary>Where each item snaps; <see cref="Start"/> is daisyUI's default, so it renders no class.</summary>
public enum DCarouselSnap
{
Start,
Center,
End
}
@@ -0,0 +1,28 @@
@inherits DComponentBase
@implements IDisposable
@* One slide of a DCarousel. It registers with the carousel for its id and, with ShowNavigation, draws the
previous/next links over itself. *@
<div @attributes="AdditionalAttributes" id="@Id" class="@Cx("carousel-item", HasNavigation ? "relative" : null, Class)">
@ChildContent
@if (HasNavigation)
{
<div class="absolute inset-x-2 top-1/2 flex -translate-y-1/2 justify-between">
<a href="@Parent.Link(Index - 1)" class="btn btn-circle btn-sm" aria-label="@Parent.PreviousText">❮</a>
<a href="@Parent.Link(Index + 1)" class="btn btn-circle btn-sm" aria-label="@Parent.NextText">❯</a>
</div>
}
</div>
@code {
[CascadingParameter] DCarousel Parent { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
int Index => Parent?.IndexOf(this) ?? -1;
string Id => Parent is null ? null : Parent.SlideId(Index);
bool HasNavigation => Parent is { ShowNavigation: true, Count: > 1 };
protected override void OnInitialized() => Parent?.Add(this);
public void Dispose() => Parent?.Remove(this);
}
+32
View File
@@ -0,0 +1,32 @@
@inherits DComponentBase
@* daisyUI Chat: https://daisyui.com/components/chat/ . One line of a conversation: an optional avatar, header and
footer around one DChatBubble (ChildContent). Avatar renders inside <div class="chat-image avatar">, so it takes
the avatar's frame, e.g. <div class="w-10 rounded-full"><img … /></div>. *@
<div @attributes="AdditionalAttributes" class="@CssClass">
@if (Avatar is not null)
{
<div class="chat-image avatar">@Avatar</div>
}
@if (Header is not null)
{
<div class="@Cx("chat-header", HeaderClass)">@Header</div>
}
@ChildContent
@if (Footer is not null)
{
<div class="@Cx("chat-footer", FooterClass)">@Footer</div>
}
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public RenderFragment Avatar { get; set; }
[Parameter] public RenderFragment Header { get; set; }
[Parameter] public RenderFragment Footer { get; set; }
[Parameter] public DChatPlacement Placement { get; set; }
[Parameter] public string HeaderClass { get; set; }
[Parameter] public string FooterClass { get; set; } = "opacity-50";
string CssClass => Cx("chat", Placement == DChatPlacement.End ? "chat-end" : "chat-start", Class);
}
@@ -0,0 +1,27 @@
@inherits DComponentBase
@* daisyUI Chat bubble: https://daisyui.com/components/chat/ . The message of a DChat; one per DChat, since daisyUI
places every bubble in the same grid cell. *@
<div @attributes="AdditionalAttributes" class="@CssClass">
@ChildContent
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DColor Color { get; set; }
string CssClass => Cx("chat-bubble", ColorClass, Class);
string ColorClass => Color switch
{
DColor.Neutral => "chat-bubble-neutral",
DColor.Primary => "chat-bubble-primary",
DColor.Secondary => "chat-bubble-secondary",
DColor.Accent => "chat-bubble-accent",
DColor.Info => "chat-bubble-info",
DColor.Success => "chat-bubble-success",
DColor.Warning => "chat-bubble-warning",
DColor.Error => "chat-bubble-error",
_ => null
};
}
@@ -0,0 +1,8 @@
namespace decePubClient.Components.Daisy.DataDisplay;
/// <summary>daisyUI requires a placement, so unlike other enums both members render a class.</summary>
public enum DChatPlacement
{
Start,
End
}
@@ -0,0 +1,45 @@
@inherits DComponentBase
@* daisyUI Collapse: https://daisyui.com/components/collapse/ . The open state lives in C# (@bind-IsOpen) and is
rendered with collapse-open / collapse-close; clicking the header (or Enter/Space on it) toggles it. neo-raised
gives it the neumorphic panel look (see Styles/neo.css for why it isn't a @utility override). *@
<div @attributes="AdditionalAttributes" class="@CssClass">
<div class="@Cx("collapse-title", Disabled ? "cursor-not-allowed" : "cursor-pointer", HeaderClass)" role="button"
tabindex="@(Disabled ? "-1" : "0")" aria-expanded="@(IsOpen ? "true" : "false")" aria-disabled="@(Disabled ? "true" : null)"
@onclick="Toggle" @onkeydown="OnKeyDown">
@Header
</div>
<div class="@Cx("collapse-content", ContentClass)">
@ChildContent
</div>
</div>
@code {
[Parameter] public RenderFragment Header { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
[Parameter] public DCollapseIcon Icon { get; set; }
[Parameter] public bool Disabled { get; set; }
[Parameter] public string HeaderClass { get; set; }
[Parameter] public string ContentClass { get; set; }
string CssClass => Cx("collapse neo-raised", IconClass, IsOpen ? "collapse-open" : "collapse-close", Class);
string IconClass => Icon switch
{
DCollapseIcon.Arrow => "collapse-arrow",
DCollapseIcon.Plus => "collapse-plus",
_ => null
};
async Task Toggle()
{
if (Disabled)
return;
IsOpen = !IsOpen;
await IsOpenChanged.InvokeAsync(IsOpen);
}
Task OnKeyDown(KeyboardEventArgs e) => e.Key is "Enter" or " " ? Toggle() : Task.CompletedTask;
}
@@ -0,0 +1,8 @@
namespace decePubClient.Components.Daisy.DataDisplay;
public enum DCollapseIcon
{
None,
Arrow,
Plus
}
@@ -0,0 +1,18 @@
@inherits DComponentBase
@* daisyUI Countdown: https://daisyui.com/components/countdown/ . Shows one number (0-999) that rolls to each new
Value; --value and --digits go in the inner span's style. For a clock, put several side by side with separators. *@
<span @attributes="AdditionalAttributes" class="@Cx("countdown", Class)">
<span style="@ValueStyle" aria-live="polite" aria-label="@Number">@Number</span>
</span>
@code {
/// <summary>The number to show, clamped to 0-999.</summary>
[Parameter] public int Value { get; set; }
/// <summary>Minimum digits shown (1-3); 2 pads 5 to 05.</summary>
[Parameter] public int Digits { get; set; } = 1;
int Number => Math.Clamp(Value, 0, 999);
string ValueStyle => Digits > 1 ? $"--value:{Number}; --digits:{Math.Min(Digits, 3)};" : $"--value:{Number};";
}
+20
View File
@@ -0,0 +1,20 @@
@inherits DComponentBase
@* daisyUI Diff: https://daisyui.com/components/diff/ . Item1 and Item2 overlap, and the resizer handle reveals one over
the other; each slot takes one element (an img, or a div that fills it). Keyboard focus on the figure or on item 1
slides the handle to either end. It has no height of its own: AspectClass (or a height in Class) gives it one. *@
<figure @attributes="AdditionalAttributes" class="@Cx("diff", AspectClass, Class)" tabindex="0">
<div class="diff-item-1" role="img" aria-label="@Item1Alt" tabindex="0">@Item1</div>
<div class="diff-item-2" role="img" aria-label="@Item2Alt">@Item2</div>
<div class="diff-resizer"></div>
</figure>
@code {
[Parameter] public RenderFragment Item1 { get; set; }
[Parameter] public RenderFragment Item2 { get; set; }
/// <summary>Accessible name of item 1 (its role is img, which hides the alt of an img inside).</summary>
[Parameter] public string Item1Alt { get; set; }
[Parameter] public string Item2Alt { get; set; }
/// <summary>An aspect-* class for the figure; null when Class gives it a height instead.</summary>
[Parameter] public string AspectClass { get; set; } = "aspect-16/9";
}
@@ -0,0 +1,24 @@
@inherits DComponentBase
@* daisyUI Hover 3D: https://daisyui.com/components/hover-3d/ . ChildContent must be one non-interactive element (a
<figure>, a card); the eight empty divs after it are the hover zones that tilt it. With Href the whole thing is a
link, the only way to make it clickable. *@
@if (Href is null)
{
<div @attributes="AdditionalAttributes" class="@Cx("hover-3d", Class)">
@ChildContent
<div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div>
</div>
}
else
{
<a @attributes="AdditionalAttributes" href="@Href" class="@Cx("hover-3d", Class)">
@ChildContent
<div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div>
</a>
}
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public string Href { get; set; }
}
@@ -0,0 +1,22 @@
@inherits DComponentBase
@* daisyUI Hover gallery: https://daisyui.com/components/hover-gallery/ . Shows the first image; moving the pointer
across it shows the others. Up to 10 images of the same size, from Images or as <img> children; cap its width in
Class (max-w-60), or it fills the container. *@
<figure @attributes="AdditionalAttributes" class="@Cx("hover-gallery", Class)">
@if (Images is not null)
{
foreach (var src in Images.Take(10))
{
<img src="@src" alt="@Alt" />
}
}
@ChildContent
</figure>
@code {
[Parameter] public IEnumerable<string> Images { get; set; }
// "" on purpose (the documented exception to "no string defaults"): an empty alt marks a decorative image.
[Parameter] public string Alt { get; set; } = "";
[Parameter] public RenderFragment ChildContent { get; set; }
}
+20
View File
@@ -0,0 +1,20 @@
@inherits DComponentBase
@* daisyUI Kbd: https://daisyui.com/components/kbd/ *@
<kbd @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</kbd>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DSize Size { get; set; }
string CssClass => Cx("kbd", SizeClass, Class);
string SizeClass => Size switch
{
DSize.Xs => "kbd-xs",
DSize.Sm => "kbd-sm",
DSize.Lg => "kbd-lg",
DSize.Xl => "kbd-xl",
_ => null
};
}
+10
View File
@@ -0,0 +1,10 @@
@inherits DComponentBase
@* daisyUI List: https://daisyui.com/components/list/ . Rows are DListRow children; a plain <li> works as a caption. *@
<ul @attributes="AdditionalAttributes" class="@Cx("list", Class)">
@ChildContent
</ul>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
}
@@ -0,0 +1,11 @@
@inherits DComponentBase
@* One row of a DList. Its second child fills the remaining width; put list-col-grow on another child to change that,
and list-col-wrap on a child to move it to a second line. *@
<li @attributes="AdditionalAttributes" class="@Cx("list-row", Class)">
@ChildContent
</li>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
}
+37
View File
@@ -0,0 +1,37 @@
@inherits DComponentBase
@* One stat of a DStats: ChildContent is the value (stat-value); each slot renders its part only when set. *@
<div @attributes="AdditionalAttributes" class="@Cx("stat", Class)">
@if (Figure is not null)
{
<div class="@Cx("stat-figure", FigureClass)">@Figure</div>
}
@if (Heading is not null)
{
<div class="stat-title">@Heading</div>
}
@if (ChildContent is not null)
{
<div class="@Cx("stat-value", ValueClass)">@ChildContent</div>
}
@if (Desc is not null)
{
<div class="stat-desc">@Desc</div>
}
@if (Actions is not null)
{
<div class="stat-actions">@Actions</div>
}
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public RenderFragment Figure { get; set; }
[Parameter] public RenderFragment Heading { get; set; }
[Parameter] public RenderFragment Desc { get; set; }
[Parameter] public RenderFragment Actions { get; set; }
/// <summary>Extra classes on stat-figure, e.g. text-primary.</summary>
[Parameter] public string FigureClass { get; set; }
/// <summary>Extra classes on stat-value, e.g. text-primary.</summary>
[Parameter] public string ValueClass { get; set; }
}
+14
View File
@@ -0,0 +1,14 @@
@inherits DComponentBase
@* daisyUI Stat: https://daisyui.com/components/stat/ . The container of DStat items; horizontal unless Direction is
Vertical (for a responsive switch add lg:stats-horizontal in Class). *@
<div @attributes="AdditionalAttributes" class="@CssClass">
@ChildContent
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DDirection Direction { get; set; }
string CssClass => Cx("stats", Direction == DDirection.Vertical ? "stats-vertical" : null, Class);
}
@@ -0,0 +1,45 @@
@inherits DComponentBase
@* daisyUI Status: https://daisyui.com/components/status/ . Give it an aria-label (online, error, …). Ping stacks an
animate-ping copy under the dot, as in daisyUI's ping example; the attributes stay on the visible dot. *@
@if (Ping)
{
<span class="inline-grid *:[grid-area:1/1]">
<span class="@Cx(CssClass, "animate-ping")" aria-hidden="true"></span>
<span @attributes="AdditionalAttributes" class="@CssClass"></span>
</span>
}
else
{
<span @attributes="AdditionalAttributes" class="@CssClass"></span>
}
@code {
[Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; }
[Parameter] public bool Ping { get; set; }
string CssClass => Cx("status", ColorClass, SizeClass, Class);
string ColorClass => Color switch
{
DColor.Neutral => "status-neutral",
DColor.Primary => "status-primary",
DColor.Secondary => "status-secondary",
DColor.Accent => "status-accent",
DColor.Info => "status-info",
DColor.Success => "status-success",
DColor.Warning => "status-warning",
DColor.Error => "status-error",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "status-xs",
DSize.Sm => "status-sm",
DSize.Lg => "status-lg",
DSize.Xl => "status-xl",
_ => null
};
}
+41
View File
@@ -0,0 +1,41 @@
@inherits DComponentBase
@* daisyUI Table: https://daisyui.com/components/table/ . ChildContent is the <thead>/<tbody>/<tfoot> markup. Class and
the other attributes go on the <table>; with Scroll (the default) it sits in an overflow-x-auto div, whose classes
are WrapperClass (pinned rows need it to have a height, e.g. h-96); it is marked as a strip that scrolls sideways on
purpose (data-scroll-x), which the phone tests allow. *@
@if (Scroll)
{
<div class="@Cx("overflow-x-auto", WrapperClass)" data-scroll-x>
<table @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</table>
</div>
}
else
{
<table @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</table>
}
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DSize Size { get; set; }
[Parameter] public bool Zebra { get; set; }
/// <summary>Sticky thead/tfoot rows while the wrapper scrolls vertically.</summary>
[Parameter] public bool PinRows { get; set; }
/// <summary>Sticky &lt;th&gt; columns while the wrapper scrolls horizontally.</summary>
[Parameter] public bool PinCols { get; set; }
/// <summary>Wrap the table in an overflow-x-auto div so it scrolls on narrow screens.</summary>
[Parameter] public bool Scroll { get; set; } = true;
[Parameter] public string WrapperClass { get; set; }
string CssClass => Cx("table", Zebra ? "table-zebra" : null, PinRows ? "table-pin-rows" : null,
PinCols ? "table-pin-cols" : null, SizeClass, Class);
string SizeClass => Size switch
{
DSize.Xs => "table-xs",
DSize.Sm => "table-sm",
DSize.Lg => "table-lg",
DSize.Xl => "table-xl",
_ => null
};
}
@@ -0,0 +1,24 @@
@inherits DComponentBase
@* daisyUI Text rotate: https://daisyui.com/components/text-rotate/ . Loops through 2-6 lines, one at a time, pausing on
hover. The lines are Items, or <span> children in ChildContent (for per-line classes). Set the loop time with a
duration-* in Class (default 10s). *@
<span @attributes="AdditionalAttributes" class="@Cx("text-rotate", Class)">
<span class="@(Centered ? "justify-items-center" : null)">
@if (Items is not null)
{
foreach (var item in Items.Take(6))
{
<span>@item</span>
}
}
@ChildContent
</span>
</span>
@code {
[Parameter] public IEnumerable<string> Items { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
/// <summary>Centre the lines horizontally.</summary>
[Parameter] public bool Centered { get; set; }
}
@@ -0,0 +1,21 @@
@inherits DComponentBase
@* daisyUI Timeline: https://daisyui.com/components/timeline/ . Horizontal unless Direction is Vertical (for a
responsive switch add lg:timeline-horizontal in Class). Every DTimelineItem draws its connecting <hr> on both
sides; the two arbitrary variants here hide the outer ends, so the line runs only between items. *@
<ul @attributes="AdditionalAttributes" class="@CssClass">
@ChildContent
</ul>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DDirection Direction { get; set; }
/// <summary>Put every item on one side of the line (timeline-compact).</summary>
[Parameter] public bool Compact { get; set; }
/// <summary>Align the icon with the start of the content instead of its middle (timeline-snap-icon).</summary>
[Parameter] public bool SnapIcon { get; set; }
string CssClass => Cx("timeline", Direction == DDirection.Vertical ? "timeline-vertical" : null,
Compact ? "timeline-compact" : null, SnapIcon ? "timeline-snap-icon" : null,
"[&>li:first-child>hr:first-child]:hidden [&>li:last-child>hr:last-child]:hidden", Class);
}
@@ -0,0 +1,36 @@
@inherits DComponentBase
@* One event of a DTimeline: Start and End on either side of the line, Middle (an icon) on it. Each part renders only
when set. The <hr> before and after are the line segments to the neighbouring items; colour them with
LineBeforeClass / LineAfterClass (bg-primary). *@
<li @attributes="AdditionalAttributes" class="@Class">
<hr class="@LineBeforeClass" />
@if (Start is not null)
{
<div class="@Cx("timeline-start", StartBox ? "timeline-box" : null, StartClass)">@Start</div>
}
@if (Middle is not null)
{
<div class="@Cx("timeline-middle", MiddleClass)">@Middle</div>
}
@if (End is not null)
{
<div class="@Cx("timeline-end", EndBox ? "timeline-box" : null, EndClass)">@End</div>
}
<hr class="@LineAfterClass" />
</li>
@code {
[Parameter] public RenderFragment Start { get; set; }
[Parameter] public RenderFragment Middle { get; set; }
[Parameter] public RenderFragment End { get; set; }
/// <summary>Draw Start as a box (timeline-box).</summary>
[Parameter] public bool StartBox { get; set; }
/// <summary>Draw End as a box (timeline-box).</summary>
[Parameter] public bool EndBox { get; set; }
[Parameter] public string StartClass { get; set; }
[Parameter] public string MiddleClass { get; set; }
[Parameter] public string EndClass { get; set; }
[Parameter] public string LineBeforeClass { get; set; }
[Parameter] public string LineAfterClass { get; set; }
}
@@ -0,0 +1,40 @@
@inherits DInputBase<DateOnly?>
@using System.Globalization
@* daisyUI Calendar (Cally): https://daisyui.com/components/calendar/ . Wraps the <calendar-date> web component from
wwwroot/vendor/cally.js; value, min and max are yyyy-MM-dd, and Blazor reads the element's value on its change
event. Other Cally attributes (locale, first-day-of-week, months) pass through. *@
<calendar-date @attributes="InputAttributes" class="@Cx("cally", CssClass)" value="@CurrentValueAsString" min="@Format(Min)"
max="@Format(Max)" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)">
<svg aria-label="@PreviousText" class="fill-current size-4" slot="previous" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.75 19.5 8.25 12l7.5-7.5"></path></svg>
<svg aria-label="@NextText" class="fill-current size-4" slot="next" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m8.25 4.5 7.5 7.5-7.5 7.5"></path></svg>
<calendar-month></calendar-month>
</calendar-date>
@code {
[Parameter] public DateOnly? Min { get; set; }
[Parameter] public DateOnly? Max { get; set; }
[Parameter] public string PreviousText { get; set; } = "Previous";
[Parameter] public string NextText { get; set; } = "Next";
static string Format(DateOnly? date) => date?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
protected override string FormatValueAsString(DateOnly? value) => Format(value);
protected override bool TryParseValueFromString(string value, out DateOnly? result, out string validationErrorMessage)
{
validationErrorMessage = null;
result = null;
if (string.IsNullOrEmpty(value))
return true;
if (DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
{
result = date;
return true;
}
validationErrorMessage = $"The {DisplayName ?? FieldIdentifier.FieldName} field is not a valid date.";
return false;
}
void OnChange(ChangeEventArgs e) => CurrentValueAsString = e.Value?.ToString();
}
@@ -0,0 +1,63 @@
@inherits DInputBase<bool>
@* daisyUI Checkbox: https://daisyui.com/components/checkbox/ . With LabelText the checkbox sits in a <label class="label">
followed by the text, as in daisyUI's fieldset examples. Indeterminate renders aria-checked="mixed", which daisyUI
draws like the :indeterminate state that only script could set. *@
@if (LabelText is null)
{
@Checkbox
}
else
{
<label class="label">
@Checkbox
@LabelText
</label>
}
@code {
[Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; }
[Parameter] public string LabelText { get; set; }
/// <summary>The mixed state; a click clears it and reports false through IndeterminateChanged.</summary>
[Parameter] public bool Indeterminate { get; set; }
[Parameter] public EventCallback<bool> IndeterminateChanged { get; set; }
string CssClassFull => Cx("checkbox", IsInvalid ? "checkbox-error" : ColorClass, SizeClass, CssClass);
RenderFragment Checkbox => @<input @attributes="InputAttributes" type="checkbox" class="@CssClassFull" name="@NameAttribute"
checked="@CurrentValue" @onchange="OnChange" aria-checked="@(Indeterminate ? "mixed" : null)"
aria-invalid="@(IsInvalid ? "true" : null)" />;
string ColorClass => Color switch
{
DColor.Neutral => "checkbox-neutral",
DColor.Primary => "checkbox-primary",
DColor.Secondary => "checkbox-secondary",
DColor.Accent => "checkbox-accent",
DColor.Info => "checkbox-info",
DColor.Success => "checkbox-success",
DColor.Warning => "checkbox-warning",
DColor.Error => "checkbox-error",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "checkbox-xs",
DSize.Sm => "checkbox-sm",
DSize.Lg => "checkbox-lg",
DSize.Xl => "checkbox-xl",
_ => null
};
async Task OnChange(ChangeEventArgs e)
{
CurrentValue = e.Value is true;
if (Indeterminate)
{
Indeterminate = false;
await IndeterminateChanged.InvokeAsync(false);
}
}
}
@@ -0,0 +1,21 @@
@inherits DComponentBase
@* daisyUI Fieldset: https://daisyui.com/components/fieldset/ . Legend becomes the fieldset-legend, Description the
trailing p.label; disabled on the fieldset disables every field in it. *@
<fieldset @attributes="AdditionalAttributes" class="@Cx("fieldset", Class)">
@if (Legend is not null)
{
<legend class="fieldset-legend">@Legend</legend>
}
@ChildContent
@if (Description is not null)
{
<p class="label">@Description</p>
}
</fieldset>
@code {
[Parameter] public RenderFragment Legend { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public RenderFragment Description { get; set; }
}
@@ -0,0 +1,39 @@
@inherits DComponentBase
@* daisyUI File input: https://daisyui.com/components/file-input/ . Blazor's InputFile with the file-input classes;
read the files in OnChange (e.File / e.GetMultipleFiles()). *@
<InputFile @attributes="AdditionalAttributes" class="@CssClass" OnChange="OnChange" multiple="@Multiple" accept="@Accept" />
@code {
[Parameter] public EventCallback<InputFileChangeEventArgs> OnChange { get; set; }
[Parameter] public bool Multiple { get; set; }
/// <summary>The accept attribute: file types, e.g. "image/*,.pdf".</summary>
[Parameter] public string Accept { get; set; }
[Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; }
[Parameter] public bool Ghost { get; set; }
string CssClass => Cx("file-input", ColorClass, SizeClass, Ghost ? "file-input-ghost" : null, Class);
string ColorClass => Color switch
{
DColor.Neutral => "file-input-neutral",
DColor.Primary => "file-input-primary",
DColor.Secondary => "file-input-secondary",
DColor.Accent => "file-input-accent",
DColor.Info => "file-input-info",
DColor.Success => "file-input-success",
DColor.Warning => "file-input-warning",
DColor.Error => "file-input-error",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "file-input-xs",
DSize.Sm => "file-input-sm",
DSize.Lg => "file-input-lg",
DSize.Xl => "file-input-xl",
_ => null
};
}
+43
View File
@@ -0,0 +1,43 @@
@typeparam TValue
@inherits DInputBase<TValue>
@* daisyUI Filter: https://daisyui.com/components/filter/ . The <div> form of the filter: a native form reset would
not tell Blazor, and a <form> can't nest in an EditForm. Each option is a radio button checked while Value equals
it; the filter-reset radio sets Value back to default, so with no default among the options nothing is selected. *@
<div @attributes="InputAttributes" class="@Cx("filter", CssClass)">
<input type="radio" name="@GroupName" class="@Cx("btn", "filter-reset", SizeClass)" aria-label="@ResetText"
disabled="@Disabled" @onchange="() => CurrentValue = default" />
@if (Options is not null)
{
foreach (var option in Options)
{
<input type="radio" name="@GroupName" class="@Cx("btn", SizeClass)" aria-label="@(TextOf?.Invoke(option) ?? option?.ToString())"
checked="@IsSelected(option)" disabled="@Disabled"
@onchange="() => CurrentValue = option" />
}
}
</div>
@code {
[Parameter] public IEnumerable<TValue> Options { get; set; }
/// <summary>An option's button text; ToString() when null.</summary>
[Parameter] public Func<TValue, string> TextOf { get; set; }
[Parameter] public string ResetText { get; set; } = "×";
[Parameter] public DSize Size { get; set; }
[Parameter] public bool Disabled { get; set; }
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
string GroupName => string.IsNullOrEmpty(NameAttributeValue) ? uid : NameAttributeValue;
bool IsSelected(TValue option) => EqualityComparer<TValue>.Default.Equals(CurrentValue, option);
string SizeClass => Size switch
{
DSize.Xs => "btn-xs",
DSize.Sm => "btn-sm",
DSize.Lg => "btn-lg",
DSize.Xl => "btn-xl",
_ => null
};
}
+65
View File
@@ -0,0 +1,65 @@
@typeparam TValue
@inherits DInputBase<TValue>
@* daisyUI Input: https://daisyui.com/components/input/ . With Prefix or Suffix (icons, labels, kbd) the input class
moves to a wrapping <label>, as daisyUI requires when the field holds more than one element. *@
@if (Prefix is null && Suffix is null)
{
<input @attributes="InputAttributes" type="@Type" class="@CssClassFull" name="@NameAttribute"
value="@CurrentValueAsString" @oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)" />
}
else
{
<label class="@CssClassFull">
@Prefix
<input @attributes="InputAttributes" type="@Type" class="grow" name="@NameAttribute"
value="@CurrentValueAsString" @oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)" />
@Suffix
</label>
}
@code {
[Parameter] public string Type { get; set; } = "text";
[Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; }
[Parameter] public bool Ghost { get; set; }
/// <summary>Update the bound value on every keystroke instead of on change.</summary>
[Parameter] public bool Immediate { get; set; }
[Parameter] public RenderFragment Prefix { get; set; }
[Parameter] public RenderFragment Suffix { get; set; }
/// <summary>Adds daisyUI's validator class: error/success colors and a following DValidatorHint.</summary>
[Parameter] public bool Validator { get; set; }
string CssClassFull => Cx("input", IsInvalid ? "input-error" : ColorClass, SizeClass, Ghost ? "input-ghost" : null,
Validator ? "validator" : null, CssClass);
string ColorClass => Color switch
{
DColor.Neutral => "input-neutral",
DColor.Primary => "input-primary",
DColor.Secondary => "input-secondary",
DColor.Accent => "input-accent",
DColor.Info => "input-info",
DColor.Success => "input-success",
DColor.Warning => "input-warning",
DColor.Error => "input-error",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "input-xs",
DSize.Sm => "input-sm",
DSize.Lg => "input-lg",
DSize.Xl => "input-xl",
_ => null
};
void OnInput(ChangeEventArgs e)
{
if (Immediate)
CurrentValueAsString = e.Value?.ToString();
}
void OnChange(ChangeEventArgs e) => CurrentValueAsString = e.Value?.ToString();
}
+22
View File
@@ -0,0 +1,22 @@
@inherits DComponentBase
@* daisyUI Label: https://daisyui.com/components/label/ . By default a span.label holding ChildContent, for a DInput or
DSelect Prefix/Suffix or a fieldset. Floating renders label.floating-label around the field in ChildContent, with
Text as the label that floats above it once the field has focus or a value. *@
@if (Floating)
{
<label @attributes="AdditionalAttributes" class="@Cx("floating-label", Class)">
@ChildContent
<span>@Text</span>
</label>
}
else
{
<span @attributes="AdditionalAttributes" class="@Cx("label", Class)">@(Text)@ChildContent</span>
}
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public string Text { get; set; }
[Parameter] public bool Floating { get; set; }
}
+58
View File
@@ -0,0 +1,58 @@
@inherits DInputBase<string>
@* daisyUI OTP: https://daisyui.com/components/otp/ . The otp class is on the label; Length (4 to 6) sets the number of
digit boxes, maxlength and pattern together. required is on by default because daisyUI styles the complete code
through :valid; required="false" removes it. *@
<label class="@CssClassFull">
@for (var i = 0; i < Digits; i++)
{
<span></span>
}
<input type="text" autocomplete="one-time-code" inputmode="numeric" required @attributes="InputAttributes"
maxlength="@Digits" pattern="@($"[0-9]{{{Digits}}}")" name="@NameAttribute" value="@CurrentValueAsString"
@oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)" />
</label>
@code {
[Parameter] public int Length { get; set; } = 6;
[Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; }
/// <summary>Joins the digit boxes into one field (otp-joined).</summary>
[Parameter] public bool Joined { get; set; }
/// <summary>Update the bound value on every keystroke instead of on change.</summary>
[Parameter] public bool Immediate { get; set; }
int Digits => Math.Clamp(Length, 4, 6);
string CssClassFull => Cx("otp", IsInvalid ? "otp-error" : ColorClass, SizeClass, Joined ? "otp-joined" : null, CssClass);
string ColorClass => Color switch
{
DColor.Neutral => "otp-neutral",
DColor.Primary => "otp-primary",
DColor.Secondary => "otp-secondary",
DColor.Accent => "otp-accent",
DColor.Info => "otp-info",
DColor.Success => "otp-success",
DColor.Warning => "otp-warning",
DColor.Error => "otp-error",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "otp-xs",
DSize.Sm => "otp-sm",
DSize.Lg => "otp-lg",
DSize.Xl => "otp-xl",
_ => null
};
void OnInput(ChangeEventArgs e)
{
if (Immediate)
CurrentValueAsString = e.Value?.ToString();
}
void OnChange(ChangeEventArgs e) => CurrentValueAsString = e.Value?.ToString();
}
+60
View File
@@ -0,0 +1,60 @@
@typeparam TValue
@inherits DComponentBase
@* One radio of a DRadioGroup: checked while the group's value equals Value; choosing it sets the group's value. With
LabelText it sits in a label.label followed by the text. *@
@if (LabelText is null)
{
@Radio
}
else
{
<label class="label">
@Radio
@LabelText
</label>
}
@code {
[CascadingParameter] DRadioGroup<TValue> Group { get; set; }
[Parameter] public TValue Value { get; set; }
[Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; }
[Parameter] public bool Disabled { get; set; }
[Parameter] public string LabelText { get; set; }
RenderFragment Radio =>
@<input @attributes="AdditionalAttributes" type="radio" class="@CssClass" name="@Group.GroupName"
checked="@Group.IsSelected(Value)" disabled="@Disabled" @onchange="() => Group.Select(Value)"
aria-invalid="@(Group.GroupInvalid ? "true" : null)" />;
string CssClass => Cx("radio", Group.GroupInvalid ? "radio-error" : ColorClass, SizeClass, Class);
string ColorClass => Color switch
{
DColor.Neutral => "radio-neutral",
DColor.Primary => "radio-primary",
DColor.Secondary => "radio-secondary",
DColor.Accent => "radio-accent",
DColor.Info => "radio-info",
DColor.Success => "radio-success",
DColor.Warning => "radio-warning",
DColor.Error => "radio-error",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "radio-xs",
DSize.Sm => "radio-sm",
DSize.Lg => "radio-lg",
DSize.Xl => "radio-xl",
_ => null
};
protected override void OnInitialized()
{
if (Group is null)
throw new InvalidOperationException("DRadio must be inside a DRadioGroup of the same TValue.");
}
}
@@ -0,0 +1,33 @@
@typeparam TValue
@inherits DInputBase<TValue>
@attribute [CascadingTypeParameter(nameof(TValue))]
@* daisyUI Radio: https://daisyui.com/components/radio/ . The group holds the bound value and cascades itself (not
fixed, so every DRadio re-renders with it); its radios share one name, the name attribute or a per-instance id.
It adds a div role="radiogroup" only when given attributes (a class, an aria-label). *@
<CascadingValue Value="this">
@if (AdditionalAttributes is null || AdditionalAttributes.Count == 0)
{
@ChildContent
}
else
{
<div @attributes="InputAttributes" role="radiogroup" class="@CssClass">
@ChildContent
</div>
}
</CascadingValue>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
internal string GroupName => string.IsNullOrEmpty(NameAttributeValue) ? uid : NameAttributeValue;
internal bool GroupInvalid => IsInvalid;
internal bool IsSelected(TValue value) => EqualityComparer<TValue>.Default.Equals(CurrentValue, value);
internal void Select(TValue value) => CurrentValue = value;
}
+54
View File
@@ -0,0 +1,54 @@
@typeparam TValue
@inherits DInputBase<TValue>
@* daisyUI Range: https://daisyui.com/components/range/ . TValue is numeric; Min and Max are always rendered, as daisyUI
requires. *@
<input @attributes="InputAttributes" type="range" class="@CssClassFull" name="@NameAttribute" min="@Format(Min)"
max="@Format(Max)" step="@(Step is null ? null : Format(Step.Value))" value="@CurrentValueAsString"
@oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)" />
@code {
[Parameter] public double Min { get; set; }
[Parameter] public double Max { get; set; } = 100;
[Parameter] public double? Step { get; set; }
[Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; }
[Parameter] public DDirection Direction { get; set; }
/// <summary>Update the bound value while dragging instead of on release.</summary>
[Parameter] public bool Immediate { get; set; }
string CssClassFull => Cx("range", IsInvalid ? "range-error" : ColorClass, SizeClass,
Direction == DDirection.Vertical ? "range-vertical" : null, CssClass);
string ColorClass => Color switch
{
DColor.Neutral => "range-neutral",
DColor.Primary => "range-primary",
DColor.Secondary => "range-secondary",
DColor.Accent => "range-accent",
DColor.Info => "range-info",
DColor.Success => "range-success",
DColor.Warning => "range-warning",
DColor.Error => "range-error",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "range-xs",
DSize.Sm => "range-sm",
DSize.Lg => "range-lg",
DSize.Xl => "range-xl",
_ => null
};
static string Format(double value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture);
void OnInput(ChangeEventArgs e)
{
if (Immediate)
CurrentValueAsString = e.Value?.ToString();
}
void OnChange(ChangeEventArgs e) => CurrentValueAsString = e.Value?.ToString();
}
+85
View File
@@ -0,0 +1,85 @@
@inherits DInputBase<int>
@* daisyUI Rating: https://daisyui.com/components/rating/ . A radio per item, sharing a per-instance name; Value is the
number of the checked item, 0 for none. With Half every item is two half radios and Value counts halves (3 = 1.5
stars). Static renders the read-only <div> version, marking the current item with aria-current. *@
<div @attributes="InputAttributes" class="@CssClassFull">
@if (Static)
{
for (var i = 1; i <= Items; i++)
{
<div class="@ItemCss(i)" aria-label="@ItemText(i)" aria-current="@(i == CurrentValue ? "true" : null)"></div>
}
}
else
{
if (Clearable)
{
<input type="radio" name="@GroupName" class="rating-hidden" aria-label="@ClearText" checked="@(CurrentValue == 0)"
disabled="@Disabled" @onchange="() => CurrentValue = 0" />
}
for (var i = 1; i <= Items; i++)
{
var item = i;
<input type="radio" name="@GroupName" class="@ItemCss(item)" aria-label="@ItemText(item)" checked="@(item == CurrentValue)"
disabled="@Disabled" @onchange="() => CurrentValue = item" aria-invalid="@(IsInvalid ? "true" : null)" />
}
}
</div>
@code {
[Parameter] public int Count { get; set; } = 5;
[Parameter] public DRatingMask Mask { get; set; }
[Parameter] public bool Half { get; set; }
[Parameter] public DSize Size { get; set; }
/// <summary>Adds the hidden first radio (rating-hidden) that clears the rating to 0.</summary>
[Parameter] public bool Clearable { get; set; }
/// <summary>Read-only rating: <div> items instead of radios.</summary>
[Parameter] public bool Static { get; set; }
[Parameter] public bool Disabled { get; set; }
/// <summary>Classes for every item, e.g. a color: bg-orange-400.</summary>
[Parameter] public string ItemClass { get; set; }
/// <summary>The unit in each item's aria-label ("2 star").</summary>
[Parameter] public string UnitText { get; set; } = "star";
[Parameter] public string ClearText { get; set; } = "clear";
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
string GroupName => string.IsNullOrEmpty(NameAttributeValue) ? uid : NameAttributeValue;
int Items => Half ? Count * 2 : Count;
string CssClassFull => Cx("rating", Half ? "rating-half" : null, SizeClass, CssClass);
string ItemCss(int item) => Cx("mask", MaskClass, Half ? (item % 2 == 1 ? "mask-half-1" : "mask-half-2") : null, ItemClass);
string ItemText(int item) =>
$"{(Half ? item / 2.0 : item).ToString(System.Globalization.CultureInfo.InvariantCulture)} {UnitText}";
string MaskClass => Mask switch
{
DRatingMask.Star2 => "mask-star-2",
DRatingMask.Heart => "mask-heart",
DRatingMask.Squircle => "mask-squircle",
DRatingMask.Hexagon => "mask-hexagon",
DRatingMask.Hexagon2 => "mask-hexagon-2",
DRatingMask.Decagon => "mask-decagon",
DRatingMask.Pentagon => "mask-pentagon",
DRatingMask.Diamond => "mask-diamond",
DRatingMask.Circle => "mask-circle",
DRatingMask.Triangle => "mask-triangle",
DRatingMask.Triangle2 => "mask-triangle-2",
DRatingMask.Triangle3 => "mask-triangle-3",
DRatingMask.Triangle4 => "mask-triangle-4",
_ => "mask-star"
};
string SizeClass => Size switch
{
DSize.Xs => "rating-xs",
DSize.Sm => "rating-sm",
DSize.Lg => "rating-lg",
DSize.Xl => "rating-xl",
_ => null
};
}
@@ -0,0 +1,20 @@
namespace decePubClient.Components.Daisy.DataInput;
/// <summary>The mask shape of a DRating's items. A rating always needs one, so the default Star is mask-star.</summary>
public enum DRatingMask
{
Star,
Star2,
Heart,
Squircle,
Hexagon,
Hexagon2,
Decagon,
Pentagon,
Diamond,
Circle,
Triangle,
Triangle2,
Triangle3,
Triangle4
}
+59
View File
@@ -0,0 +1,59 @@
@typeparam TValue
@inherits DInputBase<TValue>
@* daisyUI Select: https://daisyui.com/components/select/ . The options are ChildContent (<option value="...">);
enums bind by name. With Prefix (an icon or a span.label) the select class moves to a wrapping <label>. *@
@if (Prefix is null)
{
<select @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
@onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)">
@ChildContent
</select>
}
else
{
<label class="@CssClassFull">
@Prefix
<select @attributes="InputAttributes" name="@NameAttribute" value="@CurrentValueAsString" @onchange="OnChange"
aria-invalid="@(IsInvalid ? "true" : null)">
@ChildContent
</select>
</label>
}
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public RenderFragment Prefix { get; set; }
[Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; }
[Parameter] public bool Ghost { get; set; }
/// <summary>Adds daisyUI's validator class: error/success colors and a following DValidatorHint.</summary>
[Parameter] public bool Validator { get; set; }
string CssClassFull => Cx("select", IsInvalid ? "select-error" : ColorClass, SizeClass, Ghost ? "select-ghost" : null,
Validator ? "validator" : null, CssClass);
string ColorClass => Color switch
{
DColor.Neutral => "select-neutral",
DColor.Primary => "select-primary",
DColor.Secondary => "select-secondary",
DColor.Accent => "select-accent",
DColor.Info => "select-info",
DColor.Success => "select-success",
DColor.Warning => "select-warning",
DColor.Error => "select-error",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "select-xs",
DSize.Sm => "select-sm",
DSize.Lg => "select-lg",
DSize.Xl => "select-xl",
_ => null
};
void OnChange(ChangeEventArgs e) => CurrentValueAsString = e.Value?.ToString();
}
@@ -0,0 +1,53 @@
@typeparam TValue
@inherits DInputBase<TValue>
@* daisyUI Textarea: https://daisyui.com/components/textarea/ . Blazor sets the textarea's value property, so the bound
value is the content; rows, placeholder and the like pass through. *@
<textarea @ref="Element" @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
@oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)"></textarea>
@code {
/// <summary>The textarea itself, for focusing it.</summary>
public ElementReference Element { get; private set; }
[Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; }
[Parameter] public bool Ghost { get; set; }
/// <summary>Update the bound value on every keystroke instead of on change.</summary>
[Parameter] public bool Immediate { get; set; }
/// <summary>Adds daisyUI's validator class: error/success colors and a following DValidatorHint.</summary>
[Parameter] public bool Validator { get; set; }
string CssClassFull => Cx("textarea", IsInvalid ? "textarea-error" : ColorClass, SizeClass, Ghost ? "textarea-ghost" : null,
Validator ? "validator" : null, CssClass);
string ColorClass => Color switch
{
DColor.Neutral => "textarea-neutral",
DColor.Primary => "textarea-primary",
DColor.Secondary => "textarea-secondary",
DColor.Accent => "textarea-accent",
DColor.Info => "textarea-info",
DColor.Success => "textarea-success",
DColor.Warning => "textarea-warning",
DColor.Error => "textarea-error",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "textarea-xs",
DSize.Sm => "textarea-sm",
DSize.Lg => "textarea-lg",
DSize.Xl => "textarea-xl",
_ => null
};
void OnInput(ChangeEventArgs e)
{
if (Immediate)
CurrentValueAsString = e.Value?.ToString();
}
void OnChange(ChangeEventArgs e) => CurrentValueAsString = e.Value?.ToString();
}
+101
View File
@@ -0,0 +1,101 @@
@inherits DInputBase<bool>
@* daisyUI Toggle: https://daisyui.com/components/toggle/ . With CheckedIcon/UncheckedIcon the toggle class moves to a
wrapper around the checkbox and the two icons (each must be a single element: daisyUI shows the wrapper's 2nd child
while unchecked and its 3rd while checked). daisyUI colors such a wrapper only through aria-checked, which a label
may not carry, so its color is a has-checked: variable instead. With LabelText all of it sits in a label.label. *@
@if (!HasIcons && LabelText is null)
{
@Checkbox(Cx("toggle", IsInvalid ? "toggle-error" : ColorClass, SizeClass, CssClass))
}
else if (!HasIcons)
{
<label class="label">
@Checkbox(Cx("toggle", IsInvalid ? "toggle-error" : ColorClass, SizeClass, CssClass))
@LabelText
</label>
}
else if (LabelText is null)
{
<label class="@WrapperClass">@IconParts</label>
}
else
{
<label class="label">
<span class="@WrapperClass">@IconParts</span>
@LabelText
</label>
}
@code {
[Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; }
[Parameter] public string LabelText { get; set; }
[Parameter] public RenderFragment CheckedIcon { get; set; }
[Parameter] public RenderFragment UncheckedIcon { get; set; }
bool HasIcons => CheckedIcon is not null || UncheckedIcon is not null;
string WrapperClass => Cx("toggle", IsInvalid ? "has-checked:[--input-color:var(--color-error)]" : WrapperColorClass, SizeClass, CssClass);
RenderFragment Checkbox(string cssClass) =>
@<input @attributes="InputAttributes" type="checkbox" class="@cssClass" name="@NameAttribute" checked="@CurrentValue"
@onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)" />;
RenderFragment IconParts => @<text>
@Checkbox(null)
@if (UncheckedIcon is null)
{
<span></span>
}
else
{
@UncheckedIcon
}
@if (CheckedIcon is null)
{
<span></span>
}
else
{
@CheckedIcon
}
</text>;
string ColorClass => Color switch
{
DColor.Neutral => "toggle-neutral",
DColor.Primary => "toggle-primary",
DColor.Secondary => "toggle-secondary",
DColor.Accent => "toggle-accent",
DColor.Info => "toggle-info",
DColor.Success => "toggle-success",
DColor.Warning => "toggle-warning",
DColor.Error => "toggle-error",
_ => null
};
string WrapperColorClass => Color switch
{
DColor.Neutral => "has-checked:[--input-color:var(--color-neutral)]",
DColor.Primary => "has-checked:[--input-color:var(--color-primary)]",
DColor.Secondary => "has-checked:[--input-color:var(--color-secondary)]",
DColor.Accent => "has-checked:[--input-color:var(--color-accent)]",
DColor.Info => "has-checked:[--input-color:var(--color-info)]",
DColor.Success => "has-checked:[--input-color:var(--color-success)]",
DColor.Warning => "has-checked:[--input-color:var(--color-warning)]",
DColor.Error => "has-checked:[--input-color:var(--color-error)]",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "toggle-xs",
DSize.Sm => "toggle-sm",
DSize.Lg => "toggle-lg",
DSize.Xl => "toggle-xl",
_ => null
};
void OnChange(ChangeEventArgs e) => CurrentValue = e.Value is true;
}
@@ -0,0 +1,12 @@
@inherits DComponentBase
@* daisyUI Validator: https://daisyui.com/components/validator/ . Place it right after a field with Validator set (a
following sibling): it shows, in the error color, while that field is :user-invalid or aria-invalid (failed
EditForm validation). *@
<p @attributes="AdditionalAttributes" class="@Cx("validator-hint", Compact ? "hidden" : null, Class)">@ChildContent</p>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
/// <summary>Takes no space while the field is valid (daisyUI's validator-hint hidden) instead of keeping its line.</summary>
[Parameter] public bool Compact { get; set; }
}
+44
View File
@@ -0,0 +1,44 @@
@inherits DComponentBase
@* daisyUI Alert: https://daisyui.com/components/alert/ . The alert is a grid of up to three cells: Icon, ChildContent
(wrapped in one <div>, so a heading plus text stays one cell) and Actions. role="alert" comes before the splatted
attributes, so a consumer can pass role="status" for a non-urgent message. *@
<div role="alert" @attributes="AdditionalAttributes" class="@CssClass">
@Icon
<div>@ChildContent</div>
@if (Actions is not null)
{
<div class="flex gap-2">@Actions</div>
}
</div>
@code {
[Parameter] public RenderFragment Icon { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public RenderFragment Actions { get; set; }
[Parameter] public DAlertColor Color { get; set; }
[Parameter] public DAlertVariant Variant { get; set; }
[Parameter] public DDirection Direction { get; set; }
/// <summary>Vertical on small screens, horizontal from sm up (alert-vertical sm:alert-horizontal).</summary>
[Parameter] public bool Responsive { get; set; }
string CssClass => Cx("alert", ColorClass, VariantClass,
Direction == DDirection.Vertical || Responsive ? "alert-vertical" : null, Responsive ? "sm:alert-horizontal" : null, Class);
string ColorClass => Color switch
{
DAlertColor.Info => "alert-info",
DAlertColor.Success => "alert-success",
DAlertColor.Warning => "alert-warning",
DAlertColor.Error => "alert-error",
_ => null
};
string VariantClass => Variant switch
{
DAlertVariant.Outline => "alert-outline",
DAlertVariant.Dash => "alert-dash",
DAlertVariant.Soft => "alert-soft",
_ => null
};
}
+19
View File
@@ -0,0 +1,19 @@
namespace decePubClient.Components.Daisy.Feedback;
/// <summary>daisyUI's alert colours; there is no neutral/primary/secondary/accent alert, hence not DColor.</summary>
public enum DAlertColor
{
Default,
Info,
Success,
Warning,
Error
}
public enum DAlertVariant
{
Default,
Outline,
Dash,
Soft
}
+47
View File
@@ -0,0 +1,47 @@
@inherits DComponentBase
@* daisyUI Loading: https://daisyui.com/components/loading/ . It is drawn in currentColor, so Color maps to text-*
classes. role="status" and aria-label come before the splatted attributes, so a consumer can override them. *@
<span role="status" aria-label="@LabelText" @attributes="AdditionalAttributes" class="@CssClass"></span>
@code {
[Parameter] public DLoadingVariant Variant { get; set; }
[Parameter] public DSize Size { get; set; }
[Parameter] public DColor Color { get; set; }
/// <summary>What screen readers announce (aria-label).</summary>
[Parameter] public string LabelText { get; set; } = "Loading";
string CssClass => Cx("loading", VariantClass, SizeClass, ColorClass, Class);
string VariantClass => Variant switch
{
DLoadingVariant.Dots => "loading-dots",
DLoadingVariant.Ring => "loading-ring",
DLoadingVariant.Ball => "loading-ball",
DLoadingVariant.Bars => "loading-bars",
DLoadingVariant.Infinity => "loading-infinity",
_ => null
};
string SizeClass => Size switch
{
DSize.Xs => "loading-xs",
DSize.Sm => "loading-sm",
DSize.Lg => "loading-lg",
DSize.Xl => "loading-xl",
_ => null
};
string ColorClass => Color switch
{
DColor.Neutral => "text-neutral",
DColor.Primary => "text-primary",
DColor.Secondary => "text-secondary",
DColor.Accent => "text-accent",
DColor.Info => "text-info",
DColor.Success => "text-success",
DColor.Warning => "text-warning",
DColor.Error => "text-error",
_ => null
};
}
@@ -0,0 +1,12 @@
namespace decePubClient.Components.Daisy.Feedback;
/// <summary>The animation; Spinner is what a bare loading class draws.</summary>
public enum DLoadingVariant
{
Spinner,
Dots,
Ring,
Ball,
Bars,
Infinity
}
+30
View File
@@ -0,0 +1,30 @@
@using System.Globalization
@inherits DComponentBase
@* daisyUI Progress: https://daisyui.com/components/progress/ . A null Value leaves out the value attribute, which makes
the bar indeterminate (animated). Numbers are written with the invariant culture. The width comes from class
(it is 100% by default). *@
<progress @attributes="AdditionalAttributes" class="@CssClass" value="@(Value?.ToString(CultureInfo.InvariantCulture))"
max="@(Max.ToString(CultureInfo.InvariantCulture))"></progress>
@code {
/// <summary>The progress, from 0 to Max; null shows an indeterminate bar.</summary>
[Parameter] public double? Value { get; set; }
[Parameter] public double Max { get; set; } = 100;
[Parameter] public DColor Color { get; set; }
string CssClass => Cx("progress", ColorClass, Class);
string ColorClass => Color switch
{
DColor.Neutral => "progress-neutral",
DColor.Primary => "progress-primary",
DColor.Secondary => "progress-secondary",
DColor.Accent => "progress-accent",
DColor.Info => "progress-info",
DColor.Success => "progress-success",
DColor.Warning => "progress-warning",
DColor.Error => "progress-error",
_ => null
};
}
@@ -0,0 +1,63 @@
@using System.Globalization
@inherits DComponentBase
@* daisyUI Radial progress: https://daisyui.com/components/radial-progress/ . Value/Max becomes a 0-100 percentage in
the --value style variable and aria-valuenow, and the default text; Diameter and Thickness set --size and
--thickness. A consumer's style is appended to those. It is drawn in currentColor, so Color maps to text-*. *@
<div @attributes="AdditionalAttributes" class="@CssClass" style="@StyleValue" role="progressbar"
aria-valuenow="@PercentText" aria-valuemin="0" aria-valuemax="100">
@if (ChildContent is null)
{
@($"{Math.Round(Percent)}%")
}
else
{
@ChildContent
}
</div>
@code {
[Parameter] public double Value { get; set; }
[Parameter] public double Max { get; set; } = 100;
[Parameter] public DColor Color { get; set; }
/// <summary>A CSS length for --size (daisyUI's default is 5rem).</summary>
[Parameter] public string Diameter { get; set; }
/// <summary>A CSS length for --thickness (daisyUI's default is a tenth of the size).</summary>
[Parameter] public string Thickness { get; set; }
/// <summary>Replaces the default "NN%" text.</summary>
[Parameter] public RenderFragment ChildContent { get; set; }
double Percent => Max > 0 ? Math.Clamp(Value / Max * 100, 0, 100) : 0;
string PercentText => Math.Round(Percent, 2).ToString(CultureInfo.InvariantCulture);
string StyleValue
{
get
{
var style = $"--value:{PercentText};";
if (!string.IsNullOrEmpty(Diameter))
style += $" --size:{Diameter};";
if (!string.IsNullOrEmpty(Thickness))
style += $" --thickness:{Thickness};";
if (AdditionalAttributes != null && AdditionalAttributes.TryGetValue("style", out var extra) && extra is not null)
style += " " + extra;
return style;
}
}
string CssClass => Cx("radial-progress", ColorClass, Class);
string ColorClass => Color switch
{
DColor.Neutral => "text-neutral",
DColor.Primary => "text-primary",
DColor.Secondary => "text-secondary",
DColor.Accent => "text-accent",
DColor.Info => "text-info",
DColor.Success => "text-success",
DColor.Warning => "text-warning",
DColor.Error => "text-error",
_ => null
};
}
+13
View File
@@ -0,0 +1,13 @@
@inherits DComponentBase
@* daisyUI Skeleton: https://daisyui.com/components/skeleton/ . Size it with h-* / w-* classes; IsText (skeleton-text)
animates ChildContent's text instead of drawing a block. *@
<div @attributes="AdditionalAttributes" class="@Cx("skeleton", IsText ? "skeleton-text" : null, Class)">
@ChildContent
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
/// <summary>Shimmer the text in ChildContent (skeleton-text) rather than a filled block.</summary>
[Parameter] public bool IsText { get; set; }
}
+29
View File
@@ -0,0 +1,29 @@
@inherits DComponentBase
@* daisyUI Toast: https://daisyui.com/components/toast/ . A fixed-position stack (ChildContent, usually DAlerts) in a
corner or edge of the viewport; End/Bottom is daisyUI's default and adds no class. *@
<div @attributes="AdditionalAttributes" class="@CssClass">
@ChildContent
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DToastHorizontal Horizontal { get; set; }
[Parameter] public DToastVertical Vertical { get; set; }
string CssClass => Cx("toast", HorizontalClass, VerticalClass, Class);
string HorizontalClass => Horizontal switch
{
DToastHorizontal.Start => "toast-start",
DToastHorizontal.Center => "toast-center",
_ => null
};
string VerticalClass => Vertical switch
{
DToastVertical.Top => "toast-top",
DToastVertical.Middle => "toast-middle",
_ => null
};
}
+17
View File
@@ -0,0 +1,17 @@
namespace decePubClient.Components.Daisy.Feedback;
/// <summary>Where the toast sits horizontally; End is daisyUI's default.</summary>
public enum DToastHorizontal
{
End,
Start,
Center
}
/// <summary>Where the toast sits vertically; Bottom is daisyUI's default.</summary>
public enum DToastVertical
{
Bottom,
Top,
Middle
}
+51
View File
@@ -0,0 +1,51 @@
@inherits DComponentBase
@* daisyUI Tooltip: https://daisyui.com/components/tooltip/ . Shows on hover or keyboard focus of ChildContent: Tip
sets data-tip, or TipContent renders a tooltip-content part for markup. IsOpen keeps it shown (tooltip-open). *@
<div @attributes="AdditionalAttributes" class="@CssClass" data-tip="@Tip">
@if (TipContent is not null)
{
<div class="tooltip-content">@TipContent</div>
}
@ChildContent
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public string Tip { get; set; }
[Parameter] public RenderFragment TipContent { get; set; }
[Parameter] public DTooltipPlacement Placement { get; set; }
[Parameter] public DTooltipAlign Align { get; set; }
/// <summary>Neutral is the default look; daisyUI has no tooltip-neutral.</summary>
[Parameter] public DColor Color { get; set; }
[Parameter] public bool IsOpen { get; set; }
string CssClass => Cx("tooltip", PlacementClass, AlignClass, ColorClass, IsOpen ? "tooltip-open" : null, Class);
string PlacementClass => Placement switch
{
DTooltipPlacement.Bottom => "tooltip-bottom",
DTooltipPlacement.Left => "tooltip-left",
DTooltipPlacement.Right => "tooltip-right",
_ => null
};
string AlignClass => Align switch
{
DTooltipAlign.Start => "tooltip-start",
DTooltipAlign.End => "tooltip-end",
_ => null
};
string ColorClass => Color switch
{
DColor.Primary => "tooltip-primary",
DColor.Secondary => "tooltip-secondary",
DColor.Accent => "tooltip-accent",
DColor.Info => "tooltip-info",
DColor.Success => "tooltip-success",
DColor.Warning => "tooltip-warning",
DColor.Error => "tooltip-error",
_ => null
};
}
@@ -0,0 +1,18 @@
namespace decePubClient.Components.Daisy.Feedback;
/// <summary>Which side of the element the tooltip shows on; Top is daisyUI's default.</summary>
public enum DTooltipPlacement
{
Top,
Bottom,
Left,
Right
}
/// <summary>How the tooltip lines up with the element along that side; Center is daisyUI's default.</summary>
public enum DTooltipAlign
{
Center,
Start,
End
}
+11
View File
@@ -0,0 +1,11 @@
namespace decePubClient.Components.Daisy;
/// <summary>
/// Where an open overlay (a dialog, a menu, a sheet) says it is open, so that the phone's Back button closes it instead
/// of leaving the page: Open registers how to close it and returns what ends the registration once it is closed. The
/// app cascades one (OverlayService, from MainLayout); a component without one simply doesn't register.
/// </summary>
public interface IDOverlayHost
{
IDisposable Open(Func<Task> close);
}
+40
View File
@@ -0,0 +1,40 @@
@inherits DComponentBase
@* daisyUI Divider: https://daisyui.com/components/divider/ . Leave ChildContent empty for a blank line. *@
<div @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DColor Color { get; set; }
[Parameter] public DDividerDirection Direction { get; set; }
[Parameter] public DDividerPlacement Placement { get; set; }
string CssClass => Cx("divider", DirectionClass, ColorClass, PlacementClass, Class);
string DirectionClass => Direction switch
{
DDividerDirection.Horizontal => "divider-horizontal",
DDividerDirection.Vertical => "divider-vertical",
_ => null
};
string ColorClass => Color switch
{
DColor.Neutral => "divider-neutral",
DColor.Primary => "divider-primary",
DColor.Secondary => "divider-secondary",
DColor.Accent => "divider-accent",
DColor.Info => "divider-info",
DColor.Success => "divider-success",
DColor.Warning => "divider-warning",
DColor.Error => "divider-error",
_ => null
};
string PlacementClass => Placement switch
{
DDividerPlacement.Start => "divider-start",
DDividerPlacement.End => "divider-end",
_ => null
};
}
+21
View File
@@ -0,0 +1,21 @@
namespace decePubClient.Components.Daisy.Layout;
/// <summary>
/// daisyUI's names, which describe the content around the divider: <see cref="Horizontal"/> (divider-horizontal) is a
/// vertical line between items side by side; <see cref="Vertical"/> (divider-vertical) is a horizontal line between
/// stacked items, as is the default.
/// </summary>
public enum DDividerDirection
{
Default,
Horizontal,
Vertical
}
/// <summary>Where the text sits along the line; Center is daisyUI's default.</summary>
public enum DDividerPlacement
{
Center,
Start,
End
}
+60
View File
@@ -0,0 +1,60 @@
@inherits DComponentBase
@* daisyUI Drawer: https://daisyui.com/components/drawer/ . daisyUI's hidden drawer-toggle checkbox is bound to IsOpen
(@bind-IsOpen); the overlay label toggles it natively, Escape closes it, and any
<label for="{ToggleId}" class="btn drawer-button"> in the content toggles it too. Put the navbar and footer in
ChildContent. *@
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
<input id="@toggleId" type="checkbox" class="drawer-toggle" checked="@IsOpen" @onchange="OnChange" />
<div class="@Cx("drawer-content", ContentClass)">
@ChildContent
</div>
<div class="@Cx("drawer-side", SideClass)">
<label for="@toggleId" aria-label="@CloseText" class="drawer-overlay"></label>
@Side
</div>
</div>
@code {
/// <summary>The page content, beside the side.</summary>
[Parameter] public RenderFragment ChildContent { get; set; }
/// <summary>The sidebar, e.g. &lt;ul class="menu bg-base-200 min-h-full w-80 p-4"&gt;.</summary>
[Parameter] public RenderFragment Side { get; set; }
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
/// <summary>Put the side on the right (drawer-end).</summary>
[Parameter] public bool End { get; set; }
[Parameter] public DDrawerOpenFrom OpenFrom { get; set; }
/// <summary>The checkbox id, for drawer-button labels; defaults to a per-instance id.</summary>
[Parameter] public string ToggleId { get; set; }
/// <summary>Accessible name of the overlay, which closes the side.</summary>
[Parameter] public string CloseText { get; set; } = "close sidebar";
[Parameter] public string ContentClass { get; set; }
[Parameter] public string SideClass { get; set; }
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
string toggleId => ToggleId ?? uid;
string CssClass => Cx("drawer", End ? "drawer-end" : null, OpenFromClass, Class);
string OpenFromClass => OpenFrom switch
{
DDrawerOpenFrom.Sm => "sm:drawer-open",
DDrawerOpenFrom.Md => "md:drawer-open",
DDrawerOpenFrom.Lg => "lg:drawer-open",
DDrawerOpenFrom.Xl => "xl:drawer-open",
DDrawerOpenFrom.Always => "drawer-open",
_ => null
};
Task OnChange(ChangeEventArgs e) => SetOpen(e.Value is true);
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false) : Task.CompletedTask;
async Task SetOpen(bool isOpen)
{
IsOpen = isOpen;
await IsOpenChanged.InvokeAsync(isOpen);
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace decePubClient.Components.Daisy.Layout;
/// <summary>From which breakpoint the side stays open beside the content (drawer-open), whatever IsOpen says.</summary>
public enum DDrawerOpenFrom
{
Never,
Sm,
Md,
Lg,
Xl,
Always
}
+25
View File
@@ -0,0 +1,25 @@
@inherits DComponentBase
@* daisyUI Footer: https://daisyui.com/components/footer/ . Its children are laid out on a grid; use DFooterNav (or
<nav> / <aside>) for each column. *@
<footer @attributes="AdditionalAttributes" class="@CssClass">
@ChildContent
</footer>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DFooterDirection Direction { get; set; }
/// <summary>Center the children (footer-center).</summary>
[Parameter] public bool Center { get; set; }
string CssClass => Cx("footer", DirectionClass, Center ? "footer-center" : null, Class);
string DirectionClass => Direction switch
{
DFooterDirection.Horizontal => "footer-horizontal",
DFooterDirection.HorizontalFromSm => "sm:footer-horizontal",
DFooterDirection.HorizontalFromMd => "md:footer-horizontal",
DFooterDirection.HorizontalFromLg => "lg:footer-horizontal",
_ => null
};
}
+12
View File
@@ -0,0 +1,12 @@
namespace decePubClient.Components.Daisy.Layout;
/// <summary>How the footer lays out its children: in a column (daisyUI's default), in a row, or in a row from a
/// breakpoint up (the responsive sm:footer-horizontal that daisyUI recommends).</summary>
public enum DFooterDirection
{
Vertical,
Horizontal,
HorizontalFromSm,
HorizontalFromMd,
HorizontalFromLg
}
+15
View File
@@ -0,0 +1,15 @@
@inherits DComponentBase
@* One column of a DFooter: a <nav> with an optional footer-title heading above its links. *@
<nav @attributes="AdditionalAttributes" class="@Class">
@if (Heading is not null)
{
<h6 class="footer-title">@Heading</h6>
}
@ChildContent
</nav>
@code {
[Parameter] public RenderFragment Heading { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
}
+36
View File
@@ -0,0 +1,36 @@
@inherits DComponentBase
@* daisyUI Hero: https://daisyui.com/components/hero/ . ChildContent goes in hero-content (text, and optionally a
figure); with Overlay a hero-overlay tints the background image. *@
<div @attributes="AdditionalAttributes" class="@CssClass" style="@StyleValue">
@if (Overlay)
{
<div class="@Cx("hero-overlay", OverlayClass)"></div>
}
<div class="@Cx("hero-content", ContentClass)">
@ChildContent
</div>
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
/// <summary>URL of a background image, set as an inline background-image.</summary>
[Parameter] public string BackgroundImage { get; set; }
/// <summary>Add a hero-overlay color layer above the background image.</summary>
[Parameter] public bool Overlay { get; set; }
[Parameter] public string OverlayClass { get; set; }
/// <summary>Classes for hero-content, e.g. "text-center" or "flex-col lg:flex-row".</summary>
[Parameter] public string ContentClass { get; set; }
string CssClass => Cx("hero", Class);
// The consumer's own style attribute is kept, after the background image.
string StyleValue
{
get
{
var style = AdditionalAttributes?.GetValueOrDefault("style")?.ToString();
return BackgroundImage is null ? style : Cx($"background-image: url('{BackgroundImage}');", style);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
@inherits DComponentBase
@* daisyUI Indicator: https://daisyui.com/components/indicator/ . Put the DIndicatorItem children first and the main
element after them, as daisyUI requires. *@
<div @attributes="AdditionalAttributes" class="@CssClass">
@ChildContent
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
string CssClass => Cx("indicator", Class);
}
@@ -0,0 +1,17 @@
namespace decePubClient.Components.Daisy.Layout;
/// <summary>Horizontal position of an indicator item; End is daisyUI's default.</summary>
public enum DIndicatorHorizontal
{
End,
Start,
Center
}
/// <summary>Vertical position of an indicator item; Top is daisyUI's default.</summary>
public enum DIndicatorVertical
{
Top,
Middle,
Bottom
}
Loaded 100 of 409 files, more files were not shown because too many files have changed in this diff. Show more