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