54 Commits
Author SHA1 Message Date
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
thepraandClaude Opus 5.5 b9dc865053 net10, pointed at PrivaPub, and deployed as a static site to decepub.thepra.dev
Build / Build (push) Successful in 18s
Deploy / decepub.thepra.dev (push) Successful in 1m11s
The project referenced ../SocialPub/SocialPub/SocialPub.ClientModels, a path
that has not existed since the server became PrivaPub; it references
../SocialPub/PrivaPub.ClientModels now, and CI clones SocialPub beside it.

Retargeted to net10.0 with the Microsoft packages at 10.0.9.
BlazorDownloadFileFast moves to 1.0.0.1 because 0.2.0 used
IJSUnmarshalledRuntime, which .NET 9 removed. The Visual Studio bundler and
web compiler packages are gone; the bundle they produced, css/style.min.css,
is committed and stays the stylesheet, and its stale .gz is deleted because
.NET's static-asset compression writes that path itself.

AppConfiguration.ApiBaseAddress (https://privapub.thepra.dev) is the API
client's base address; the template's placeholder OIDC section and the
Toolbelt.Blazor.HotKeys script tag, whose package is not referenced, are
removed. The vhost sends noindex while the feed shows the prototype's mock
posts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 08:09:45 +02:00
thepra 872d1460f8 up 2022-12-16 20:19:23 +01:00
thepra cc1805e1d4 up 2022-12-16 00:55:43 +01:00
thepra 9ef06db411 up 2022-12-15 20:03:40 +01:00
thepra f622fbcf0a up 2022-12-02 10:19:57 +01:00
366 changed files with 35343 additions and 23729 deletions

No files matched your search

+4
View File
@@ -61,3 +61,7 @@
#*.PDF diff=astextplain #*.PDF diff=astextplain
#*.rtf diff=astextplain #*.rtf 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
+33
View File
@@ -0,0 +1,33 @@
name: Build
on:
push:
branches: [master]
pull_request:
jobs:
build:
name: Build
runs-on: build
steps:
# 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:
READ_TOKEN: ${{ secrets.THEPRA_READ_TOKEN }}
run: |
rm -rf SocialPub
git -c http.extraHeader="Authorization: token $READ_TOKEN" clone -q --depth 1 \
https://git.thepra.dev/thepra/SocialPub.git SocialPub
- name: Build
run: dotnet build decePubClient/decePubClient.csproj -c Release
+82
View File
@@ -0,0 +1,82 @@
name: Deploy
on:
workflow_dispatch:
push:
tags:
- 'v*'
env:
WEB_ROOT: /var/www/decepub.thepra.dev
BACKUPS: /var/backups/decepub.thepra.dev
PUBLIC_URL: https://decepub.thepra.dev
jobs:
site:
name: decepub.thepra.dev
runs-on: build
steps:
# 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:
READ_TOKEN: ${{ secrets.THEPRA_READ_TOKEN }}
run: |
rm -rf SocialPub
git -c http.extraHeader="Authorization: token $READ_TOKEN" clone -q --depth 1 \
https://git.thepra.dev/thepra/SocialPub.git SocialPub
- name: Resolve the build identity
run: |
echo "BUILD_COMMIT=$(echo "$GITHUB_SHA" | cut -c1-8)" >> "$GITHUB_ENV"
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
- name: Publish
run: |
rm -rf "$GITHUB_WORKSPACE/publish"
dotnet publish decePubClient/decePubClient.csproj -c Release -o "$GITHUB_WORKSPACE/publish"
printf '{"commit":"%s","buildRef":"%s","builtAt":"%s"}\n' "$BUILD_COMMIT" "$BUILD_REF" "$BUILD_TIME" \
> "$GITHUB_WORKSPACE/publish/wwwroot/build.json"
- 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/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
run: |
STAMP=$(date +%Y%m%d-%H%M%S)
rsync -a "$WEB_ROOT/" "$BACKUPS/site-$STAMP/"
echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV"
ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true
- name: Sync
run: rsync -a --delete "$GITHUB_WORKSPACE/publish/wwwroot/" "$WEB_ROOT/"
- name: Verify what is being served, roll back on failure
run: |
served=$(curl -fsS "$PUBLIC_URL/build.json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))" || true)
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/some/client/route")
if [ "$served" != "$BUILD_COMMIT" ] || [ "$code" != "200" ]; then
echo "::error::served build '$served' (expected '$BUILD_COMMIT'), fallback route answered $code - rolling back"
rsync -a --delete "$SNAPSHOT/" "$WEB_ROOT/"
exit 1
fi
echo "::notice::serving $served"
+8
View File
@@ -363,3 +363,11 @@ MigrationBackup/
FodyWeavers.xsd FodyWeavers.xsd
/.idea/.idea.decePubClient/.idea /.idea/.idea.decePubClient/.idea
/.idea/config /.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/
+13 -9
View File
@@ -1,27 +1,31 @@
<CascadingState> <CascadingAuthenticationState>
<CascadingAuthenticationState> <CascadingState>
<Router AppAssembly="@typeof(App).Assembly"> <Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData"> <Found Context="routeData">
<AuthorizeRouteView DefaultLayout="@typeof(MainLayout)" RouteData="@routeData"> <AuthorizeRouteView DefaultLayout="@typeof(MainLayout)" RouteData="@routeData">
<NotAuthorized> <NotAuthorized>
@if (context.User.Identity?.IsAuthenticated != true) @if (context.User.Identity?.IsAuthenticated != true)
{ {
<RedirectToLogin/> <RedirectToLogin />
} }
else 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> </NotAuthorized>
</AuthorizeRouteView> </AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="section"/> <FocusOnNavigate RouteData="@routeData" Selector="a" />
</Found> </Found>
<NotFound> <NotFound>
<Title>Not found</Title> <Title>@Localizer["Not found"]</Title>
<LayoutView Layout="@typeof(MainLayout)"> <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> </LayoutView>
</NotFound> </NotFound>
</Router> </Router>
</CascadingAuthenticationState> </CascadingState>
</CascadingState> </CascadingAuthenticationState>
@code {
[Inject] CoalescingStringLocalizer Localizer { get; set; }
}
+514
View File
@@ -0,0 +1,514 @@
# 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`.
- **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!`.
- Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
every collapse disappears.
## 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`.
**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.
- 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`), `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`
watches the page for `img[data-blurhash]`), a picture's focal point (`object-position`), and an event's online
link, participants and status.
- **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`: its words and
warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media.
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, 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.
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
administrators the servers blocked for everyone and the federation statistics. PrivaPub has no admin list of users,
and adding an admin endpoint waits for the owner, so the Users section stays empty.
- **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, the phase and the season
in the panel's top corner. The theme changes only the atmosphere and the points.
- **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 camera, points, countries and frame rate, and its Sun (subsolar point, distance, irradiance,
the place's elevation and day length, the next equinox or solstice).
**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.
+62
View File
@@ -0,0 +1,62 @@
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(ViewReport))]
[JsonSerializable(typeof(List<ViewReport>))]
[JsonSerializable(typeof(DomainBlockForm))]
[JsonSerializable(typeof(ViewDomainBlock))]
[JsonSerializable(typeof(List<ViewDomainBlock>))]
[JsonSerializable(typeof(ViewStatisticsOverview))]
[JsonSerializable(typeof(ViewHostsPage))]
[JsonSerializable(typeof(ViewCrawler))]
public partial class ClientJsonContext : JsonSerializerContext
{
}
-106
View File
@@ -1,106 +0,0 @@
<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">@CascadingState.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">@CascadingState.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>@CascadingState.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);
}
}
+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>
<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);
}
}
+528
View File
@@ -0,0 +1,528 @@
@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
and warning change, its media, visibility and poll stay). *@
<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="relative">
<DTextarea 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 />
<span class="absolute text-xs opacity-50 right-3 bottom-2">@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" 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" 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">
@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">
@if (media.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image"))
{
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
}
else
{
<i class="text-2xl @form.MediaType.GetMediaTypeIcon()"></i>
}
<div class="flex flex-col flex-1 min-w-0 gap-1">
<p class="text-xs break-all"><b>@media.FileName</b> · @media.ContentType · @media.Size.GetFileSize(Localizer)</p>
<DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" autocomplete="off" maxlength="1500"
placeholder="@Localizer["Alternative text"]" rows="1" />
</div>
<DButton Size="DSize.Sm" class="self-start" OnClick="() => form.Media.Remove(media)" title="@Localizer["Delete"]">
<i class="ion-md-trash text-base text-error"></i>
</DButton>
</div>
}
</div>
}
@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")"
ContentClass="mt-2 flex gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
<Trigger>
<i class="@form.MessageType.GetMessageTypeIcon() text-base"></i>
</Trigger>
<ChildContent>
@foreach (var messageType in Enum.GetValues<MessageType>())
{
<DButton Size="DSize.Sm" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)" title="@Localizer[messageType.ToString()]">
<i class="@messageType.GetMessageTypeIcon() text-base"></i>
</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 && Editing 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)" />
<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] 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 + " ";
}
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;
long MaximumFileSize => form.MediaType switch
{
MediaType.Images => 16_777_216,
MediaType.Audio => 41_943_040,
_ => 99_614_720
};
string AcceptedFileTypes => form.MediaType switch
{
MediaType.Images => ".jpg,.jpeg,.png,.gif,.webp,.avif",
MediaType.Video => ".mp4,.m4v,.webm,.mov",
MediaType.Audio => ".mp3,.ogg,.oga,.opus,.wav,.flac,.m4a",
_ => default
};
// 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
};
protected override async Task OnInitializedAsync()
{
form.GroupId = GroupId;
form.MessageType = DefaultVisibility;
form.Language = DefaultLanguage;
form.QuotedStatusId = Quoting?.Id;
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;
}
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))
{
if (file.Size > MaximumFileSize)
{
fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
file.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
continue;
}
if (await ReadFile(file) is { } media)
form.Media.Add(media);
else
fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
}
}
/// <summary>Reads a picked file into memory; null when the browser can't read it (logged).</summary>
async Task<UploadMedia> ReadFile(IBrowserFile file, CancellationToken cancellationToken = default)
{
try
{
using var memoryStream = new MemoryStream();
await using var fileStream = file.OpenReadStream(MaximumFileSize, cancellationToken);
await fileStream.CopyToAsync(memoryStream, cancellationToken);
var media = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
if (file.ContentType.StartsWith("image"))
media.Base64Preview = $"data:{media.ContentType};base64,{Convert.ToBase64String(media.Blob)}";
return media;
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(Composer)}.{nameof(ReadFile)}:{file.Name}", cancellationToken);
return default;
}
}
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 (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; }
}
}
+1195 -197
View File
File diff suppressed because it is too large. Load diff
+74
View File
@@ -0,0 +1,74 @@
@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">
@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; }
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
}
+66
View File
@@ -0,0 +1,66 @@
@inherits DComponentBase
@* 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. *@
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
<div role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
@onclick="Toggle">
@Trigger
</div>
@if (IsOpen)
{
<div class="fixed inset-0 z-[998] cursor-default" aria-hidden="true" @onclick="Close"></div>
}
<div tabindex="-1" class="@Cx("dropdown-content z-[999]", ContentClass)" @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; }
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
};
Task Toggle() => SetOpen(!IsOpen);
Task Close() => SetOpen(false);
Task OnContentClick() => CloseOnContentClick ? SetOpen(false) : Task.CompletedTask;
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false) : Task.CompletedTask;
async Task SetOpen(bool isOpen)
{
IsOpen = isOpen;
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
};
}
+83
View File
@@ -0,0 +1,83 @@
@inherits DComponentBase
@* 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. *@
<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)
{
<h3 id="@uid" class="text-lg font-bold">@Heading</h3>
}
@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>Text of the invisible backdrop button, read by screen readers.</summary>
[Parameter] public string CloseText { get; set; } = "close";
[Parameter] public string BoxClass { get; set; }
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
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",
_ => null
};
protected override void OnParametersSet()
{
if (IsOpen && !wasOpen)
focusPending = true;
wasOpen = IsOpen;
}
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;
await IsOpenChanged.InvokeAsync(isOpen);
}
}
+11
View File
@@ -0,0 +1,11 @@
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
}
+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
};
}
+40
View File
@@ -0,0 +1,40 @@
@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). *@
@if (Scroll)
{
<div class="@Cx("overflow-x-auto", WrapperClass)">
<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,50 @@
@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 @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
@oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)"></textarea>
@code {
[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
}
+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
}
@@ -0,0 +1,26 @@
@inherits DComponentBase
@* An indicator-item of a DIndicator, placed on its corner or edge, e.g. with class="badge badge-primary". *@
<span @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</span>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DIndicatorHorizontal Horizontal { get; set; }
[Parameter] public DIndicatorVertical Vertical { get; set; }
string CssClass => Cx("indicator-item", HorizontalClass, VerticalClass, Class);
string HorizontalClass => Horizontal switch
{
DIndicatorHorizontal.Start => "indicator-start",
DIndicatorHorizontal.Center => "indicator-center",
_ => null
};
string VerticalClass => Vertical switch
{
DIndicatorVertical.Middle => "indicator-middle",
DIndicatorVertical.Bottom => "indicator-bottom",
_ => null
};
}
+15
View File
@@ -0,0 +1,15 @@
@inherits DComponentBase
@* daisyUI Join: https://daisyui.com/components/join/ . Groups its direct children, rounding only the outer corners;
give each of them the join-item class (e.g. <DButton class="join-item">). For a responsive group, add
class="lg:join-horizontal" to a vertical one. *@
<div @attributes="AdditionalAttributes" class="@CssClass">
@ChildContent
</div>
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DDirection Direction { get; set; }
string CssClass => Cx("join", Direction == DDirection.Vertical ? "join-vertical" : null, Class);
}
+47
View File
@@ -0,0 +1,47 @@
@inherits DComponentBase
@* daisyUI Mask: https://daisyui.com/components/mask/ . Renders an <img> when Src is set, otherwise a <div> around
ChildContent. daisyUI requires a Shape or a Half; size it with w-* and h-*. *@
@if (Src is not null)
{
<img @attributes="AdditionalAttributes" src="@Src" class="@CssClass" />
}
else
{
<div @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</div>
}
@code {
[Parameter] public string Src { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DMaskShape Shape { get; set; }
[Parameter] public DMaskHalf Half { get; set; }
string CssClass => Cx("mask", ShapeClass, HalfClass, Class);
string ShapeClass => Shape switch
{
DMaskShape.Squircle => "mask-squircle",
DMaskShape.Heart => "mask-heart",
DMaskShape.Hexagon => "mask-hexagon",
DMaskShape.Hexagon2 => "mask-hexagon-2",
DMaskShape.Decagon => "mask-decagon",
DMaskShape.Pentagon => "mask-pentagon",
DMaskShape.Diamond => "mask-diamond",
DMaskShape.Circle => "mask-circle",
DMaskShape.Star => "mask-star",
DMaskShape.Star2 => "mask-star-2",
DMaskShape.Triangle => "mask-triangle",
DMaskShape.Triangle2 => "mask-triangle-2",
DMaskShape.Triangle3 => "mask-triangle-3",
DMaskShape.Triangle4 => "mask-triangle-4",
_ => null
};
string HalfClass => Half switch
{
DMaskHalf.First => "mask-half-1",
DMaskHalf.Second => "mask-half-2",
_ => null
};
}
Loaded 100 of 366 files, more files were not shown because too many files have changed in this diff. Show more