Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,7 +51,7 @@ 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; }
|
||||
|
||||
@@ -363,3 +363,7 @@ 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
|
||||
@@ -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,396 @@
|
||||
# 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 are no tests and no linter.
|
||||
|
||||
## 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.
|
||||
- `Styles/neo.css` holds the neumorphic layer:
|
||||
- `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
|
||||
old `neomorph is-nxsmall` classes);
|
||||
- `neo-gradient(-pressed)` and `neo-hue-track`;
|
||||
- `@utility <daisy-component>` overrides that give btn, input, select, card, … the neo look. daisyUI puts its rules
|
||||
in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the
|
||||
daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo
|
||||
shadow with `shadow-none!`.
|
||||
- Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and
|
||||
every collapse disappears.
|
||||
|
||||
## daisyUI Blazor components (`Components/Daisy/`)
|
||||
|
||||
Every daisyUI component has a `D`-prefixed wrapper (`DButton`, `DModal`, `DDropdown`, `DInput`, ...), grouped by
|
||||
daisyUI's categories into folders whose namespaces are imported in `_Imports.razor`. `/styleguide` (not linked) renders
|
||||
all of them in every theme. When you add or change one:
|
||||
- Map each enum to its classes in a switch with whole literal class names.
|
||||
- Value-holding fields derive from `DInputBase<TValue>`, Blazor's `InputBase`, so `@bind-Value` and EditForm
|
||||
validation work. Everything else derives from `DComponentBase` (`Class`, pass-through attributes, `Cx(...)`).
|
||||
- Open/active state lives in C# (`@bind-IsOpen`, `@bind-Active`) and is rendered with daisyUI's `*-open` modifier
|
||||
classes. No JS interop.
|
||||
- What daisyUI does with scripts has a script-free equivalent here: `DCheckbox.Indeterminate` renders
|
||||
`aria-checked="mixed"`, and `DCarousel`'s navigation links to the slides' ids, which Blazor scrolls to.
|
||||
- `DCalendar` is the one exception: it wraps the vendored Cally web component (`wwwroot/vendor/cally.js`).
|
||||
- Images and links in `/styleguide` stay local (`wwwroot/imgs/styleguide/`).
|
||||
- Don't name a parameter after an HTML attribute (`Title`, `Style`, `Id`, `Label`, ...). Blazor matches attributes
|
||||
to parameters without regard to case, so the parameter would swallow the attribute.
|
||||
- Components don't use `Localizer` or `CascadingState`; their user-visible strings are parameters.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Startup (`Program.cs`).**
|
||||
- DI registration happens here.
|
||||
- The named `HttpClient` "default" uses `ApiBaseAddress` as its base address. It falls back to the host's base
|
||||
address when that setting is empty.
|
||||
- `SetDefaultCulture()` (`Extensions/ExtensionMethods.cs`) runs before the app starts. It picks the culture from the
|
||||
saved `PublicCacheData`, or on a first visit from the browser language.
|
||||
- Only English (`en-GB`) and Italian (`it-IT`) have resources; any other language gets English.
|
||||
- Settings → General changes the language, saves it, and reloads the app.
|
||||
|
||||
**`LayerComponents/CascadingState.razor`** wraps the router in `App.razor`. It is the app-wide state object. It handles:
|
||||
- `PublicCacheData` (page settings: theme colours, dark/light/system theming, language), persisted in localStorage;
|
||||
- the theme, applied through `AppStatusService.ApplyTheme` (`wwwroot/js/theme.js`);
|
||||
- `IsOnline` / `IsOffline`, polled every 10 seconds;
|
||||
- the session: `IsSignedIn`, `Personas` (the root's), `PersonaId` and `Persona` (its Mastodon `Account`), with
|
||||
`LoadSession`, `SwitchPersona` and `SignOut`. A persona change re-renders every subscriber; pages compare
|
||||
`PersonaId` and reload what belongs to the persona. The JWT is renewed hourly when needed;
|
||||
- `ProcessError` / `ProcessWarning`, which log through `ILoggingService` and show a toast;
|
||||
- `LogFromJs`, which JS calls back into through a `DotNetObjectReference`.
|
||||
|
||||
**Components and pages** inherit `LocalizableComponentBase`, the equivalent of b2b.next's `WidmannComponentBase`. It
|
||||
supplies:
|
||||
- `CascadingState`, `Localizer`, `Navigation` and `Toast`;
|
||||
- `IsLoading`, which starts true, and `IsDefaultDisabled`;
|
||||
- `AfterRenderAsyncJobs`, run once after the next render;
|
||||
- `WhenOnline` / `StartOnlinePolling`.
|
||||
|
||||
`PagesBase` is an empty subclass of it that nothing uses. Never redeclare the base's members in a component.
|
||||
|
||||
**UI feedback.**
|
||||
- `Services/ToastService.cs` is the snackbar: the SUtility extensions `AddResponseError(WebResult)` (silent on
|
||||
410/cancelled; connectivity failures show as warnings), `AddResponseError(string)`, `AddResponseSuccess` (closes
|
||||
after 5 s), `AddWarning` and `AddException`. `Components/ToastHost.razor`, placed in `MainLayout`, shows the
|
||||
messages.
|
||||
- `Components/ErrorViewer.razor` shows in-page errors: the cascading `EditContext`'s validation messages,
|
||||
`AdditionalError` and `AdditionalErrors`.
|
||||
|
||||
**HTTP (`Services/IHttpService.cs`).**
|
||||
- Routes are constants in `Models/APIs.cs` (b2b.next naming: `GET_`/`POST_`/`DELETE_`, `_M_` for routes built from
|
||||
parameters), relative to `ApiBaseAddress`.
|
||||
- The route picks the credentials: `clientapi/` sends the root's JWT, `api/` the current persona's Mastodon token
|
||||
(exchanged when missing, exchanged once more and retried when PrivaPub refuses it), `oauth/` nothing.
|
||||
- It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`,
|
||||
`SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload
|
||||
can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt.
|
||||
- Auth levels: `Get` / `Post` / `Delete` need credentials and end the session when PrivaPub refuses them; `GetAnon` /
|
||||
`PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT: PrivaPub
|
||||
redirects it).
|
||||
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
||||
- Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from
|
||||
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
|
||||
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
|
||||
`{"error": ...}`) and bodies with `DefaultReadFromJsonAsync` / `MastodonReadFromJsonAsync`; a body that doesn't fit
|
||||
comes back as the default and is written to the browser console. `PageCursors()` reads `max_id`/`min_id` from `Link`.
|
||||
- Every request PrivaPub answers is recorded in `RequestStats` (method, route template, status, duration, size, rate
|
||||
limit headers; never a body or a token), for the nerd stats.
|
||||
- PrivaPub's `WebResult` gets `IsInvalid` from `Extensions/WebResultExtensions.cs`, a C# 14 extension property.
|
||||
|
||||
**PrivaPub wiring.**
|
||||
- **One sign-in** (`Services/AuthService.cs`, `Pages/Login.razor`): the root signs in on `/clientapi/user/login`, then
|
||||
each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`,
|
||||
RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the
|
||||
persona tokens; logout revokes every persona token, then forgets the session and IndexedDB.
|
||||
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
|
||||
circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from
|
||||
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
|
||||
threads, mute, block; located and group posts go through `/clientapi/post/insert`), `MediaService` (uploads),
|
||||
`GroupService`, `InstanceService` and `ProvenanceService`.
|
||||
- **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`. Older pages load as the end comes near (`wwwroot/js/feed.js`); newer ones are polled every 45 s while
|
||||
the page is visible (PrivaPub has no streaming) and wait behind a "new" button.
|
||||
- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in
|
||||
`wwwroot/vendor/globe/` with its textures and `countries.json` (`SOURCES.md` has versions, licences and hashes; the
|
||||
service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an
|
||||
arc to a hovered post, honours `prefers-reduced-motion`, and draws only while something moves (idle, it costs no GPU
|
||||
time; `wake()` before anything that animates).
|
||||
- **Map.** Each pixel computes its own map coordinates from its point on the sphere (`sharpen`), so the poles don't
|
||||
pinch; the maps wrap horizontally and get 8× anisotropic filtering (`keepSharp`). The day texture is NASA's, its
|
||||
seam repaired by `tools/globe/prepare-day-texture.py` (dev-only, needs Pillow).
|
||||
- **Sun.** `wwwroot/js/sun.js` (pure functions of a date: the subsolar point, Earth–Sun distance, seasons, a place's
|
||||
elevation, phase of the day, solar time and day length) lights the globe in the shader: the day map shaded by the
|
||||
Sun's height and distance, city lights on the night side, a twilight blend between. It shows now (refreshed every
|
||||
30 s), or the moment of the hovered, focused or pinned post (`data-post-at` on the card; a click pins a card,
|
||||
`data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment, the phase and the season
|
||||
in the panel's top corner. The theme changes only the atmosphere and the points.
|
||||
- **Countries.** Land borders as thin paths, and names placed by `placeLabels`: largest countries first, once they
|
||||
span enough pixels, away from the globe's edge and clear of each other, in the reader's language
|
||||
(`Intl.DisplayNames`). `countries.json` is built by `tools/globe/prepare-countries.py` from world-atlas (Natural
|
||||
Earth). The label renderer owns `display`, so labels hide through `visibility`.
|
||||
- CDN-fronted posts are labels per CDN on a ring at 68°N, never a place; hovering one draws its arc there and a ring
|
||||
where the server was before the CDN. The posts' places reach it in one call per feed change; cards are matched by
|
||||
`data-post-id`.
|
||||
- **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on
|
||||
hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how
|
||||
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last
|
||||
request; the globe's camera, points, countries and frame rate, and its Sun (subsolar point, distance, irradiance,
|
||||
the place's elevation and day length, the next equinox or solstice).
|
||||
|
||||
**Localization (`Services/CoalescingStringLocalizer.cs`).**
|
||||
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
|
||||
`ErrorsResource`.
|
||||
- A missing key renders as the key itself, formatted with its arguments, so a typo fails silently.
|
||||
- The class lives in namespace `collAnon.Client.Services`, a leftover from another project.
|
||||
- `Resources/ErrorMessages.resx` is read by `[Required]` / `[StringLength]` through `ErrorMessageResourceType`. A new key
|
||||
needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
|
||||
|
||||
**Client storage** comes in two layers:
|
||||
- **localStorage** (Blazored.LocalStorage, camelCase JSON) holds `AuthData`, `PublicCacheData`, `FeedFilter` and the
|
||||
server cache `ServerInfos`, keyed by `nameof(Type)`.
|
||||
- **IndexedDB** (DnetIndexedDb, database `data`) is reached through `IStorage`. Its schema is declared in
|
||||
`GenericExtensions.AddIndexedDb()`, and you must bump `WithVersion` when you change it.
|
||||
- The schema is at version 3: the `ClientLogs` store that `IStorage.AddLog` writes (version 2 added it; version 3 left
|
||||
the mock feed's `Message` store out).
|
||||
|
||||
**Auth.** `TokenAuthStateProvider` holds `AuthData` (localStorage, cached in memory) and builds the auth state from
|
||||
it: the root's name, its policies as claims, and the current persona. Pages that need a session carry
|
||||
`[Authorize]`; `RedirectToLogin` sends the reader to `/login?returnUrl=…`.
|
||||
|
||||
**JS interop.**
|
||||
- `wwwroot/main.js` defines `window.*` helpers. `Services/AppStatusService.cs` calls them, mostly through the
|
||||
synchronous `IJSInProcessRuntime`.
|
||||
- Each helper reports its errors back through `window.logFromJs`.
|
||||
- The other scripts (IndexedDB, HeadElement, rxjs) are loaded by `<script>` tags in `wwwroot/index.html`; the feed's
|
||||
modules (`js/feed.js`, `js/globe.js`) are imported on demand through `IJSObjectReference`.
|
||||
|
||||
**PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version.
|
||||
|
||||
## Development rules
|
||||
|
||||
These rules are adapted from the owner's b2b.next project. They are enforced by review: the build doesn't check them,
|
||||
so follow them in every change.
|
||||
|
||||
| Area | Rule |
|
||||
|---|---|
|
||||
| All | `var` everywhere; uninitialised locals are `var x = default(T);` |
|
||||
| All | Primary constructors for DI (`class Foo(IBar bar)`), not constructor + fields |
|
||||
| All | Every async method takes a `CancellationToken` as its last parameter |
|
||||
| All | Early return on error: guard, handle, `return`; never if/else for success vs failure |
|
||||
| Models / state | Lists and arrays `= []`, never null; no `= string.Empty` / `""` defaults; explicit enum defaults |
|
||||
| Components | No `@{ }` blocks in markup: locals become `@code` members |
|
||||
| Components | `[Inject]` in `@code`, never `@inject` |
|
||||
| Components | Disable controls with `IsDefaultDisabled` (plus local conditions), never bare offline state |
|
||||
| Components | Every text/number input declares `autocomplete` |
|
||||
| Services | `Task<WebResult>` for mutations, `Task<List<T>>` (`[]` on error) for lists, `Task<T>` with a default for values |
|
||||
| Localization | Every user-facing string through `Localizer`, its key in **both** `AllStrings.resx` and `AllStrings.it.resx` |
|
||||
|
||||
**CancellationToken.** It goes on every async method in services, `CascadingState` and helpers, defaulting to
|
||||
`default`; pass it on to what you call. Exempt: Blazor lifecycle overrides, `[JSInvokable]` methods, framework overrides
|
||||
(`GetAuthenticationStateAsync`), and private UI handlers bound from markup.
|
||||
|
||||
**State initialisation.** Initialise page and component state (`= new()`, `= []`) and bind it directly. No passthrough
|
||||
getters and no `?.` chains over state that can't be null:
|
||||
|
||||
```csharp
|
||||
List<FeedPost> posts = []; // DO
|
||||
@posts.Count // DO
|
||||
@(posts?.Count ?? 0) // DON'T
|
||||
```
|
||||
|
||||
`= ""` is allowed only as a documented exception, with the reason in a comment (e.g. a decorative image's `Alt`).
|
||||
|
||||
**Pages that load data.** Pages fetch in `LoadData` and keep lifecycle concerns in the lifecycle method:
|
||||
|
||||
```csharp
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
```
|
||||
|
||||
- Reuse the base `IsLoading`; don't add per-section loading flags.
|
||||
- A component that binds `IsDefaultDisabled` must set `IsLoading = false` once it is ready, even if it loads nothing;
|
||||
otherwise its controls stay disabled.
|
||||
- Never wrap a service call in try/catch. Services already catch and log, and return an invalid `WebResult`. Branch on
|
||||
`result.IsInvalid` instead:
|
||||
|
||||
```csharp
|
||||
var result = await StatusService.Delete(Status.Id);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.AddResponseSuccess(Localizer["Message deleted."]);
|
||||
await OnDeleted.InvokeAsync(Post);
|
||||
```
|
||||
|
||||
**Client services** follow b2b.next's two patterns (see its `StoreService`):
|
||||
- Create `var result = new WebResult();`, call `httpService` inside a try/catch, and on a non-success status
|
||||
`result = await response.Content.ReadWebResult(response.StatusCode, localizer, ct)`. Log with `ILoggingService`.
|
||||
- Return `result` (mutations) or `[]` (lists), and set `result.Data` on success.
|
||||
- POST bodies are JSON payloads for `/clientapi` and the Mastodon API (snake_case DTOs), forms for `/oauth`.
|
||||
|
||||
**In-page errors.** `ErrorViewer` takes `AdditionalError` for one message and `AdditionalErrors` for a list. Never
|
||||
join a list into one string with `<br/>` or `\n`.
|
||||
|
||||
**Autocomplete values:**
|
||||
- `autocomplete="off"` for search, filter and free text;
|
||||
- `new-password` when setting a password;
|
||||
- `one-time-code` for OTP (DOtp sets it);
|
||||
- `username` and `current-password` on the login form, so password managers fill it;
|
||||
- inside `Components/Daisy`, attributes pass through from the caller.
|
||||
|
||||
**Localization:**
|
||||
- Keys are English source strings. Grep both resx files for an existing key before adding one; match keys exactly.
|
||||
- Never add a key that differs from an existing one only by case ("users" next to "Users"): resource lookups collide
|
||||
on them and one of the two silently shows the other's text. Reuse the existing key.
|
||||
- Use `Localizer[key, args]`, never `string.Format(Localizer[key], …)`. Don't write `.Value`.
|
||||
- Enum members shown through `Localizer[value.ToString()]` are keys too: when you add a member, add its key.
|
||||
- Language names in the language picker stay in their own language.
|
||||
|
||||
**Formatting.** Date formats come from `SUtility.dateTimeFormat` / `timeFormat` / `dateTimeFormatExt`. Relative times
|
||||
and sizes come from `GetPassedTime` / `GetFileSize` (`Extensions/GenericExtensions.cs`). No inline format strings for
|
||||
display.
|
||||
|
||||
**JSON.** Register every `/clientapi` or localStorage type in `ClientJsonContext.cs` (camelCase: `theme.js` reads
|
||||
`pageSettings` straight from localStorage), every Mastodon API type in `MastodonJsonContext.cs` (snake_case).
|
||||
|
||||
**Wiring a feature to PrivaPub:**
|
||||
1. For `/clientapi`, add the request/response DTOs to `PrivaPub.ClientModels` in the SocialPub repo: `View*` for
|
||||
responses, `*Request` for requests, `*Form` for forms with validation. Lists `= []`, no string defaults. For the
|
||||
Mastodon API, add the shape to `Models/Mastodon/`.
|
||||
2. Register them in `ClientJsonContext` or `MastodonJsonContext`, and the route in `Models/APIs.cs`.
|
||||
3. Add a client service method that calls `IHttpService` against the server's route, following the patterns above.
|
||||
4. Wire the page with early returns, toasts and/or an `ErrorViewer`.
|
||||
|
||||
**Not adopted from b2b.next, on purpose:**
|
||||
- **The `tw:` Tailwind prefix.** b2b.next needs it next to MudBlazor's CSS; here daisyUI is the only component CSS.
|
||||
- **Server-only rules:** no 5XX responses, the controller pattern, `IOptionsMonitor`, data access and caching.
|
||||
- **b2b.next-specific features:** Excel import/export, password policy, legacy porting rules, the `{culture}` route
|
||||
placeholder (PrivaPub's routes are absolute `/clientapi/...`), and the release/API-status worker.
|
||||
- **Mandatory `/// <summary>` on every `[Parameter]`.** Document only what isn't obvious from the name.
|
||||
|
||||
## Deployment
|
||||
|
||||
Both workflows check the repo out with a plain `git fetch` from this Gitea, not `actions/checkout`, because the runner
|
||||
would download that action from github.com. Never add a `uses:` step.
|
||||
|
||||
`.gitea/workflows/deploy.yml` runs on a `v*` tag or a manual dispatch, on a self-hosted runner that is also the web
|
||||
host. The steps:
|
||||
1. Publish the site.
|
||||
2. Write `build.json` (commit, ref, time).
|
||||
3. Snapshot the live directory to `/var/backups/decepub.thepra.dev`.
|
||||
4. `rsync --delete` into `/var/www/decepub.thepra.dev`.
|
||||
5. Check that `build.json` serves the new commit and that a deep client route returns 200 (the SPA fallback). Otherwise,
|
||||
roll back to the snapshot.
|
||||
|
||||
The nginx vhost and headers are in `deploy/nginx/` (it sends `X-Robots-Tag: noindex, nofollow`), and
|
||||
`deploy/max/setup.sh` is the one-time root setup on the server. If you add a file that must never be cached, add it to the `expires -1` locations in the vhost.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Indent with tabs.
|
||||
- `Nullable` is disabled and `ImplicitUsings` is enabled.
|
||||
- Global Razor usings are in `_Imports.razor`.
|
||||
- Styling uses the D components, Tailwind utilities and the `neo-*` utilities. Use `gap-*` rather than `space-x/y-*`
|
||||
on flex containers; the feeds use `flex-col-reverse`, which `space-*` gets wrong.
|
||||
- `SUtility.IfTrueThen(cond, "class")` is the helper for conditional classes.
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using decePubClient.Models;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
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(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(JwtUser))]
|
||||
[JsonSerializable(typeof(ViewAvatar))]
|
||||
[JsonSerializable(typeof(List<ViewAvatar>))]
|
||||
[JsonSerializable(typeof(ViewGroup))]
|
||||
[JsonSerializable(typeof(List<ViewGroup>))]
|
||||
[JsonSerializable(typeof(ViewPost))]
|
||||
[JsonSerializable(typeof(List<ViewPost>))]
|
||||
[JsonSerializable(typeof(InsertPostForm))]
|
||||
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,300 @@
|
||||
@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. *@
|
||||
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
@if (form.HasContentWarning)
|
||||
{
|
||||
<DInput TValue="string" class="w-full rounded-t-[1.4rem] rounded-b-lg" maxlength="500" autocomplete="off"
|
||||
placeholder="@Localizer["Content warning"]" @bind-Value="form.SpoilerText">
|
||||
<Prefix><i class="ion-md-warning text-warning"></i></Prefix>
|
||||
</DInput>
|
||||
}
|
||||
<div class="relative">
|
||||
<DTextarea TValue="string" @bind-Value="form.Content" 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="@(InReplyTo is null ? Localizer["What's happening?"] : Localizer["Reply to {0}", "@" + InReplyTo.Account?.Acct])" rows="3" />
|
||||
<span class="absolute text-xs opacity-50 right-3 bottom-2">@ContentLength/@ComposeForm.MaxContentLength</span>
|
||||
</div>
|
||||
|
||||
@if (form.Media.Count > 0)
|
||||
{
|
||||
<div class="flex flex-col gap-2">
|
||||
@foreach (var media in form.Media)
|
||||
{
|
||||
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs">
|
||||
@if (media.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image"))
|
||||
{
|
||||
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="text-2xl @form.MediaType.GetMediaTypeIcon()"></i>
|
||||
}
|
||||
<div class="flex flex-col flex-1 min-w-0 gap-1">
|
||||
<p class="text-xs break-all"><b>@media.FileName</b> · @media.ContentType · @media.Size.GetFileSize(Localizer)</p>
|
||||
<DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" autocomplete="off" maxlength="1500"
|
||||
placeholder="@Localizer["Alternative text"]" rows="1" />
|
||||
</div>
|
||||
<DButton Size="DSize.Sm" class="self-start" OnClick="() => form.Media.Remove(media)" title="@Localizer["Delete"]">
|
||||
<i class="ion-md-trash text-base text-error"></i>
|
||||
</DButton>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (form.IsLocated)
|
||||
{
|
||||
<div class="flex flex-col gap-1 rounded-xl p-3 neo-inset neo-2xs text-xs">
|
||||
<span class="inline-flex gap-2 items-center">
|
||||
<i class="ion-md-locate text-primary"></i>
|
||||
@if (form.Latitude is null)
|
||||
{
|
||||
<span>@Localizer["Asking this device for its position…"]</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@Localizer["Shown only to people within {0} km of here, never sent to other servers.", form.RangeKm.ToString("0")]</span>
|
||||
}
|
||||
</span>
|
||||
<DRange TValue="double" Size="DSize.Xs" Min="1" Max="50" Step="1" @bind-Value="form.RangeKm" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<ErrorViewer AdditionalError="@error" AdditionalErrors="fileErrors" />
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if (!form.IsLocated && GroupId is null)
|
||||
{
|
||||
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
|
||||
ContentClass="mt-2 flex gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
|
||||
<Trigger>
|
||||
<i class="@form.MessageType.GetMessageTypeIcon() text-base"></i>
|
||||
</Trigger>
|
||||
<ChildContent>
|
||||
@foreach (var messageType in Enum.GetValues<MessageType>())
|
||||
{
|
||||
<DButton Size="DSize.Sm" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)" title="@Localizer[messageType.ToString()]">
|
||||
<i class="@messageType.GetMessageTypeIcon() text-base"></i>
|
||||
</DButton>
|
||||
}
|
||||
</ChildContent>
|
||||
</DDropdown>
|
||||
}
|
||||
|
||||
<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)
|
||||
{
|
||||
@* A label styled as a button opens the hidden file input. *@
|
||||
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
|
||||
<InputFile accept="@AcceptedFileTypes" class="hidden" multiple="@(form.MediaType is MediaType.Images)" OnChange="OnFileChange"
|
||||
disabled="@(form.Media.Count >= MaximumFileCount)" />
|
||||
<i class="ion-md-attach text-base"></i>
|
||||
@if (form.Media.Count != 0)
|
||||
{
|
||||
<span>+@form.Media.Count</span>
|
||||
}
|
||||
</label>
|
||||
<DSelect TValue="MediaType" Size="DSize.Sm" class="w-auto" Value="form.MediaType" ValueChanged="v => form.MediaType = v"
|
||||
ValueExpression="() => form.MediaType" disabled="@(form.Media.Count > 0)">
|
||||
<Prefix><i class="@form.MediaType.GetMediaTypeIcon()"></i></Prefix>
|
||||
<ChildContent>
|
||||
@foreach (var mediaType in new[] { MediaType.Images, MediaType.Video, MediaType.Audio })
|
||||
{
|
||||
<option value="@mediaType">@Localizer[mediaType.ToString()]</option>
|
||||
}
|
||||
</ChildContent>
|
||||
</DSelect>
|
||||
}
|
||||
|
||||
@if (InReplyTo is null && GroupId is null)
|
||||
{
|
||||
<DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]">
|
||||
<i class="ion-md-locate 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>@Localizer[InReplyTo is null ? "Post" : "Reply"]</span>
|
||||
<i class="ion-md-send"></i>
|
||||
</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
[Inject] StatusService StatusService { 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 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 = [];
|
||||
string error;
|
||||
bool isPublishing;
|
||||
|
||||
int ContentLength => form.Content?.Length ?? 0;
|
||||
|
||||
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
|
||||
|
||||
long MaximumFileSize => form.MediaType switch
|
||||
{
|
||||
MediaType.Images => 16_777_216,
|
||||
MediaType.Audio => 41_943_040,
|
||||
_ => 99_614_720
|
||||
};
|
||||
|
||||
string AcceptedFileTypes => form.MediaType switch
|
||||
{
|
||||
MediaType.Images => ".jpg,.jpeg,.png,.gif,.webp,.avif",
|
||||
MediaType.Video => ".mp4,.m4v,.webm,.mov",
|
||||
MediaType.Audio => ".mp3,.ogg,.oga,.opus,.wav,.flac,.m4a",
|
||||
_ => default
|
||||
};
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
IsLoading = false;
|
||||
form.GroupId = GroupId;
|
||||
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
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
async Task ToggleLocated()
|
||||
{
|
||||
form.IsLocated = !form.IsLocated;
|
||||
if (!form.IsLocated || form.Latitude is not null)
|
||||
return;
|
||||
|
||||
var position = await JS.InvokeAsync<GeoPosition>("feedPosition");
|
||||
if (position?.Error is { Length: > 0 } positionError)
|
||||
{
|
||||
form.IsLocated = false;
|
||||
error = Localizer["This device did not give its position: {0}", positionError];
|
||||
return;
|
||||
}
|
||||
|
||||
form.Latitude = position?.Latitude;
|
||||
form.Longitude = position?.Longitude;
|
||||
}
|
||||
|
||||
async Task OnFileChange(InputFileChangeEventArgs eventArgs)
|
||||
{
|
||||
fileErrors = [];
|
||||
if (eventArgs.FileCount + form.Media.Count > MaximumFileCount)
|
||||
{
|
||||
fileErrors.Add(Localizer["The maximum number of files accepted is {0}, but {1} were supplied.", MaximumFileCount, form.Media.Count + eventArgs.FileCount]);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
|
||||
{
|
||||
if (file.Size > MaximumFileSize)
|
||||
{
|
||||
fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
||||
file.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await ReadFile(file) is { } media)
|
||||
form.Media.Add(media);
|
||||
else
|
||||
fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads a picked file into memory; null when the browser can't read it (logged).</summary>
|
||||
async Task<UploadMedia> ReadFile(IBrowserFile file, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
await using var fileStream = file.OpenReadStream(MaximumFileSize, cancellationToken);
|
||||
await fileStream.CopyToAsync(memoryStream, cancellationToken);
|
||||
var media = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
|
||||
if (file.ContentType.StartsWith("image"))
|
||||
media.Base64Preview = $"data:{media.ContentType};base64,{Convert.ToBase64String(media.Blob)}";
|
||||
return media;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Logger.ProcessError(ex, $"{nameof(Composer)}.{nameof(ReadFile)}:{file.Name}", cancellationToken);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
async Task Publish()
|
||||
{
|
||||
error = default;
|
||||
if (string.IsNullOrWhiteSpace(form.Content) && form.Media.Count == 0)
|
||||
{
|
||||
error = Localizer["Missing content, either message or media"];
|
||||
return;
|
||||
}
|
||||
if (CascadingState.PersonaId is null)
|
||||
{
|
||||
error = Localizer["Choose a persona first."];
|
||||
return;
|
||||
}
|
||||
|
||||
isPublishing = true;
|
||||
var result = await StatusService.Publish(form, CascadingState.PersonaId);
|
||||
isPublishing = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
error = result.ErrorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
|
||||
var published = result.Data as FeedPost;
|
||||
form = new() { GroupId = GroupId };
|
||||
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; }
|
||||
}
|
||||
}
|
||||
+614
-200
@@ -1,262 +1,676 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
<div class="flex space-x-3 w-full neomorph is-nxxsmall rounded-xl @CssContainer">
|
||||
@* One feed item: a post (a boost shows the original and its booster), a notification, or a conversation's last post.
|
||||
The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page
|
||||
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
|
||||
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
|
||||
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun. Located posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on
|
||||
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
|
||||
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
|
||||
data-post-id="@(Compact ? null : Post.Key)" data-lat="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Latitude))"
|
||||
data-lng="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Longitude))"
|
||||
data-src="@(Compact ? null : Post.Location?.Source.ToString().ToLowerInvariant())" data-cdn="@(Compact ? null : Post.Location?.CdnKey)"
|
||||
data-label="@(Compact ? null : Post.Location?.Label)" data-host="@(Compact ? null : Post.Host)"
|
||||
data-range="@(Post.Located && !Compact ? Coordinate(Post.RangeKm) : null)" data-post-at="@(Compact ? null : Post.WrittenAt?.ToString("O"))">
|
||||
|
||||
<div class="flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center"
|
||||
style="background-image:linear-gradient(to left, var(--background), transparent), url('@(Message.User?.BackgroundUrl)');">
|
||||
|
||||
<a class="block h-12 w-12 md:h-16 md:w-16" href="@Message.User.ProfileUrl" title="@Message.User.UserName">
|
||||
<img alt="@Message.User.UserName" class="h-12 w-12 md:h-16 md:w-16 object-cover rounded-full neomorph is-nxxsmall" src="@(Message.User.PictureUrl ?? "/imgs/icon-192.png")" />
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-3 flex-1 py-3 pr-3 md:py-4 md:pr-4 min-w-0">
|
||||
|
||||
<div class="flex flex-col space-y-1 flex-1 min-w-0">
|
||||
@if (Message.BoostingUser != null)
|
||||
@if (Author is { } author)
|
||||
{
|
||||
<p class="inline-flex flex-1 space-x-2 min-w-0 text-xs text-xs">
|
||||
<a class="font-bold" href="@Message.BoostingUser.ProfileUrl" title="@Message.BoostingUser.UserName">@Message.BoostingUser.DisplayName</a>
|
||||
<i class="ion-md-repeat has-text-info" aria-hidden="true"></i>
|
||||
<div class="flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center"
|
||||
style="@HeaderBackground(author)">
|
||||
<a class="block h-12 w-12 md:h-14 md:w-14" href="@author.Url" target="_blank" rel="noopener noreferrer" title="@author.Acct">
|
||||
@* alt="": the author's name is next to it. *@
|
||||
<img alt="" loading="lazy" class="h-12 w-12 md:h-14 md:w-14 object-cover rounded-full neo-raised neo-2xs bg-base-100"
|
||||
src="@(author.Avatar ?? "imgs/icon-192.png")" />
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex flex-col gap-2 flex-1 py-3 pr-3 md:py-4 md:pr-4 min-w-0">
|
||||
|
||||
@if (Post.Notification is { } notification)
|
||||
{
|
||||
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs">
|
||||
<i aria-hidden="true" class="@NotificationIcon(notification.Type)"></i>
|
||||
<span>@NotificationText(notification)</span>
|
||||
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
|
||||
</p>
|
||||
}
|
||||
else if (Post.BoostedBy is { } booster)
|
||||
{
|
||||
<p class="inline-flex flex-1 items-center gap-2 min-w-0 text-xs">
|
||||
<i class="ion-md-repeat text-info" aria-hidden="true"></i>
|
||||
<a class="font-bold truncate" href="@booster.Url" target="_blank" rel="noopener noreferrer" title="@booster.Acct">@booster.Name</a>
|
||||
<span>@Localizer["boosted"]</span>
|
||||
</p>
|
||||
}
|
||||
<p class="inline-flex flex-1 space-x-2 min-w-0 justify-between text-xs md:text-sm">
|
||||
<span class="inline-flex space-x-2 min-w-0">
|
||||
<b class="shrink truncate max-w-[80%]" title="@Message.User.DisplayName">
|
||||
@Message.User.DisplayName
|
||||
</b>
|
||||
<a class="underline flex-1 min-w-6 opacity-50 truncate self-center text-xs" href="@Message.User.ProfileUrl" title="@Message.User.UserName">
|
||||
@Message.User.UserName
|
||||
</a>
|
||||
</span>
|
||||
<span class="flex-none inline-flex space-x-1 items-center">
|
||||
<span title="@Message.CreatedAt.ToLocalTime().ToString("📅dd/MM/yy 🕒HH:mm")">
|
||||
@Message.CreatedAt.GetPassedTime(Localizer._pLocalizer)
|
||||
</span>
|
||||
<i aria-hidden="true" class="@Message.MessageType.GetMessageTypeIcon() text-md"
|
||||
title="@Localizer[Message.MessageType.ToString()]">
|
||||
</i>
|
||||
</span>
|
||||
else if (Post.Conversation is { } conversation)
|
||||
{
|
||||
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs">
|
||||
<i class="ion-md-mail @(conversation.Unread ? "text-primary" : null)" aria-hidden="true"></i>
|
||||
<span class="truncate">@string.Join(", ", conversation.Accounts.Select(a => "@" + a.Acct))</span>
|
||||
</p>
|
||||
@if (Message.Title is { Length: > 0 })
|
||||
{
|
||||
<p class="text-sm md:text-base font-bold break-all">@Message.Title</p>
|
||||
}
|
||||
|
||||
@if (Message.Content is { Length: > 0 })
|
||||
@if (Status is { } status)
|
||||
{
|
||||
<div class="text-sm md:text-base break-all">
|
||||
@((MarkupString)Message.Content)
|
||||
</div>
|
||||
<header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 min-w-0 justify-between text-xs md:text-sm">
|
||||
<NerdTip Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" OnOpen="ReadServer" Align="DTooltipAlign.Start" Class="min-w-0 flex-1">
|
||||
<span class="inline-flex gap-2 min-w-0 max-w-full">
|
||||
<b class="shrink truncate" title="@status.Account?.Name">@status.Account?.Name</b>
|
||||
<span class="flex-1 min-w-6 opacity-50 truncate self-center text-xs">@("@" + status.Account?.Acct)</span>
|
||||
</span>
|
||||
</NerdTip>
|
||||
<span class="flex-none inline-flex gap-2 items-center">
|
||||
@if (Post.Location is { Source: LocationSource.Cdn } fronted)
|
||||
{
|
||||
<NerdTip Heading="@Localizer["The server is behind {0}, so its place is unknown.", fronted.Label]" Rows="CdnRows()" Pending="isReadingServer"
|
||||
OnOpen="ReadServer" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
|
||||
<i class="ion-md-cloud" aria-hidden="true"></i>
|
||||
<span class="truncate">@fronted.Label</span>
|
||||
</span>
|
||||
</NerdTip>
|
||||
}
|
||||
else if (Post.Location is { } location)
|
||||
{
|
||||
<NerdTip Heading="@Localizer["Place"]" Rows="LocationRows()" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
|
||||
<i class="@LocationIcon(location.Source)" aria-hidden="true"></i>
|
||||
<span class="truncate">@(location.Label ?? Localizer["Near you"])</span>
|
||||
</span>
|
||||
</NerdTip>
|
||||
}
|
||||
<NerdTip Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" OnOpen="ReadProvenance"
|
||||
Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||
<time datetime="@NerdRow.Time(status.CreatedAt)" class="opacity-80">@status.CreatedAt.GetPassedTime(Localizer)</time>
|
||||
@if (status.EditedAt is not null)
|
||||
{
|
||||
<span class="opacity-60">*</span>
|
||||
}
|
||||
</NerdTip>
|
||||
<i aria-hidden="true" class="@VisibilityIcon(status.Visibility) text-base" title="@VisibilityText(status.Visibility)"></i>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
@if (status.Privapub?.Title is { Length: > 0 } title)
|
||||
{
|
||||
<p class="text-sm md:text-base font-bold break-words">@title</p>
|
||||
}
|
||||
|
||||
@if (Message.Medias.Count != 0)
|
||||
@if (status.SpoilerText is { Length: > 0 } spoiler)
|
||||
{
|
||||
<div class="grid gap-4 auto-cols-auto grid-rows-1 grid-flow-col-dense">
|
||||
@foreach (var media in Message.Medias)
|
||||
{
|
||||
if (media.ContentType.StartsWith("image"))
|
||||
{
|
||||
<a class="w-auto" href="@media.Url">
|
||||
<img alt="@media.AltText" class="w-full rounded-lg @SUtility.IfTrueThen(Message.Medias.Count > 1, "max-h-[30vh]")" src="@media.Url" title="@media.AltText">
|
||||
</a>
|
||||
}
|
||||
else if (media.ContentType.StartsWith("video"))
|
||||
{
|
||||
<video class="w-full max-h-[50vh] aspect-video rounded-lg mx-auto neomorphInset is-nxxsmall" controls="controls" playsinline="playsinline" preload="metadata" title="@media.FileName">
|
||||
<source src="@media.Url" type="@media.ContentType" />
|
||||
</video>
|
||||
}
|
||||
else if (media.ContentType.StartsWith("audio"))
|
||||
{
|
||||
<audio class="w-full max-h-8" controls="controls" preload="metadata" title="@media.FileName">
|
||||
<source src="@media.Url" type="@media.ContentType" />
|
||||
</audio>
|
||||
<DCollapse @bind-IsOpen="isSpoilerOpen" Icon="DCollapseIcon.Arrow" class="rounded-lg neo-xs" HeaderClass="text-sm py-2 min-h-0"
|
||||
ContentClass="text-sm">
|
||||
<Header>
|
||||
<span class="inline-flex gap-2 items-center"><i class="ion-md-warning text-warning"></i>@spoiler</span>
|
||||
</Header>
|
||||
<ChildContent>
|
||||
@Body(status)
|
||||
</ChildContent>
|
||||
</DCollapse>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex items-center space-x-3 align-center rounded-lg p-3 md:p-4 neomorph is-nxxsmall">
|
||||
<span>
|
||||
<i class="text-2xl ion-md-document"></i>
|
||||
</span>
|
||||
<div class="flex flex-col w-full space-y-1">
|
||||
<p class="text-xs md:text-sm break-all">
|
||||
<b>@media.FileName</b>
|
||||
@Body(status)
|
||||
}
|
||||
|
||||
@if (Post.Located)
|
||||
{
|
||||
<p class="inline-flex gap-2 items-center text-xs opacity-70">
|
||||
<i class="ion-md-locate"></i>
|
||||
<span>@Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)]</span>
|
||||
</p>
|
||||
<p class="text-xs break-all">
|
||||
<i class="ion-md-code"></i> @media.ContentType
|
||||
</p>
|
||||
</div>
|
||||
<button class="button is-small is-rounded neoBtnSmall" @onclick="async () => await OnMessageMediaDownload.InvokeAsync(media)" type="button">
|
||||
<span class="icon">
|
||||
<i class="ion-md-download text-base"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!Post.Located && !Compact)
|
||||
{
|
||||
<footer class="flex gap-2 mt-1 justify-between">
|
||||
<div class="flex gap-2">
|
||||
<DButton Size="DSize.Sm" Active="isReplying" Disabled="IsDefaultDisabled" OnClick="ToggleReply" title="@Localizer[isReplying ? "Close" : "Reply"]">
|
||||
<i aria-hidden="true" class="@(isReplying ? "ion-md-close-circle" : "ion-md-text") text-lg text-success"></i>
|
||||
@if (status.RepliesCount > 0)
|
||||
{
|
||||
<span>@status.RepliesCount</span>
|
||||
}
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Active="status.Reblogged" Disabled="IsDefaultDisabled || isActing || !CanBoost(status)"
|
||||
OnClick="() => Act(status.Reblogged ? StatusAction.Unreblog : StatusAction.Reblog)" title="@Localizer["Boost"]">
|
||||
<i aria-hidden="true" class="ion-md-repeat text-lg text-info"></i>
|
||||
@if (status.ReblogsCount > 0)
|
||||
{
|
||||
<span>@status.ReblogsCount</span>
|
||||
}
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Active="status.Favourited" Disabled="IsDefaultDisabled || isActing"
|
||||
OnClick="() => Act(status.Favourited ? StatusAction.Unfavourite : StatusAction.Favourite)" title="@Localizer["Favourite"]">
|
||||
<i aria-hidden="true" class="@(status.Favourited ? "ion-md-heart" : "ion-md-heart-empty") text-lg text-pink-400"></i>
|
||||
@if (status.FavouritesCount > 0)
|
||||
{
|
||||
<span>@status.FavouritesCount</span>
|
||||
}
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
|
||||
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]">
|
||||
<i aria-hidden="true" class="ion-md-bookmark text-lg @(status.Bookmarked ? "text-warning" : null)"></i>
|
||||
</DButton>
|
||||
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
|
||||
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
|
||||
</NerdTip>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3 mt-3 justify-between">
|
||||
<div class="flex space-x-3">
|
||||
@if (OnMessageReply.HasDelegate)
|
||||
{
|
||||
<button class="button is-small is-rounded @(isAnswering ? "neoBtnSmallInsetPlain" : "neoBtnSmall")" @onclick="Reply"
|
||||
title="@Localizer[isAnswering ? "Close" : "Reply"]">
|
||||
<span class="icon">
|
||||
<i aria-hidden="true" class="@SUtility.IfTrueThen(isAnswering, "ion-md-close-circle", "ion-md-text") text-lg has-text-success"></i>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@if (OnMessageBoost.HasDelegate)
|
||||
{
|
||||
<button class="button is-small is-rounded @SUtility.IfTrueThen(Message.IsBoostedByCurrentUser,"neoBtnSmallInsetPlain","neoBtnSmall") @SUtility.IfTrueThen(Message.BoostsCounter != 0, "has-icons-left")" @onclick="() => OnMessageBoost.InvokeAsync(Message)"
|
||||
title="@Localizer["Boost"]">
|
||||
<span class="icon is-left">
|
||||
<i aria-hidden="true" class="ion-md-repeat @SUtility.IfTrueThen(Message.BoostsCounter != 0,null,"text-lg") has-text-info"></i>
|
||||
</span>
|
||||
@if (Message.BoostsCounter != 0)
|
||||
{
|
||||
<span>@Message.BoostsCounter</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (OnMessageFavourite.HasDelegate)
|
||||
{
|
||||
<button class="button is-small is-rounded @SUtility.IfTrueThen(Message.IsFavourite, "neoBtnSmallInsetPlain", "neoBtnSmall")" @onclick="() => OnMessageFavourite.InvokeAsync(Message)"
|
||||
title="@Localizer["Favourite"]">
|
||||
<span class="icon">
|
||||
<i aria-hidden="true" class="@SUtility.IfTrueThen(Message.IsFavourite, "ion-md-heart-dislike", "ion-md-heart") text-lg text-pink-300"></i>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<DropdownButton IsOpen="Message.IsOptionsOpen">
|
||||
<DropdownTrigger>
|
||||
<button class="button is-small is-rounded neoBtnSmall" @onclick="() => Message.IsOptionsOpen = !Message.IsOptionsOpen"
|
||||
title="@Localizer["Other"]">
|
||||
<span class="icon">
|
||||
<div class="flex gap-2">
|
||||
<DDropdown @bind-IsOpen="isOptionsOpen" Align="DDropdownAlign.End" CloseOnContentClick="true" Placement="DDropdownPlacement.Top"
|
||||
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
|
||||
ContentClass="mb-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Other"]">
|
||||
<Trigger>
|
||||
<i aria-hidden="true" class="ion-md-more text-lg"></i>
|
||||
</span>
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent>
|
||||
@if (OnUserDirectMessage.HasDelegate)
|
||||
</Trigger>
|
||||
<ChildContent>
|
||||
@if (status.Url is { Length: > 0 } url)
|
||||
{
|
||||
<div class="dropdown-item">
|
||||
<button class="button is-small is-rounded has-icons-left neoBtnSmall" @onclick="() => OnUserDirectMessage.InvokeAsync(Message)">
|
||||
<span class="icon is-left">
|
||||
<i aria-hidden="true" class="ion-md-paper-plane text-base has-text-success"></i>
|
||||
</span>
|
||||
<span>@Localizer["Direct message"]</span>
|
||||
</button>
|
||||
</div>
|
||||
<a class="btn btn-sm justify-start whitespace-nowrap" href="@url" target="_blank" rel="noopener noreferrer">
|
||||
<i aria-hidden="true" class="ion-md-open text-base"></i>
|
||||
<span>@Localizer["Open original"]</span>
|
||||
</a>
|
||||
}
|
||||
@if (OnUserSilence.HasDelegate)
|
||||
@if (!IsOwn)
|
||||
{
|
||||
<div class="dropdown-item">
|
||||
<button class="button is-small is-rounded has-icons-left neoBtnSmall" @onclick="() => OnUserSilence.InvokeAsync(Message.User)">
|
||||
<span class="icon is-left">
|
||||
<i aria-hidden="true" class="ion-md-eye-off text-base drop-shadow has-text-warning"></i>
|
||||
</span>
|
||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))">
|
||||
<i aria-hidden="true" class="ion-md-eye-off text-base text-warning"></i>
|
||||
<span>@Localizer["Mute"]</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (OnUserBlock.HasDelegate)
|
||||
{
|
||||
<div class="dropdown-item">
|
||||
<button class="button is-small is-rounded has-icons-left neoBtnSmall" @onclick="() => OnUserBlock.InvokeAsync(Message.User)">
|
||||
<span class="icon is-left">
|
||||
<i aria-hidden="true" class="ion-md-remove-circle text-base has-text-danger"></i>
|
||||
</span>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("block"))">
|
||||
<i aria-hidden="true" class="ion-md-remove-circle text-base text-error"></i>
|
||||
<span>@Localizer["Block"]</span>
|
||||
</button>
|
||||
</div>
|
||||
</DButton>
|
||||
}
|
||||
@if (@*Message.User.UserName == CurrentUserName &&*@ OnMessageDelete.HasDelegate)
|
||||
else
|
||||
{
|
||||
<div class="dropdown-item">
|
||||
<button class="button is-small has-icons-left is-rounded neoBtnSmall" @onclick="DeleteMessage"
|
||||
title="@Localizer["Delete"]">
|
||||
<span class="icon is-left">
|
||||
<i aria-hidden="true" class="ion-md-trash text-lg has-text-danger"></i>
|
||||
</span>
|
||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="Delete">
|
||||
<i aria-hidden="true" class="ion-md-trash text-base text-error"></i>
|
||||
<span>@Localizer["Delete"]</span>
|
||||
</button>
|
||||
</div>
|
||||
</DButton>
|
||||
}
|
||||
</DropdownContent>
|
||||
</DropdownButton>
|
||||
</ChildContent>
|
||||
</DDropdown>
|
||||
@if (IncludeExpand)
|
||||
{
|
||||
<NavLink class="button is-small is-rounded neoBtnSmall" href="@($"expand/{Message.MessageId}")"
|
||||
title="@Localizer["Expand"]">
|
||||
<span class="icon">
|
||||
<NavLink class="btn btn-sm" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]">
|
||||
<i aria-hidden="true" class="ion-md-expand text-lg"></i>
|
||||
</span>
|
||||
</NavLink>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@if (isAnswering)
|
||||
@if (isReplying)
|
||||
{
|
||||
<MessageUpsertForm AnsweringMessage="Message" OnMessageSubmit="SubmitReply"></MessageUpsertForm>
|
||||
<Composer InReplyTo="status" OnPublished="Replied" />
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (Post.Notification?.Account is { } follower)
|
||||
{
|
||||
<p class="text-sm">
|
||||
<b>@follower.Name</b> <span class="opacity-60">@("@" + follower.Acct)</span>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] Task<AuthenticationState> AuthState { get; set; }
|
||||
[CascadingParameter] CascadingState CascadingState { get; set; }
|
||||
[Parameter] public Message Message { get; set; } = new();
|
||||
[Parameter] public EventCallback<MessageForm> OnMessageReply { get; set; }
|
||||
[Parameter] public EventCallback<Message> OnMessageBoost { get; set; }
|
||||
[Parameter] public EventCallback<Message> OnMessageFavourite { get; set; }
|
||||
[Parameter] public EventCallback<Message> OnMessageDelete { get; set; }
|
||||
[Parameter] public EventCallback<Message> OnUserDirectMessage { get; set; }
|
||||
[Parameter] public EventCallback<Media> OnMessageMediaDownload { get; set; }
|
||||
[Parameter] public EventCallback<MessageUser> OnUserBlock { get; set; }
|
||||
[Parameter] public EventCallback<MessageUser> OnUserSilence { get; set; }
|
||||
[Inject] StatusService StatusService { get; set; }
|
||||
[Inject] InstanceService InstanceService { get; set; }
|
||||
[Inject] ProvenanceService ProvenanceService { get; set; }
|
||||
|
||||
[Parameter, EditorRequired] public FeedPost Post { get; set; }
|
||||
[Parameter] public EventCallback<FeedPost> OnDeleted { get; set; }
|
||||
[Parameter] public EventCallback<FeedPost> OnReplied { get; set; }
|
||||
[Parameter] public EventCallback<string> OnTag { get; set; }
|
||||
[Parameter] public string CssContainer { get; set; }
|
||||
/// <summary>Deeper shadow, for the post a thread page is about.</summary>
|
||||
[Parameter] public bool Highlighted { get; set; }
|
||||
[Parameter] public bool IncludeExpand { get; set; } = true;
|
||||
/// <summary>Without actions, e.g. a quoted post.</summary>
|
||||
[Parameter] public bool Compact { get; set; }
|
||||
|
||||
bool isAnswering { get; set; } = false;
|
||||
bool isReplying;
|
||||
bool isActing;
|
||||
bool isOptionsOpen;
|
||||
bool isSpoilerOpen;
|
||||
bool isReadingProvenance;
|
||||
bool isReadingServer;
|
||||
readonly HashSet<string> revealedMedia = [];
|
||||
|
||||
string CurrentUserName
|
||||
Status Status => Post.Status;
|
||||
Account Author => Post.Status?.Account ?? Post.Notification?.Account;
|
||||
ServerInfo Server => InstanceService.Known(Post.Host);
|
||||
InstanceLocation ServerGeo => Server?.Geo;
|
||||
bool IsOwn => Post.IsOwnedBy(CascadingState.PersonaId);
|
||||
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
|
||||
RenderFragment Body(Status status) => @<div class="flex flex-col gap-3">
|
||||
@if (status.Content is { Length: > 0 } content)
|
||||
{
|
||||
get
|
||||
<div class="post-content text-sm md:text-base break-words">@((MarkupString)content)</div>
|
||||
}
|
||||
|
||||
@if (status.MediaAttachments.Count > 0)
|
||||
{
|
||||
return AuthState.Result.User.Identity?.Name;
|
||||
<div class="grid gap-2 @(status.MediaAttachments.Count > 1 ? "grid-cols-2" : "grid-cols-1")">
|
||||
@foreach (var media in status.MediaAttachments)
|
||||
{
|
||||
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full">
|
||||
@if (status.Sensitive && !revealedMedia.Contains(media.Id))
|
||||
{
|
||||
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm"
|
||||
@onclick="() => revealedMedia.Add(media.Id)">
|
||||
<i class="ion-md-eye-off mr-2"></i>@Localizer["Sensitive media, show"]
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
@Media(media, status.MediaAttachments.Count > 1)
|
||||
}
|
||||
</NerdTip>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (status.Card is { } card && status.MediaAttachments.Count == 0)
|
||||
{
|
||||
<a class="flex gap-3 rounded-lg p-2 neo-inset neo-2xs min-w-0" href="@card.Url" target="_blank" rel="noopener noreferrer">
|
||||
@if (card.Image is { Length: > 0 } image)
|
||||
{
|
||||
<img alt="@card.ImageDescription" loading="lazy" class="h-16 w-24 flex-none object-cover rounded-md" src="@image" />
|
||||
}
|
||||
<span class="flex flex-col min-w-0 text-xs">
|
||||
<b class="truncate">@card.Title</b>
|
||||
<span class="line-clamp-2 opacity-70">@card.Description</span>
|
||||
<span class="truncate opacity-50">@(card.ProviderName ?? CardHost(card.Url))</span>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (status.Poll is { } poll)
|
||||
{
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
@foreach (var option in poll.Options)
|
||||
{
|
||||
var share = poll.VotesCount > 0 && option.VotesCount is { } votes ? votes * 100d / poll.VotesCount : 0;
|
||||
<div class="relative rounded-md overflow-hidden neo-inset neo-2xs px-2 py-1">
|
||||
<div class="absolute inset-y-0 left-0 bg-primary/20" style="width:@(share.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture))%"></div>
|
||||
<span class="relative flex justify-between gap-2"><span>@option.Title</span><span>@share.ToString("0")%</span></span>
|
||||
</div>
|
||||
}
|
||||
<span class="opacity-60">@Localizer["{0} votes", poll.VotesCount] · @(poll.Expired ? Localizer["closed"] : Localizer["open"])</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (status.Privapub?.Event is { } happening)
|
||||
{
|
||||
<div class="flex flex-col gap-1 text-xs rounded-lg p-2 neo-inset neo-2xs">
|
||||
<span class="inline-flex gap-2 items-center"><i class="ion-md-calendar"></i>@EventTime(happening)</span>
|
||||
@foreach (var venue in happening.Places)
|
||||
{
|
||||
<span class="inline-flex gap-2 items-center"><i class="ion-md-pin"></i>@(venue.Name ?? venue.Address ?? venue.Url)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (status.Quote?.QuotedStatus is { } quoted)
|
||||
{
|
||||
<Content Post="FeedPost.Of(quoted)" Compact="true" IncludeExpand="false" CssContainer="neo-inset! shadow-none" />
|
||||
}
|
||||
|
||||
@if (status.Tags.Count > 0)
|
||||
{
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@foreach (var tag in status.Tags)
|
||||
{
|
||||
<button type="button" class="badge badge-soft badge-sm" @onclick="() => OnTag.InvokeAsync(tag.Name)">#@tag.Name</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>;
|
||||
|
||||
RenderFragment Media(MediaAttachment media, bool several) => media.Type switch
|
||||
{
|
||||
"image" => @<a class="block w-full" href="@media.Url" target="_blank" rel="noopener noreferrer">
|
||||
<img alt="@media.Description" title="@media.Description" loading="lazy" class="w-full rounded-lg object-cover @(several ? "aspect-square" : "max-h-[60vh]")"
|
||||
src="@(media.PreviewUrl ?? media.Url)" />
|
||||
</a>,
|
||||
"gifv" => @<video class="w-full rounded-lg" autoplay muted loop playsinline title="@media.Description" src="@media.Url"></video>,
|
||||
"video" => @<video class="w-full max-h-[50vh] aspect-video rounded-lg neo-inset neo-2xs" controls playsinline preload="metadata"
|
||||
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url"></video>,
|
||||
"audio" => @<audio class="w-full" controls preload="metadata" title="@media.Description" src="@media.Url"></audio>,
|
||||
_ => @<a class="btn btn-sm justify-start" href="@media.Url" target="_blank" rel="noopener noreferrer">
|
||||
<i class="ion-md-document"></i><span class="truncate">@(media.Description ?? media.Url)</span>
|
||||
</a>
|
||||
};
|
||||
|
||||
async Task Act(StatusAction action)
|
||||
{
|
||||
isActing = true;
|
||||
var result = await StatusService.Act(Status.Id, action);
|
||||
isActing = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unreblog answers with the original; reblog with the boost wrapper.
|
||||
var updated = (Status)result.Data;
|
||||
Post.Status = updated.Reblog ?? updated;
|
||||
}
|
||||
|
||||
async Task Delete()
|
||||
{
|
||||
isActing = true;
|
||||
var result = await StatusService.Delete(Status.Id);
|
||||
isActing = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.AddResponseSuccess(Localizer["Message deleted."]);
|
||||
await OnDeleted.InvokeAsync(Post);
|
||||
}
|
||||
|
||||
async Task AccountAction(string action)
|
||||
{
|
||||
isActing = true;
|
||||
var result = await StatusService.AccountAction(Status.Account.Id, action);
|
||||
isActing = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.AddResponseSuccess(action == "block" ? Localizer["{0} blocked.", Status.Account.Name] : Localizer["{0} muted.", Status.Account.Name]);
|
||||
}
|
||||
|
||||
void ToggleReply() => isReplying = !isReplying;
|
||||
|
||||
async Task Replied(FeedPost reply)
|
||||
{
|
||||
isReplying = false;
|
||||
Status.RepliesCount++;
|
||||
await OnReplied.InvokeAsync(reply);
|
||||
}
|
||||
|
||||
async Task ReadProvenance()
|
||||
{
|
||||
if (Status is null || Post.Located)
|
||||
return;
|
||||
isReadingProvenance = true;
|
||||
await ProvenanceService.Get(Status.Id);
|
||||
isReadingProvenance = false;
|
||||
}
|
||||
|
||||
// The author's server through time, and its CDN's page when it has one: read on the first hover.
|
||||
async Task ReadServer()
|
||||
{
|
||||
if (Post.Host is null)
|
||||
return;
|
||||
isReadingServer = true;
|
||||
await InstanceService.History(Post.Host);
|
||||
if (Post.Location?.CdnKey is { } cdn)
|
||||
await InstanceService.Cdn(cdn);
|
||||
isReadingServer = false;
|
||||
}
|
||||
|
||||
static bool CanBoost(Status status) => status.Visibility is "public" or "unlisted";
|
||||
|
||||
static string Coordinate(double? value) => value?.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
static string Number(double? value) => value?.ToString("0.#", System.Globalization.CultureInfo.CurrentCulture) ?? "?";
|
||||
|
||||
static string CardHost(string url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : url;
|
||||
|
||||
// PrivaPub (like Mastodon) answers a placeholder for a missing header: /media/missing-header.png.
|
||||
static string HeaderBackground(Account account) => account.Header is { Length: > 0 } header && !header.Contains("/missing-")
|
||||
? $"background-image:linear-gradient(to left, var(--color-base-100), transparent), url('{header}');"
|
||||
: null;
|
||||
|
||||
string EventTime(PrivaPubEvent happening) => happening.Start is { } start
|
||||
? happening.End is { } end
|
||||
? $"{start.ToLocalTime():g} – {end.ToLocalTime():t}"
|
||||
: start.ToLocalTime().ToString("g")
|
||||
: Localizer["Event"];
|
||||
|
||||
string VisibilityText(string visibility) => visibility switch
|
||||
{
|
||||
"public" => Localizer[MessageType.Public.ToString()],
|
||||
"unlisted" => Localizer[MessageType.Unlisted.ToString()],
|
||||
"private" => Localizer[MessageType.FollowersOnly.ToString()],
|
||||
"direct" => Localizer[MessageType.Direct.ToString()],
|
||||
_ => Localizer["Located"]
|
||||
};
|
||||
|
||||
static string VisibilityIcon(string visibility) => visibility switch
|
||||
{
|
||||
"public" => "ion-md-globe",
|
||||
"unlisted" => "ion-md-unlock",
|
||||
"private" => "ion-md-lock",
|
||||
"direct" => "ion-md-paper-plane",
|
||||
_ => "ion-md-locate"
|
||||
};
|
||||
|
||||
static string LocationIcon(LocationSource source) => source switch
|
||||
{
|
||||
LocationSource.Place or LocationSource.Event => "ion-md-pin",
|
||||
LocationSource.NearYou => "ion-md-locate",
|
||||
LocationSource.Country => "ion-md-flag",
|
||||
_ => "ion-md-planet"
|
||||
};
|
||||
|
||||
static string NotificationIcon(string type) => type switch
|
||||
{
|
||||
"favourite" => "ion-md-heart text-pink-400",
|
||||
"reblog" => "ion-md-repeat text-info",
|
||||
"mention" => "ion-md-at text-success",
|
||||
"follow" or "follow_request" => "ion-md-person-add text-primary",
|
||||
"poll" => "ion-md-podium",
|
||||
"update" or "quoted_update" => "ion-md-create",
|
||||
"quote" => "ion-md-quote",
|
||||
_ => "ion-md-notifications"
|
||||
};
|
||||
|
||||
string NotificationText(Notification notification)
|
||||
{
|
||||
var name = notification.Account?.Name;
|
||||
return notification.Type switch
|
||||
{
|
||||
"favourite" => Localizer["{0} favourited your post", name],
|
||||
"reblog" => Localizer["{0} boosted your post", name],
|
||||
"mention" => Localizer["{0} mentioned you", name],
|
||||
"follow" => Localizer["{0} followed you", name],
|
||||
"follow_request" => Localizer["{0} asked to follow you", name],
|
||||
"status" => Localizer["{0} posted", name],
|
||||
"poll" => Localizer["A poll you took part in has ended"],
|
||||
"update" => Localizer["{0} edited a post", name],
|
||||
"quote" => Localizer["{0} quoted your post", name],
|
||||
"pleroma:emoji_reaction" => Localizer["{0} reacted {1}", name, notification.Emoji],
|
||||
_ => Localizer["{0}: {1}", name, notification.Type]
|
||||
};
|
||||
}
|
||||
|
||||
#region Nerd stats
|
||||
|
||||
IEnumerable<NerdRow> PostRows()
|
||||
{
|
||||
var status = Status;
|
||||
yield return NerdRow.Of("id", status.Id);
|
||||
yield return NerdRow.Of("created_at", status.CreatedAt);
|
||||
yield return NerdRow.Of("edited_at", status.EditedAt);
|
||||
yield return NerdRow.Of("uri", status.Uri);
|
||||
yield return NerdRow.Of("language", status.Language);
|
||||
yield return NerdRow.Of("visibility", status.Visibility);
|
||||
yield return NerdRow.Of("type", status.Privapub?.ObjectType);
|
||||
yield return NerdRow.Of("source", status.Privapub?.Source?.MediaType);
|
||||
yield return NerdRow.Of("client", status.Application?.Name);
|
||||
if (ProvenanceService.Known(status.Id) is not { } provenance)
|
||||
yield break;
|
||||
|
||||
yield return NerdRow.Of("path", provenance.Path);
|
||||
yield return NerdRow.Of("activity", provenance.Activity is { } activity ? $"{activity.Type} {activity.Id}" : null);
|
||||
yield return NerdRow.Of("inbox", provenance.Inbox);
|
||||
yield return NerdRow.Of("fetched_by", provenance.FetchedBy);
|
||||
yield return NerdRow.Of("published", provenance.Published);
|
||||
yield return NerdRow.Of("received_at", provenance.ReceivedAt);
|
||||
if (provenance.ReceivedAt is { } received && provenance.Published is { } published)
|
||||
yield return new(Localizer["delivery latency"], NerdRow.Duration(received - published));
|
||||
yield return NerdRow.Of("signature", provenance.Signature is { } signature
|
||||
? $"{signature.Scheme} {signature.Algorithm} ({string.Join(' ', signature.Headers)})"
|
||||
: null);
|
||||
yield return NerdRow.Of("key_id", provenance.Signature?.KeyId);
|
||||
yield return NerdRow.Of("raw", provenance.RawBytes > 0 ? NerdRow.Bytes(provenance.RawBytes) + (provenance.RawTruncated ? " (truncated)" : null) : null);
|
||||
yield return NerdRow.Of("sha256", provenance.RawHash is { Length: > 16 } hash ? hash[..16] + "…" : provenance.RawHash);
|
||||
yield return NerdRow.Of("revisions", provenance.Revisions.Count > 0 ? provenance.Revisions.Count : null);
|
||||
yield return NerdRow.Of("refetched", provenance.Refetched ? true : null);
|
||||
yield return NerdRow.Of("extensions", provenance.Extensions.Count > 0 ? string.Join(", ", provenance.Extensions) : null);
|
||||
yield return NerdRow.Of("@context", provenance.ContextNamespaces.Count > 0 ? string.Join(", ", provenance.ContextNamespaces) : null);
|
||||
}
|
||||
|
||||
IEnumerable<NerdRow> AuthorRows()
|
||||
{
|
||||
var account = Status?.Account;
|
||||
if (account is null)
|
||||
yield break;
|
||||
|
||||
yield return NerdRow.Of("acct", account.Acct);
|
||||
yield return NerdRow.Of("uri", account.Uri);
|
||||
yield return NerdRow.Of("created_at", account.CreatedAt);
|
||||
yield return NerdRow.Of(Localizer["followers"], account.FollowersCount);
|
||||
yield return NerdRow.Of(Localizer["following"], account.FollowingCount);
|
||||
yield return NerdRow.Of(Localizer["posts"], account.StatusesCount);
|
||||
yield return NerdRow.Of("bot", account.Bot ? true : null);
|
||||
yield return NerdRow.Of("locked", account.Locked ? true : null);
|
||||
if (Server is not { } server)
|
||||
yield break;
|
||||
|
||||
yield return NerdRow.Of(Localizer["server"], server.NodeName is { Length: > 0 } name && name != server.Host ? $"{server.Host} ({name})" : server.Host);
|
||||
yield return NerdRow.Of(Localizer["software"], $"{server.Software} {server.Version}".Trim());
|
||||
yield return NerdRow.Of(Localizer["Users"], server.Users is { } users
|
||||
? server.ActiveMonth is { } active ? $"{users:N0} ({active:N0} {Localizer["active this month"]})" : users.ToString("N0")
|
||||
: null);
|
||||
yield return NerdRow.Of(Localizer["local posts"], server.LocalPosts);
|
||||
yield return NerdRow.Of(Localizer["registrations"], server.OpenRegistrations is { } open ? open ? Localizer["open"] : Localizer["closed"] : null);
|
||||
yield return NerdRow.Of(Localizer["last delivery"], server.LastSuccessAt);
|
||||
yield return NerdRow.Of(Localizer["failures in a row"], server.ConsecutiveFailures > 0 ? server.ConsecutiveFailures : null);
|
||||
yield return NerdRow.Of(Localizer["described"], server.DescribedAt);
|
||||
foreach (var row in HistoryRows())
|
||||
yield return row;
|
||||
}
|
||||
|
||||
// The server's weeks, newest first: how many are known, and every change of place or CDN.
|
||||
IEnumerable<NerdRow> HistoryRows()
|
||||
{
|
||||
var history = InstanceService.KnownHistory(Post.Host);
|
||||
if (history is not { Count: > 0 })
|
||||
yield break;
|
||||
|
||||
yield return NerdRow.Of(Localizer["weeks known"], $"{history.Count} ({history[^1].Week} – {history[0].Week})");
|
||||
var changes = 0;
|
||||
for (var i = history.Count - 1; i >= 0 && changes < 6; i--)
|
||||
{
|
||||
var place = WeekPlace(history[i]);
|
||||
if (i < history.Count - 1 && place == WeekPlace(history[i + 1]))
|
||||
continue;
|
||||
yield return new(history[i].Week, place);
|
||||
changes++;
|
||||
}
|
||||
}
|
||||
|
||||
async Task DeleteMessage()
|
||||
string WeekPlace(InstanceWeek week) => week.Geo switch
|
||||
{
|
||||
isAnswering = false;
|
||||
await OnMessageDelete.InvokeAsync(Message);
|
||||
}
|
||||
{ Cdn: { Length: > 0 } cdn } => "☁ " + cdn,
|
||||
{ } geo => JoinPlace(geo.City, InstanceService.CountryName(geo.Country)) ?? Localizer["unknown"],
|
||||
_ => week.Reachable ? Localizer["unknown"] : Localizer["unreachable"]
|
||||
};
|
||||
|
||||
async Task SubmitReply(MessageForm messageForm)
|
||||
{
|
||||
isAnswering = false;
|
||||
await OnMessageReply.InvokeAsync(messageForm);
|
||||
}
|
||||
static string JoinPlace(params string[] parts) => parts.Where(p => !string.IsNullOrWhiteSpace(p)) is var known && known.Any() ? string.Join(", ", known) : null;
|
||||
|
||||
async Task Reply()
|
||||
IEnumerable<NerdRow> CdnRows()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
var location = Post.Location;
|
||||
var geo = ServerGeo;
|
||||
yield return NerdRow.Of("CDN", location.Label);
|
||||
yield return NerdRow.Of(Localizer["domain"], location.CdnKey);
|
||||
yield return NerdRow.Of(Localizer["found by"], geo?.CdnSource switch
|
||||
{
|
||||
"ranges" => Localizer["the CDN's published addresses"],
|
||||
"headers" => Localizer["the CDN's marks in the server's answers"],
|
||||
"asn" => Localizer["a network that carries only the CDN"],
|
||||
_ => geo?.CdnSource
|
||||
});
|
||||
isAnswering = !isAnswering;
|
||||
if (location.Before is { } before)
|
||||
yield return NerdRow.Of(Localizer["before the CDN"],
|
||||
$"{JoinPlace(before.City, InstanceService.CountryName(before.Country)) ?? Localizer["unknown"]} ({before.Week})");
|
||||
if (InstanceService.KnownCdn(location.CdnKey) is not { } cdn)
|
||||
yield break;
|
||||
|
||||
yield return NerdRow.Of(Localizer["servers behind it"], cdn.Servers);
|
||||
if (cdn.Ranges > 0)
|
||||
yield return NerdRow.Of(Localizer["published ranges"], $"{cdn.Ranges} ({NerdRow.Time(cdn.RangesUpdatedAt ?? default)})");
|
||||
var weeks = cdn.Weeks.Take(6).Reverse().ToList();
|
||||
if (weeks.Count > 0)
|
||||
yield return NerdRow.Of(Localizer["weekly"], string.Join(" · ", weeks.Select(w => $"{w.Week[^3..]} {w.Servers}" +
|
||||
(w.Joined.Count > 0 ? $" +{w.Joined.Count}" : null) + (w.Left.Count > 0 ? $" −{w.Left.Count}" : null))));
|
||||
}
|
||||
|
||||
IEnumerable<NerdRow> LocationRows()
|
||||
{
|
||||
var location = Post.Location;
|
||||
yield return new(Localizer["source"], location.Source switch
|
||||
{
|
||||
LocationSource.Place => Localizer["the post's own place"],
|
||||
LocationSource.Event => Localizer["the event's venue"],
|
||||
LocationSource.Server => Localizer["the author's server"],
|
||||
LocationSource.Country => Localizer["the author's server's country"],
|
||||
_ => Localizer["Near you"]
|
||||
});
|
||||
yield return NerdRow.Of("lat, lng", $"{Coordinate(location.Latitude)}, {Coordinate(location.Longitude)}");
|
||||
yield return NerdRow.Of(Localizer["precision"], $"±{location.PrecisionKm:0} km");
|
||||
if (location.Source is LocationSource.Server or LocationSource.Country && ServerGeo is { } geo)
|
||||
{
|
||||
yield return NerdRow.Of(Localizer["network"], geo.Network);
|
||||
yield return NerdRow.Of(Localizer["located by"], geo.Source);
|
||||
yield return NerdRow.Of(Localizer["located at"], geo.LocatedAt);
|
||||
}
|
||||
if (InstanceService.Known(InstanceService.SelfHost)?.Geo is { Latitude: { } selfLatitude, Longitude: { } selfLongitude } && location.Source is not LocationSource.NearYou)
|
||||
yield return new(Localizer["from PrivaPub"], $"{NerdRow.Kilometres(selfLatitude, selfLongitude, location.Latitude, location.Longitude):N0} km");
|
||||
}
|
||||
|
||||
static IEnumerable<NerdRow> MediaRows(MediaAttachment media)
|
||||
{
|
||||
yield return NerdRow.Of("type", media.Type);
|
||||
yield return NerdRow.Of("size", media.Meta?.Original?.Size);
|
||||
yield return NerdRow.Of("aspect", media.Meta?.Original?.Aspect);
|
||||
yield return NerdRow.Of("length", media.Meta?.Length);
|
||||
yield return NerdRow.Of("focus", media.Meta?.Focus is { } focus ? $"{focus.X:0.##}, {focus.Y:0.##}" : null);
|
||||
yield return NerdRow.Of("blurhash", media.Blurhash);
|
||||
yield return NerdRow.Of("served by", Uri.TryCreate(media.Url, UriKind.Absolute, out var served) ? served.Host : null);
|
||||
yield return NerdRow.Of("origin", Uri.TryCreate(media.RemoteUrl, UriKind.Absolute, out var origin) ? origin.Host : null);
|
||||
yield return NerdRow.Of("alt", media.Description is { Length: > 0 } alt ? $"{alt.Length} chars" : "none");
|
||||
}
|
||||
|
||||
IEnumerable<NerdRow> CountRows(Status status)
|
||||
{
|
||||
yield return NerdRow.Of(Localizer["replies"], status.RepliesCount);
|
||||
yield return NerdRow.Of(Localizer["boosts"], status.ReblogsCount);
|
||||
yield return NerdRow.Of(Localizer["Favourites"], status.FavouritesCount);
|
||||
yield return NerdRow.Of(Localizer["quotes"], status.QuotesCount > 0 ? status.QuotesCount : null);
|
||||
yield return NerdRow.Of(Localizer["votes"], status.Privapub?.Votes is { } votes && votes.Up + votes.Down > 0 ? $"+{votes.Up} −{votes.Down}" : null);
|
||||
if (status.FavouritesCount > 0)
|
||||
yield return NerdRow.Of(Localizer["boosts per favourite"], Math.Round((double)status.ReblogsCount / status.FavouritesCount, 2));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@inherits DComponentBase
|
||||
|
||||
@* daisyUI Button: https://daisyui.com/components/button/ . Renders an <a> when Href is set. *@
|
||||
@if (Href is null)
|
||||
{
|
||||
<button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick">
|
||||
@ChildContent
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a @attributes="AdditionalAttributes" href="@(Disabled ? null : Href)" class="@CssClass" role="button"
|
||||
aria-disabled="@(Disabled ? "true" : null)" tabindex="@(Disabled ? "-1" : null)" @onclick="OnClick">
|
||||
@ChildContent
|
||||
</a>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
[Parameter] public DColor Color { get; set; }
|
||||
[Parameter] public DButtonVariant Variant { get; set; }
|
||||
[Parameter] public DSize Size { get; set; }
|
||||
[Parameter] public DButtonShape Shape { get; set; }
|
||||
/// <summary>Pressed look (btn-active); the neo theme renders it inset.</summary>
|
||||
[Parameter] public bool Active { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public string Type { get; set; } = "button";
|
||||
[Parameter] public string Href { get; set; }
|
||||
[Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; }
|
||||
|
||||
string CssClass => Cx("btn", ColorClass, VariantClass, SizeClass, ShapeClass,
|
||||
Active ? "btn-active" : null, Disabled && Href is not null ? "btn-disabled" : null, Class);
|
||||
|
||||
string ColorClass => Color switch
|
||||
{
|
||||
DColor.Neutral => "btn-neutral",
|
||||
DColor.Primary => "btn-primary",
|
||||
DColor.Secondary => "btn-secondary",
|
||||
DColor.Accent => "btn-accent",
|
||||
DColor.Info => "btn-info",
|
||||
DColor.Success => "btn-success",
|
||||
DColor.Warning => "btn-warning",
|
||||
DColor.Error => "btn-error",
|
||||
_ => null
|
||||
};
|
||||
|
||||
string VariantClass => Variant switch
|
||||
{
|
||||
DButtonVariant.Outline => "btn-outline",
|
||||
DButtonVariant.Dash => "btn-dash",
|
||||
DButtonVariant.Soft => "btn-soft",
|
||||
DButtonVariant.Ghost => "btn-ghost",
|
||||
DButtonVariant.Link => "btn-link",
|
||||
_ => null
|
||||
};
|
||||
|
||||
string SizeClass => Size switch
|
||||
{
|
||||
DSize.Xs => "btn-xs",
|
||||
DSize.Sm => "btn-sm",
|
||||
DSize.Lg => "btn-lg",
|
||||
DSize.Xl => "btn-xl",
|
||||
_ => null
|
||||
};
|
||||
|
||||
string ShapeClass => Shape switch
|
||||
{
|
||||
DButtonShape.Wide => "btn-wide",
|
||||
DButtonShape.Block => "btn-block",
|
||||
DButtonShape.Square => "btn-square",
|
||||
DButtonShape.Circle => "btn-circle",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -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,66 @@
|
||||
@inherits DComponentBase
|
||||
|
||||
@* daisyUI Dropdown: https://daisyui.com/components/dropdown/ . The open state lives in C# (@bind-IsOpen):
|
||||
dropdown-open shows the content and dropdown-close keeps the focus-within rule from showing it while closed.
|
||||
While open, a transparent full-screen layer under the content closes it on an outside click; Escape closes it
|
||||
too. With Hover the CSS hover rule opens it instead. *@
|
||||
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
|
||||
<div role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
|
||||
@onclick="Toggle">
|
||||
@Trigger
|
||||
</div>
|
||||
@if (IsOpen)
|
||||
{
|
||||
<div class="fixed inset-0 z-[998] cursor-default" aria-hidden="true" @onclick="Close"></div>
|
||||
}
|
||||
<div tabindex="-1" class="@Cx("dropdown-content z-[999]", ContentClass)" @onclick="OnContentClick">
|
||||
@ChildContent
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment Trigger { get; set; }
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
[Parameter] public bool IsOpen { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
|
||||
[Parameter] public DDropdownPlacement Placement { get; set; }
|
||||
[Parameter] public DDropdownAlign Align { get; set; }
|
||||
/// <summary>Open on hover (dropdown-hover) as well as on click.</summary>
|
||||
[Parameter] public bool Hover { get; set; }
|
||||
/// <summary>Close after any click inside the content, e.g. on a menu item.</summary>
|
||||
[Parameter] public bool CloseOnContentClick { get; set; }
|
||||
[Parameter] public string TriggerClass { get; set; }
|
||||
[Parameter] public string ContentClass { get; set; }
|
||||
|
||||
string CssClass => Cx("dropdown", PlacementClass, AlignClass,
|
||||
IsOpen ? "dropdown-open" : Hover ? "dropdown-hover" : "dropdown-close", Class);
|
||||
|
||||
string PlacementClass => Placement switch
|
||||
{
|
||||
DDropdownPlacement.Top => "dropdown-top",
|
||||
DDropdownPlacement.Left => "dropdown-left",
|
||||
DDropdownPlacement.Right => "dropdown-right",
|
||||
_ => null
|
||||
};
|
||||
|
||||
string AlignClass => Align switch
|
||||
{
|
||||
DDropdownAlign.Center => "dropdown-center",
|
||||
DDropdownAlign.End => "dropdown-end",
|
||||
_ => null
|
||||
};
|
||||
|
||||
Task Toggle() => SetOpen(!IsOpen);
|
||||
|
||||
Task Close() => SetOpen(false);
|
||||
|
||||
Task OnContentClick() => CloseOnContentClick ? SetOpen(false) : Task.CompletedTask;
|
||||
|
||||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false) : Task.CompletedTask;
|
||||
|
||||
async Task SetOpen(bool isOpen)
|
||||
{
|
||||
IsOpen = isOpen;
|
||||
await IsOpenChanged.InvokeAsync(isOpen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace decePubClient.Components.Daisy.Actions;
|
||||
|
||||
/// <summary>Which side of the trigger the content opens on; Bottom is daisyUI's default.</summary>
|
||||
public enum DDropdownPlacement
|
||||
{
|
||||
Bottom,
|
||||
Top,
|
||||
Left,
|
||||
Right
|
||||
}
|
||||
|
||||
/// <summary>How the content lines up with the trigger along that side; Start is daisyUI's default.</summary>
|
||||
public enum DDropdownAlign
|
||||
{
|
||||
Start,
|
||||
Center,
|
||||
End
|
||||
}
|
||||
@@ -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,83 @@
|
||||
@inherits DComponentBase
|
||||
|
||||
@* daisyUI Modal: https://daisyui.com/components/modal/ . Always rendered; the open state lives in C# (@bind-IsOpen)
|
||||
and shows as modal-open. On opening, the box takes the focus (Blazor's FocusAsync) so that Escape, handled on the
|
||||
root, closes it; the backdrop button stretches over the area outside the box and closes it on click. *@
|
||||
<div @attributes="AdditionalAttributes" role="dialog" aria-modal="true" aria-labelledby="@(Heading is null ? null : uid)"
|
||||
class="@CssClass" @onkeydown="OnKeyDown">
|
||||
<div @ref="box" tabindex="-1" class="@Cx("modal-box focus:outline-none", BoxClass)">
|
||||
@if (Heading is not null)
|
||||
{
|
||||
<h3 id="@uid" class="text-lg font-bold">@Heading</h3>
|
||||
}
|
||||
@ChildContent
|
||||
@if (Actions is not null)
|
||||
{
|
||||
<div class="modal-action">
|
||||
@Actions
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (CloseOnBackdrop)
|
||||
{
|
||||
<div class="modal-backdrop">
|
||||
<button type="button" @onclick="Close">@CloseText</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment Heading { get; set; }
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
[Parameter] public RenderFragment Actions { get; set; }
|
||||
[Parameter] public bool IsOpen { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
|
||||
[Parameter] public DModalPlacement Placement { get; set; }
|
||||
/// <summary>Close on a click outside the box (renders the modal-backdrop).</summary>
|
||||
[Parameter] public bool CloseOnBackdrop { get; set; } = true;
|
||||
/// <summary>Text of the invisible backdrop button, read by screen readers.</summary>
|
||||
[Parameter] public string CloseText { get; set; } = "close";
|
||||
[Parameter] public string BoxClass { get; set; }
|
||||
|
||||
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
|
||||
ElementReference box;
|
||||
bool wasOpen;
|
||||
bool focusPending;
|
||||
|
||||
string CssClass => Cx("modal", PlacementClass, IsOpen ? "modal-open" : null, Class);
|
||||
|
||||
string PlacementClass => Placement switch
|
||||
{
|
||||
DModalPlacement.Top => "modal-top",
|
||||
DModalPlacement.Bottom => "modal-bottom",
|
||||
DModalPlacement.Start => "modal-start",
|
||||
DModalPlacement.End => "modal-end",
|
||||
_ => null
|
||||
};
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (IsOpen && !wasOpen)
|
||||
focusPending = true;
|
||||
wasOpen = IsOpen;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!focusPending)
|
||||
return;
|
||||
focusPending = false;
|
||||
await box.FocusAsync();
|
||||
}
|
||||
|
||||
Task Close() => SetOpen(false);
|
||||
|
||||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false) : Task.CompletedTask;
|
||||
|
||||
async Task SetOpen(bool isOpen)
|
||||
{
|
||||
IsOpen = isOpen;
|
||||
wasOpen = isOpen;
|
||||
await IsOpenChanged.InvokeAsync(isOpen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace decePubClient.Components.Daisy.Actions;
|
||||
|
||||
/// <summary>Where the box sits on the screen; Middle is daisyUI's default.</summary>
|
||||
public enum DModalPlacement
|
||||
{
|
||||
Middle,
|
||||
Top,
|
||||
Bottom,
|
||||
Start,
|
||||
End
|
||||
}
|
||||
@@ -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,40 @@
|
||||
@inherits DComponentBase
|
||||
|
||||
@* daisyUI Table: https://daisyui.com/components/table/ . ChildContent is the <thead>/<tbody>/<tfoot> markup. Class and
|
||||
the other attributes go on the <table>; with Scroll (the default) it sits in an overflow-x-auto div, whose classes
|
||||
are WrapperClass (pinned rows need it to have a height, e.g. h-96). *@
|
||||
@if (Scroll)
|
||||
{
|
||||
<div class="@Cx("overflow-x-auto", WrapperClass)">
|
||||
<table @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
[Parameter] public DSize Size { get; set; }
|
||||
[Parameter] public bool Zebra { get; set; }
|
||||
/// <summary>Sticky thead/tfoot rows while the wrapper scrolls vertically.</summary>
|
||||
[Parameter] public bool PinRows { get; set; }
|
||||
/// <summary>Sticky <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,50 @@
|
||||
@typeparam TValue
|
||||
@inherits DInputBase<TValue>
|
||||
|
||||
@* daisyUI Textarea: https://daisyui.com/components/textarea/ . Blazor sets the textarea's value property, so the bound
|
||||
value is the content; rows, placeholder and the like pass through. *@
|
||||
<textarea @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
|
||||
@oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)"></textarea>
|
||||
|
||||
@code {
|
||||
[Parameter] public DColor Color { get; set; }
|
||||
[Parameter] public DSize Size { get; set; }
|
||||
[Parameter] public bool Ghost { get; set; }
|
||||
/// <summary>Update the bound value on every keystroke instead of on change.</summary>
|
||||
[Parameter] public bool Immediate { get; set; }
|
||||
/// <summary>Adds daisyUI's validator class: error/success colors and a following DValidatorHint.</summary>
|
||||
[Parameter] public bool Validator { get; set; }
|
||||
|
||||
string CssClassFull => Cx("textarea", IsInvalid ? "textarea-error" : ColorClass, SizeClass, Ghost ? "textarea-ghost" : null,
|
||||
Validator ? "validator" : null, CssClass);
|
||||
|
||||
string ColorClass => Color switch
|
||||
{
|
||||
DColor.Neutral => "textarea-neutral",
|
||||
DColor.Primary => "textarea-primary",
|
||||
DColor.Secondary => "textarea-secondary",
|
||||
DColor.Accent => "textarea-accent",
|
||||
DColor.Info => "textarea-info",
|
||||
DColor.Success => "textarea-success",
|
||||
DColor.Warning => "textarea-warning",
|
||||
DColor.Error => "textarea-error",
|
||||
_ => null
|
||||
};
|
||||
|
||||
string SizeClass => Size switch
|
||||
{
|
||||
DSize.Xs => "textarea-xs",
|
||||
DSize.Sm => "textarea-sm",
|
||||
DSize.Lg => "textarea-lg",
|
||||
DSize.Xl => "textarea-xl",
|
||||
_ => null
|
||||
};
|
||||
|
||||
void OnInput(ChangeEventArgs e)
|
||||
{
|
||||
if (Immediate)
|
||||
CurrentValueAsString = e.Value?.ToString();
|
||||
}
|
||||
|
||||
void OnChange(ChangeEventArgs e) => CurrentValueAsString = e.Value?.ToString();
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
@inherits DComponentBase
|
||||
|
||||
@* An indicator-item of a DIndicator, placed on its corner or edge, e.g. with class="badge badge-primary". *@
|
||||
<span @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
[Parameter] public DIndicatorHorizontal Horizontal { get; set; }
|
||||
[Parameter] public DIndicatorVertical Vertical { get; set; }
|
||||
|
||||
string CssClass => Cx("indicator-item", HorizontalClass, VerticalClass, Class);
|
||||
|
||||
string HorizontalClass => Horizontal switch
|
||||
{
|
||||
DIndicatorHorizontal.Start => "indicator-start",
|
||||
DIndicatorHorizontal.Center => "indicator-center",
|
||||
_ => null
|
||||
};
|
||||
|
||||
string VerticalClass => Vertical switch
|
||||
{
|
||||
DIndicatorVertical.Middle => "indicator-middle",
|
||||
DIndicatorVertical.Bottom => "indicator-bottom",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@inherits DComponentBase
|
||||
|
||||
@* daisyUI Join: https://daisyui.com/components/join/ . Groups its direct children, rounding only the outer corners;
|
||||
give each of them the join-item class (e.g. <DButton class="join-item">). For a responsive group, add
|
||||
class="lg:join-horizontal" to a vertical one. *@
|
||||
<div @attributes="AdditionalAttributes" class="@CssClass">
|
||||
@ChildContent
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
[Parameter] public DDirection Direction { get; set; }
|
||||
|
||||
string CssClass => Cx("join", Direction == DDirection.Vertical ? "join-vertical" : null, Class);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@inherits DComponentBase
|
||||
|
||||
@* daisyUI Mask: https://daisyui.com/components/mask/ . Renders an <img> when Src is set, otherwise a <div> around
|
||||
ChildContent. daisyUI requires a Shape or a Half; size it with w-* and h-*. *@
|
||||
@if (Src is not null)
|
||||
{
|
||||
<img @attributes="AdditionalAttributes" src="@Src" class="@CssClass" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string Src { get; set; }
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
[Parameter] public DMaskShape Shape { get; set; }
|
||||
[Parameter] public DMaskHalf Half { get; set; }
|
||||
|
||||
string CssClass => Cx("mask", ShapeClass, HalfClass, Class);
|
||||
|
||||
string ShapeClass => Shape switch
|
||||
{
|
||||
DMaskShape.Squircle => "mask-squircle",
|
||||
DMaskShape.Heart => "mask-heart",
|
||||
DMaskShape.Hexagon => "mask-hexagon",
|
||||
DMaskShape.Hexagon2 => "mask-hexagon-2",
|
||||
DMaskShape.Decagon => "mask-decagon",
|
||||
DMaskShape.Pentagon => "mask-pentagon",
|
||||
DMaskShape.Diamond => "mask-diamond",
|
||||
DMaskShape.Circle => "mask-circle",
|
||||
DMaskShape.Star => "mask-star",
|
||||
DMaskShape.Star2 => "mask-star-2",
|
||||
DMaskShape.Triangle => "mask-triangle",
|
||||
DMaskShape.Triangle2 => "mask-triangle-2",
|
||||
DMaskShape.Triangle3 => "mask-triangle-3",
|
||||
DMaskShape.Triangle4 => "mask-triangle-4",
|
||||
_ => null
|
||||
};
|
||||
|
||||
string HalfClass => Half switch
|
||||
{
|
||||
DMaskHalf.First => "mask-half-1",
|
||||
DMaskHalf.Second => "mask-half-2",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace decePubClient.Components.Daisy.Layout;
|
||||
|
||||
public enum DMaskShape
|
||||
{
|
||||
None,
|
||||
Squircle,
|
||||
Heart,
|
||||
Hexagon,
|
||||
Hexagon2,
|
||||
Decagon,
|
||||
Pentagon,
|
||||
Diamond,
|
||||
Circle,
|
||||
Star,
|
||||
Star2,
|
||||
Triangle,
|
||||
Triangle2,
|
||||
Triangle3,
|
||||
Triangle4
|
||||
}
|
||||
|
||||
/// <summary>Keep only the first or second half of the shape (mask-half-1 / mask-half-2), as rating does.</summary>
|
||||
public enum DMaskHalf
|
||||
{
|
||||
None,
|
||||
First,
|
||||
Second
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@inherits DComponentBase
|
||||
|
||||
@* daisyUI Stack: https://daisyui.com/components/stack/ . The first child is on top; w-* and h-* on the stack make
|
||||
all its children the same size. *@
|
||||
<div @attributes="AdditionalAttributes" class="@CssClass">
|
||||
@ChildContent
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
[Parameter] public DStackPlacement Placement { get; set; }
|
||||
|
||||
string CssClass => Cx("stack", PlacementClass, Class);
|
||||
|
||||
string PlacementClass => Placement switch
|
||||
{
|
||||
DStackPlacement.Top => "stack-top",
|
||||
DStackPlacement.Start => "stack-start",
|
||||
DStackPlacement.End => "stack-end",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace decePubClient.Components.Daisy.Layout;
|
||||
|
||||
/// <summary>Which side the items behind the first one peek out from; Bottom is daisyUI's default.</summary>
|
||||
public enum DStackPlacement
|
||||
{
|
||||
Bottom,
|
||||
Top,
|
||||
Start,
|
||||
End
|
||||
}
|
||||
Loaded 100 of 307 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user