OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
Some checks failed
Security Audit / Composer Security Audit (push) Has been cancelled
Security Audit / NPM Security Audit (push) Has been cancelled
Security Audit / CodeQL Analysis (push) Has been cancelled

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,339 @@
# Frontend Componentization — Tracker & Playbook
> **Owner:** maintained by Claude as the single source of truth for the componentization
> effort. Supersedes the "Component Updates Tracker" spreadsheet (whose *status* column is
> stale — the taxonomy, naming, prop vocabulary, and priorities are kept).
## Goal
Route **all** of Leantime's HTML through a central component layer so that a future design
overhaul (e.g. daisyUI) becomes a one-file change instead of an N-thousand-call-site change.
## The rules (how we do this safely)
1. **No-op first.** Every component renders **byte-for-byte what the page renders today** —
same Bootstrap/`lt-`/`forms.css` classes. **Zero visual change.** We insert the abstraction
layer without touching the output.
2. **The prop API is the durable contract; the rendered classes are the swappable
implementation.** Call-sites are written against the canonical prop vocabulary (below) now.
At design time, only each component's internal class-map + the CSS change — restyling the
whole app from one place. This is the entire point.
3. **One component at a time, tested each step.** Build no-op component → verify identical
render (compile + Playwright before/after) → migrate call-sites in small batches → test →
commit → next. No big-bang merges (that's what broke `feature/ui-components`).
4. **Defer the design engine.** No daisyUI, no `tw-`-prefix churn, no JS rewrite during the
no-op phase. The design update (daisyUI or otherwise) is a later, separate phase that
becomes trivial *because* the component layer exists.
5. **Old branches are API reference only**, never a merge source (see Branch Landscape).
## Taxonomy & naming
Category-namespaced anonymous Blade components — resolves today with **no** ServiceProvider
change (nested folders already work):
```
<x-global::{category}.{name}> → app/Views/Templates/components/{category}/{name}.blade.php
```
Six categories: **`elements` · `forms` · `actions` · `navigation` · `feedback` · `layout`**.
Domain-specific components live under their domain namespace, e.g.
`<x-tickets::ticket-card>` → `app/Domain/Tickets/Templates/components/ticket-card.blade.php`.
## Prop vocabulary (the IDL — the durable contract)
| Prop | Options | Default | Notes |
|---|---|---|---|
| `contentRole` | default · primary · secondary · tertiary(=ghost) · accent · link | primary (actions) | semantic role |
| `state` | default · info · warning · danger · success | default | |
| `variant` | component-specific | `''` | behavior/shape variant |
| `scale` | xs · s · m · l · xl | m | size |
| `position` | left · right · top · bottom · inner · outer · start · end | bottom | |
| `tag` (element) | a · input · button · … | component-specific | polymorphic element |
| `align` | start · end | | |
| `labelText` | text | `''` | |
| `labelPosition` | top · left · right · bottom · inside | | |
| `caption` | text | `''` | helper text under the control |
| `validationText` / `validationState` | text / state | `''` | |
| `leadingVisual` / `trailingVisual` | icon class | `''` | |
| `items` | array | `[]` | for list-driven components |
> Props are **camelCase** in `@props` (`contentRole`); Blade normalizes `content-role="…"`
> attributes to the same variable, so call-sites may use either.
## No-op mapping principle (worked example: button)
The canonical vocabulary maps to **today's** classes so output is unchanged:
| canonical | renders today | (at design time →) |
|---|---|---|
| `contentRole="primary"` | `btn btn-primary` | `dui-btn dui-btn-primary` |
| `contentRole="secondary"` | `btn btn-secondary` | … |
| `contentRole="default"` | `btn btn-default` | … |
| `contentRole="tertiary"`/`ghost` | `btn btn-transparent` | … |
| `contentRole="link"` | `btn btn-link` | … |
| `state="danger"` | `btn btn-danger` | … |
| `scale="s"` / `scale="l"` | `btn btn-small` / `btn btn-large` | … |
Extra/legacy classes pass through via `$attributes->merge` (e.g. `class="addCanvasLink"`).
JS-coupled buttons (`dropdown-toggle`) are migrated in the **dropdown** component phase, not here.
## Component registry
Status: ⬜ todo · 🟡 in progress · ✅ no-op done (on master) · 🎨 design-updated.
"Ref" = branch to crib the prop API from (reference only — do not merge).
### P0 — primitives & core
| Component | Tag | Cat | Status | Ref | Notes |
|---|---|---|---|---|---|
| button | `forms.button` | forms | ✅ | refactor/table-component | merged #3531: no-op migration + 3-tier role model |
| text-input | `forms.text-input` | forms | ✅ | refactor/table-component | merged #3558: no-op; 146 call-sites / 56 files; variants `headline`/`large`/`small` (dropped `form`/`legacy` as CSS-redundant); HTML-native `type` prop; **defer JS-coupled** (datepickers/tags/inline-edit/color/sorter/hourCell) + legacy `<?php echo ?>`-in-attr |
| textarea | `forms.textarea` | forms | 🟡 | selectsComponentUpdates | PR #3562: thin no-op (attrs + inner-content slot); 10 plain migrated / 6 files; **defer Tiptap editors** (`.tiptapSimple`/`.tiptapComplex`/`.wiki-editor-textarea`) |
| select (native) | `forms.select` | forms | ⬜ | refactor/table-component | native no-op first; JS-enhanced later |
| form-field | `forms.field-row` | forms | ⬜ | refactor/table-component | label-row + caption + validation wrapper |
| card (content-box) | `elements.card` | elements | ⬜ | ui-components | **replaces `.maincontentinner`** (167 sites) |
| chip | `actions.chip` | actions | ⬜ | selectsComponentUpdates | |
| dropdown-menu | `actions.dropdown` | actions | ⬜ | refactor/table-component | JS-coupled (Bootstrap dropdown) |
| modal | `actions.modal` | actions | ⬜ | modal line | unify 3 legacy modal systems; HxComponent-aligned |
| tabs | `navigation.tabs` | navigation | ✅ | ui-components | ARIA button-tablist (roving tabindex, Arrow/Home/End, storage prop, lt:tabs:changed event); vanilla JS, htmx.onLoad-aware; variants attached/floating; tab+panel sub-components (no raw contract HTML in consumers); jQuery-UI wrapper retired (deliberate markup change, called out) |
| text-editor | `forms.text-editor` | forms | ⬜ | (Tiptap core) | wrap Tiptap (already HTMX-aware) |
| date-picker | `forms.date-picker` | forms | ⬜ | selectsComponentUpdates | jQuery-UI datepicker; needs htmx.onLoad re-init |
### P1
| Component | Tag | Cat | Status | Notes |
|---|---|---|---|---|
| checkbox | `forms.checkbox` | forms | ⬜ | |
| radio | `forms.radio` | forms | ⬜ | |
| toggle | `forms.toggle` | forms | ⬜ | |
| button-group | `forms.button-group` | forms | ⬜ | |
| badge | `elements.badge` | elements | ⬜ | flat `badge` exists on master — migrate to category |
| avatar | `elements.avatar` | elements | ⬜ | flat `avatar` exists on master |
| accordion | `elements.accordion` | elements | ⬜ | flat `accordion` exists on master |
| table | `elements.table` | elements | ⬜ | DataTables-coupled; class-backed (`Table.php`) |
| empty-state | `elements.empty-state` | elements | ⬜ | wraps `undrawSvg` |
| date-info | `elements.date-info` | elements | ⬜ | relative-time |
| statistic / code | `elements.statistic` / `elements.code` | elements | ⬜ | |
| steps / breadcrumbs / pagination | `navigation.*` | navigation | ⬜ | |
| alert / progress / skeleton / loading / indicator | `feedback.*` | feedback | ⬜ | `loader`/`loadingText` exist on master |
| page-header | `layout.page-header` | layout | ⬜ | flat `pageheader` exists on master |
| color-picker / select-panel / context-menu | various | ⬜ | |
### Domain-specific
| Component | Tag | Status | Notes |
|---|---|---|---|
| ticket-card | `tickets::ticket-card` | ⬜ | **= the tile from `refactor/card-column-components`** |
| ticket-column | `tickets::ticket-column` | ⬜ | **= `column` from `refactor/card-column-components`** |
| milestone-card | `tickets::milestone-card` | ⬜ | |
| project-card | `projects::project-card` | ⬜ | |
| comments list | `comments::list` | ⬜ | HxController-backed |
## Card naming resolution (decided)
- `elements.card` = the glass **content-box** that replaces `.maincontentinner`.
- The small **tile** I shipped on `refactor/card-column-components` becomes `tickets::ticket-card`.
- My `column` becomes `tickets::ticket-column`.
- `refactor/card-column-components` is **superseded** — its work folds into the above; the
Logic Model board will consume `tickets::*` + `elements.card`.
## Branch landscape (reference only — DO NOT merge)
| Branch | Age | Use as | Verdict |
|---|---|---|---|
| `feature/ui-components` | fresh (Feb 2026) | richest reference: daisyUI theme, full category layer, 11/12 P0, domain cards, JS modules | reference; broke features as a big-bang — harvest APIs, don't merge |
| `refactor/table-component` | ~2024 | **best forms/table/form-field + prop IDL + `Table.php`** | reference |
| `selectsComponentUpdates` | Jan 2025 | superset forms incl. chip/datepicker/select + 113 call-site examples | reference |
| `feature/leantime-design-tokens` | 2024 | daisyUI theme + Material-3 palette token values | reference (for design phase) |
| modal line (`feature/modal-component`) | 2024 | `<dialog>` + hash-routed global page-modal pattern | reference (rebuild on HxComponent) |
| `refactor/javascript-to-modules-…` | 2024 | full domain-JS ESM conversion (still pending eventually) | reference |
| `feature/card-component`, `feature/table-component`, `left-nav-design-fix`, `file-component`, `button/text-input/checkbox-radio-component`, `commentsComponent` | 2024 | stale/subsumed | reference at most |
## JS-backed component pattern (the standard)
Copy **Tiptap** (`public/assets/js/app/core/tiptap/index.js`) — the only widget already correct:
- markup carries a `data-lt-*` initializer attribute (never an inline `<script>`),
- one central **idempotent registry** per widget type (`WeakMap`, `data-…-initialized` guard),
- wired to **`htmx.onLoad`** (init on first paint + every swap) and, where teardown is needed,
`htmx:beforeSwap`/`htmx:afterSwap`,
- heavy bundles lazy-loaded via `Template::requireComponents([...])` / `needsComponent()`.
This fixes the SlimSelect / Chosen / jQuery-UI-datepicker / tabs / inlineSelect bug where
inline `jQuery(document).ready` init runs only on first paint and breaks after HTMX swaps.
## ⚠️ Gotcha: no double-quotes inside a component attribute value
Blade parses **component** attributes more strictly than plain HTML. A `"` inside a `{{ }}`
expression within an attribute value terminates the attribute early and breaks the tag —
even though the same markup works as a raw `<a href="...">`. So when migrating:
- `href="{{ $x["key"] }}"` → use `{{ $x['key'] }}` (single-quote the array key), or `:link="$x['key']"`.
- `href="{{ BASE_URL . "/path/$id" }}"` → use `link="{{ BASE_URL }}/path/{{ $id }}"` (Blade interpolation).
- `class="{{ $c ? "a" : "b" }}"` → single-quote the strings, or compute in `@php`.
Run the brace/quote-aware scan (forms.button opening tags with a `"` inside any `{{ }}`) after any
button migration batch — `view:cache` does NOT catch these (they fail at render, not compile).
## ⚠️ Gotcha: no legacy `<?php echo ?>` / `<?= ?>` inside a component attribute value
Raw PHP echo tags work in a plain `<input placeholder="<?php echo … ?>">` (PHP executes at render),
but Laravel's **component-tag compiler** treats a non-bound attribute value as a *literal string*, so
`<?php … ?>` inside a `<x-…>` attribute does NOT reliably execute. **Leave such inputs RAW** (or first
modernize the echo to `{{ … }}` / `{!! $tpl->escape(…) !!}` in a separate step, then migrate). Found in
`Auth/userInvite` (placeholders use `<?php echo $tpl->language->__('…') ?>`) — deferred. Scan migrated
tags for `<?php` / `<?=` before committing.
## Per-component playbook (repeatable)
1. Read what the primitive renders today (classes, JS hooks, every call-site shape).
2. Build the **no-op** component under the right category, full prop IDL, mapping to today's classes.
3. `php bin/leantime view:cache` + `vendor/bin/pint --test` (syntactic gate).
4. Migrate a **small pilot** batch of call-sites; **Playwright before/after** to prove zero visual diff.
5. Migrate the rest in batches, re-verifying; commit per batch.
6. Update this tracker (status, gotchas, call-site count migrated).
## Button migration — deferral backlog (handle in later passes)
The no-op migration deliberately defers buttons it can't migrate without changing the rendered
class set / behavior. Categories found (to revisit, some need a design decision):
- ~~**`class="button"` (not `btn`)**~~ — DONE (#3563): a CSS audit found `.button` has **no rule at
all**; `input[type='submit']` is styled by the `.btn-primary` element-selector group (forms.css:313), so
these 44 submits already render as primary buttons. Migrated all 44 to
`<x-global::forms.button tag="input" inputType="submit" contentRole="primary">` (no-op). Also cleaned up a
few pre-existing duplicate `class="button" class="button"` attrs. **Follow-up:** ~16 are `del*` confirmation
submits that look primary today — candidates for `state="danger"` in a later semantic pass (a visual change,
not a no-op).
- ~~**Unstyled `<input type="submit">`** (no class)~~ — DONE (#3564, round 2): NOT a design change after all —
`input[type='submit']` is in the `.btn-primary` element-selector group (forms.css:313), so bare submits
**already looked primary**. Migrated to `contentRole="primary"` (~30 of them). **Intended visual no-op**, not
strictly byte-identical: the component adds the shared `.btn` base (`input.btn { vertical-align: top; … }`)
which a bare submit lacked — imperceptible, but worth stating precisely.
- **Unmapped btn variants** — `btn-sm`/`btn-lg` (vs Leantime `btn-small`/`btn-large`),
`btn-danger-outline`, `btn-circle`, `btn-inverse`, `btn-file`. Add mappings (after confirming CSS) or keep deferred.
- **role+state combo** (`btn btn-default btn-success`) — component currently emits one color; allow coexistence.
- ~~`<a onclick>` without `href`~~ — DONE: component emits `href` only when `link` is set; migrate these by omitting the `link` prop.
- **dropdown-toggle / data-toggle / fileupload / span.btn** — handled in the dropdown / file-upload / later phases.
## Text-input migration — scope & defer rubric
`forms.text-input` is a **thin no-op**: it emits a plain `<input>` with today's class (default = no
class) and passes all attributes through; the label/validation IDL props are declared but not rendered
(a wrapper would change markup — that's the design phase). Pass the **HTML-native `type=`** (it is a
declared `@prop`, so Blade extracts it from the attribute bag — emits exactly one `type`, never a duplicate).
- ✅ **Migrate (146 done in PR #3558; more in follow-ups):** standard inputs (bare), headline title inputs
(`main-title-input` → `variant="headline"`), search inputs. Map source class → `variant`; any extra
non-variant class (tw-utilities, `pull-left`, …) passes through `class=`.
**`.form-control` AND `.input` → bare** (NOT variants): both are pure Bootstrap cruft — forms.css element
selectors override `.form-control`, and `.input` has *no backing CSS rule at all*; a bare input renders
identically (the entry-page width that `.form-control` gave comes from `.regpanelinner input{width:100%}`).
### Variant taxonomy (evidence-backed — 4-agent CSS audit)
Only visually-distinct treatments earn a variant. Verdicts:
| variant | class | real? | what it actually is |
|---|---|---|---|
| `headline` | `.main-title-input` | ✅ | large 24/26px (`--font-size-xxxl`) title font + `box-shadow:none`; keeps border/bg |
| `large` | `.input-large` | ✅ (width-only) | fixed `width:210px` — forms.css never sets width, so it survives |
| `small` | `.input-small` | ✅ (width-only) | fixed `width:90px` |
| `ghost` *(planned)* | `.secretInput` | ✅ | inline-edit "looks like text until touched": transparent, no border/shadow, hover/focus reveal box. Pending its async-save JS migration. |
| ~~`form`~~ | `.form-control` | ❌ removed | overridden by forms.css element selectors |
| ~~`legacy`~~ | `.input` | ❌ removed | no `.input` CSS rule exists anywhere |
- ⛔ **Leave RAW — do-not-touch signals** (JS-coupled; breaking these regresses behavior):
- **datepickers** (jQuery-UI): `.dates .duedates .quickDueDates .dateFrom .dateTo .editFrom .editTo
.startDate .endDate .projectDateFrom .projectDateTo .week-picker .hasDatepicker` + ids `#deadline
#sprintStart #sprintEnd #event_date_* #date #startDate #endDate #timesheetdate #invoiced* #paidDate`
(many init via inline `<script>` in the template + an a11y pass on `.hasDatepicker`).
- **time**: `.timepicker`, `type="time"`, `#dueTime #timeFrom #timeTo`.
- **tags**: `#tags` (+ `#tags_tag`/`#tags_tagsinput`), `.tagsinputField`, `data-role="tagsinput"`, `#wikiTagsInput`.
- **inline-edit / async-save**: `.secretInput`, `.asyncInputUpdate` (+ `data-label` / `data-id`).
- **color**: `.simpleColorPicker`. **honeypot**: `.ohnohoney`.
- **JS grids / clone-templates**: `.hourCell` (timesheet grid), `.sorter` + `name`/`id` clone markers
like `XXNEWKEYXX` or pipe-keyed `name="new|GENERAL_BILLABLE|…"`.
- **dynamic `class`/`id`** built with `{{ }}` / `{!! !!}` (can't statically classify → defer).
- **legacy `<?php echo ?>` / `<?= ?>` in an attribute value** (see gotcha above).
- **any inline `onchange` / `onblur` / `onkeyup` / `oninput` / `onfocus` handler**.
## Progress log
- _Phase 0_: tracker created; `feature/componentization` branched off master; card-naming resolved.
- _button_: no-op `forms.button` built + 2 correctness fixes (native button-type, no default color).
- _button pilot_: `Auth/login` migrated; Playwright before/after = byte-identical (proven).
- _button batch 1_: ~65 plain buttons migrated across 46 core form/admin/CRUD templates (9-agent
fan-out, disjoint files); ~70 deferred per the backlog above. Verified: view:cache compiles,
audit shows no JS-coupled class swallowed, real before/after on /users/showAll = identical class set.
- _button href tweak_: component emits href only when `link` is set (so `<a onclick>` w/o href migrates).
- _button batch 2_: ~100 plain buttons migrated across 43 JS-heavy templates (Tickets, Dashboard,
Widgets, Canvas/Blueprints/Goalcanvas/Logicmodel, Ideas, Wiki, Calendar, Sprints); the rest deferred
(dropdown-toggles, fc-* calendar, file-uploads, class="button", unmapped variants, role+state).
Verified: compile clean, audit clean, live no-op spot-check on /goalcanvas/showCanvas.
**Core plain-button migration is now essentially complete** — remaining work = the deferral backlog
(dropdowns get migrated in the dropdown-component phase; class="button"/unstyled = design decisions).
- _button role sanity pass_: 15 Back/Cancel/"Go Back" buttons that were hard-coded btn-primary in the
original markup demoted to contentRole="secondary" (alternative/navigate-away actions). Only the role
VALUE changed. This is intentionally NOT a no-op (appearance changes; secondary is unstyled until the
design phase).
- _button role promotions_: 5 main-action submits that were `default` promoted to `primary` for
consistency with siblings — Ideas board create/save (advancedBoards + showBoards, ×4) and the
Comments/showAll reply (generalComment's reply was already primary). Genuinely-secondary `default`
buttons (Back, Export, Copy, Reset Logo, Resend Invite, Close, Activate) left as-is.
- _button outline variant_: added `variant="outline"` to forms.button (emits btn-outline /
btn-{state}-outline). All "Save & Close" buttons set to variant="outline" to match the edit-ticket
save style (7 sites: 5 canvas/idea dialogs + the ticketDetails/articleDialog inputs componentized).
- _action-links -> secondary_: ~35 standalone Cancel/Back/Close/Delete/Remove links that were bare
`<a>` text-links (no btn class) converted to `<x-global::forms.button ... contentRole="secondary">`,
preserving onclick + JS-hook classes (delete/formModal/editTimeModal/...). Strictly skipped: dropdown
`<li>` menu-items (incl. menu delete/edit), accordion + inline `|`-separated toggles, add/create
toggles, nav, timers, and already-`btn` links. Still bare (flagged, not converted): inline per-comment
`deleteComment` links + per-row table delete actions (would need a smaller-scale/inline treatment).
- _text-input_: thin no-op `forms.text-input` built on `feature/text-input-component` (off master, post-#3531).
Scope + datepicker/tags/inline-edit defer rubric above. **PR #3558.**
- _text-input pilot_: `Projects/newProject` headline (`main-title-input` → `variant="headline"`) migrated;
Playwright = byte-identical (same class/type/name/id/style/value/placeholder); the two `.dateFrom/.dateTo`
datepickers on the same page left RAW (component never applied to JS-coupled inputs → can't regress).
(Note: dev instance currently isn't loading `compiled-app`/jQuery, so runtime datepicker init couldn't be
exercised — but the datepicker DOM is byte-identical to master since those lines are untouched.)
- _text-input sweep_: **146 call-sites across 56 files** migrated (63-file 2-phase workflow: per-file migrate
+ adversarial diff-verify; all 63 verified ok). Diff is perfectly symmetric (202 ins / 202 del = pure
in-place swaps). Static audit of all 146: 0 problems (no `type=`/inputType dup, no variant class left in
`class=`, no JS-coupled signal swallowed, no nested-quote, no dup attrs). Compile + Pint clean. Live render
no-op confirmed on `/setting/editCompanySettings` (`pull-left` passthrough) + `/clients/newClient` (bare).
**Deferred to follow-ups:** `Auth/userInvite` (3 inputs w/ legacy `<?php echo ?>` in attrs — see gotcha),
`Tickets/partials/ticketCard` + `partials/subtasks` (HTMX inline-edit/date), and everywhere the
do-not-touch signals (datepickers/tags/inline-edit/color/`sorter`/`hourCell`/dynamic-class).
- _text-input API refinement (review feedback)_: two API cleanups after review.
(1) **`inputType` → `type`**: renamed the prop to the HTML-native `type` (17 call-sites). It's a declared
`@prop`, so Blade extracts it from the attribute bag → exactly one `type`, no duplication. (`forms.button`
keeps `inputType` because it's polymorphic — `type` is ambiguous across a/button/input.)
(2) **dropped `variant="form"`** (the `form`/`bordered`→`.form-control` arm). 3-agent CSS audit proved
`.form-control` is cosmetically redundant in Leantime: `forms.css` element selectors (`input[type=text]…`,
loaded after Bootstrap) override its bg/border/radius/shadow/padding/height/color, and the only residual
effect (desktop `width:100%`) is already supplied by container rules (`.regpanelinner input{width:100%}`)
for the sole 7 call-sites (login ×2, twoFA/verify ×1, install ×4 — all entry pages). No JS hooks
`.form-control` on inputs. Collapsed those 7 to bare; live render on `/auth/login` = bare inputs, single
`type`, no `form-control`. Bare IS the form look now.
- _text-input variant taxonomy (review feedback)_: 4-agent CSS audit to keep ONLY evidence-backed variants.
Findings: `headline`(.main-title-input) = REAL (large `--font-size-xxxl` font + shadow removed);
`large`(.input-large)/`small`(.input-small) = REAL but width-only (210px/90px — the one prop forms.css
doesn't set); `ghost`(.secretInput) = REAL inline-edit treatment (4 distinct low-chrome looks found, the
canonical one being .secretInput) but its call-sites are the deferred async-save fields, so it's a planned
variant; `legacy`(.input) = REDUNDANT (no `.input` CSS rule exists anywhere). **Dropped `variant="legacy"`**
(1 call-site, TwoFA/edit → bare; removed the arm). Component now exposes only `headline`/`large`/`small`.
- _textarea_: thin no-op `forms.textarea` (#3562). Body is `<textarea {{ $attributes }}>{{ $slot }}</textarea>`
— attributes pass through, the field value is the slot (inner content) preserved EXACTLY (textareas are
whitespace-sensitive). **10 plain textareas migrated across 6 files** (Help projectDefinitionStep ×3,
Ideas/Wiki newMilestone, Timesheets add/edit + Tickets timesheet description, Widgets myToDos
description-input ×2). **19 Tiptap editor textareas left RAW** — JS upgrades exactly `textarea.tiptapSimple`
/ `textarea.tiptapComplex` (core/tiptap/index.js) plus the Wiki `.wiki-editor-textarea`; never route those
through the component. No `variant` arm (plain textareas carry no distinct style class; the only textarea
classes are editor-coupled).
- _button + text-input completion (round 2)_: swept blade for buttons/inputs missed by #3531/#3558.
**53 migrated across 38 files**: 29 bare `<input type=submit>` (no class — already looked primary via
forms.css:313, so `contentRole="primary"` is an intended **visual** no-op; the `.btn` base adds minor props
like `vertical-align`, imperceptible), 4 token-UI text inputs/buttons, Errors back ×4,
support sponsor, Auth token UI (create/copy/close/delete), Files cancel ×2, widgetManager reset
(btn-outline→secondary), Reports chart toggles ×6, showProject delete (btn-danger-outline→state=danger
variant=outline), 1 comment reply. `btn-sm`/`btn-lg`/`btn-secondary` (own CSS, ≠ Leantime's
small/large/outline) passed through `class=` pending a design-phase scale/role mapping.
**Left deferred (correct):** 3 comment `btn-success` role+state combos (component emits one color);
`partials/subtasks` quickadd (nested `__("…")` + HTMX file); dynamic-class links (calendarSettings,
Dashboard favoriteProject); `ticketFilter` raw `<a>` (whitespace-sensitive, intentional); custom non-`btn`
widget buttons (Wiki collapse/panel, calendar day-button, todoItem reset); modal `data-dismiss`/`.close`,
Files `.delete` icons, file-upload `picSubmit`, dropdown-toggles, `<?php echo` invite variants.
Verified: compile + Pint clean, 0 button-tag problems, diff is tag swaps (multiline tags collapse to 1 line).
ALSO: TimesheetCest selectors that clicked `.button` repointed to `input[type=submit]`/name (the `.button`
class is removed by the migration) — see #3563.

View File

@@ -0,0 +1,37 @@
@props([
'state' => $tpl->getToggleState("accordion_content-".$id) == 'closed' ? 'closed' : 'open',
'id'
])
<div {{ $attributes->merge([ 'class' => 'accordionWrapper' ]) }}>
@if(isset($actionlink) && $actionlink != '')
<div class="pull-right tw-pt-xs tw-pr-xs">
{!! $actionlink !!}
</div>
@endif
<a
href="javascript:void(0)"
class="accordion-toggle {{ $state }}"
id="accordion_toggle_{{ $id }}"
onclick="leantime.snippets.accordionToggle('{{ $id }}');"
>
<h5 {{ $title->attributes->merge([
'class' => 'accordionTitle tw-pb-15 tw-text-l',
'id' => "accordion_link_$id"
]) }}>
<i class="fa fa-angle-{{ $state == 'closed' ? 'right' : 'down' }}"></i>
{!! $title !!}
</h5>
</a>
<div {{ $content->attributes->merge([
'class' => "simpleAccordionContainer $state",
'id' => "accordion_content-$id",
'style' => $state =='closed' ? 'display:none;' : ''
]) }}>
{!! $content !!}
</div>
</div>

View File

@@ -0,0 +1,197 @@
{{-- OneBot 界面内 AI 助手:右下角悬浮聊天窗 --}}
@if(session()->has('userdata'))
<style>
#onebotAiFab {
position: fixed; right: 22px; bottom: 22px; z-index: 9998;
width: 56px; height: 56px; border-radius: 50%;
background: #4f46e5; color: #fff; border: none; cursor: pointer;
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
font-size: 24px; display: flex; align-items: center; justify-content: center;
}
#onebotAiFab:hover { background: #4338ca; }
#onebotAiPanel {
position: fixed; right: 22px; bottom: 90px; z-index: 9999;
width: 420px; min-width: 320px; max-width: 94vw; height: 560px; min-height: 320px; max-height: 90vh;
background: #fff; border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,0.28);
display: none; flex-direction: column; overflow: hidden;
}
#onebotAiPanel.open { display: flex; }
#onebotAiHeader {
padding: 12px 16px; background: #4f46e5; color: #fff;
display: flex; justify-content: space-between; align-items: center; flex: none;
}
#onebotAiHeader .title { font-weight: 600; font-size: 15px; }
#onebotAiHeader .cfg { cursor: pointer; opacity: .85; font-size: 13px; }
#onebotAiHeader .cfg:hover { opacity: 1; }
#onebotAiHeader .close { cursor: pointer; font-size: 18px; line-height: 1; opacity: .85; }
#onebotAiHeader .close:hover { opacity: 1; }
#onebotAiMessages {
flex: 1; overflow-y: auto; padding: 14px; background: #f8fafc;
display: flex; flex-direction: column; gap: 10px;
}
#onebotAiMessages .msg { max-width: 82%; padding: 9px 12px; border-radius: 10px; font-size: 13px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
#onebotAiMessages .msg.user { align-self: flex-end; background: #4f46e5; color: #fff; border-bottom-right-radius: 3px; }
#onebotAiMessages .msg.ai { align-self: flex-start; background: #fff; border: 1px solid #e2e8f0; color: #0f172a; border-bottom-left-radius: 3px; }
#onebotAiMessages .msg.err { align-self: flex-start; background: #fef2f2; border: 1px solid #fecaca; color: #b91c1c; }
#onebotAiInput {
flex: none; border-top: 1px solid #e2e8f0; padding: 10px; background: #fff;
display: flex; gap: 8px; align-items: flex-end;
}
#onebotAiInput textarea {
flex: 1; resize: vertical; min-height: 42px; max-height: 260px;
border: 1px solid #cbd5e1; border-radius: 8px;
padding: 8px 10px; font-size: 13px; height: 42px; outline: none;
line-height: 1.5;
}
#onebotAiInput textarea:focus { border-color: #4f46e5; }
#onebotAiInput button {
background: #4f46e5; color: #fff; border: none; border-radius: 8px;
padding: 0 16px; font-size: 13px; cursor: pointer; flex: none; height: 42px;
}
#onebotAiInput button:hover { background: #4338ca; }
#onebotAiInput button:disabled { background: #a5b4fc; cursor: not-allowed; }
#onebotAiResize {
position: absolute; right: 0; bottom: 0; width: 16px; height: 16px;
cursor: nwse-resize; z-index: 10000;
background: linear-gradient(135deg, transparent 0 45%, #cbd5e1 45% 55%, transparent 55% 100%);
}
</style>
<button id="onebotAiFab" title="AI 助手"><span class="fa fa-robot"></span></button>
<div id="onebotAiPanel">
<div id="onebotAiHeader">
<span class="title"><span class="fa fa-robot"></span> AI 助手</span>
<span>
<span class="cfg" id="onebotAiCfg" title="AI 设置"><span class="fa fa-gear"></span></span>
<span class="close" id="onebotAiClose" title="关闭">&times;</span>
</span>
</div>
<div id="onebotAiMessages"></div>
<div id="onebotAiInput">
<textarea id="onebotAiText" placeholder="输入指令,例如:列出所有 BOM" rows="1"></textarea>
<button id="onebotAiSend">发送</button>
</div>
<div id="onebotAiResize" title="拖动调整大小"></div>
</div>
<script>
jQuery(document).ready(function () {
var BASE = '{{ BASE_URL }}';
var csrf = function () { return jQuery('meta[name="csrf-token"]').attr('content') || ''; };
var history = []; // [{role, content}]
var $fab = jQuery('#onebotAiFab');
var $panel = jQuery('#onebotAiPanel');
var $msgs = jQuery('#onebotAiMessages');
var $text = jQuery('#onebotAiText');
var $send = jQuery('#onebotAiSend');
function addMsg(role, content) {
var cls = role === 'user' ? 'user' : (role === 'err' ? 'err' : 'ai');
jQuery('<div class="msg ' + cls + '"></div>').text(content).appendTo($msgs);
$msgs.scrollTop($msgs[0].scrollHeight);
}
function send() {
var text = $text.val().trim();
if (!text) { return; }
addMsg('user', text);
history.push({ role: 'user', content: text });
$text.val('');
$send.prop('disabled', true);
addMsg('ai', '思考中…');
jQuery.ajax({
url: BASE + '/ai/chat',
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrf() },
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({ messages: history })
}).done(function (res) {
jQuery('#onebotAiMessages .msg:last').remove();
var reply = res.content || '';
addMsg('ai', reply);
history.push({ role: 'assistant', content: reply });
}).fail(function (xhr) {
jQuery('#onebotAiMessages .msg:last').remove();
var m = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : ('HTTP ' + xhr.status);
addMsg('err', m);
}).always(function () {
$send.prop('disabled', false);
$text.trigger('focus');
});
}
$fab.on('click', function () { $panel.toggleClass('open'); if ($panel.hasClass('open')) $text.trigger('focus'); });
jQuery('#onebotAiClose').on('click', function () { $panel.removeClass('open'); });
$send.on('click', send);
$text.on('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } });
// 拖动调整面板大小
(function () {
var $resize = jQuery('#onebotAiResize');
var startX, startY, startW, startH;
$resize.on('mousedown', function (e) {
e.preventDefault();
e.stopPropagation();
startX = e.clientX;
startY = e.clientY;
startW = $panel.width();
startH = $panel.height();
jQuery(document).on('mousemove.aiResize', function (e) {
var w = startW + (e.clientX - startX);
var h = startH + (e.clientY - startY);
// 应用 min/max 约束(与 CSS 一致)
w = Math.max(320, Math.min(w, window.innerWidth * 0.94));
h = Math.max(320, Math.min(h, window.innerHeight * 0.9));
$panel.css({ width: w + 'px', height: h + 'px' });
});
jQuery(document).on('mouseup.aiResize', function () {
jQuery(document).off('mousemove.aiResize mouseup.aiResize');
});
});
// 触屏支持
$resize.on('touchstart', function (e) {
var t = e.originalEvent.touches[0];
startX = t.clientX;
startY = t.clientY;
startW = $panel.width();
startH = $panel.height();
});
$resize.on('touchmove', function (e) {
e.preventDefault();
var t = e.originalEvent.touches[0];
var w = startW + (t.clientX - startX);
var h = startH + (t.clientY - startY);
w = Math.max(320, Math.min(w, window.innerWidth * 0.94));
h = Math.max(320, Math.min(h, window.innerHeight * 0.9));
$panel.css({ width: w + 'px', height: h + 'px' });
});
})();
// AI 设置:弹出配置(预留地址填写)
jQuery('#onebotAiCfg').on('click', function () {
jQuery.ajax({
url: BASE + '/ai/config',
method: 'GET',
dataType: 'json'
}).done(function (res) {
var d = res.data || {};
addMsg('ai', '当前 AI 配置:\nProvider: ' + (d.provider || '-') +
'\nBaseURL: ' + (d.baseUrl || '-') +
'\nModel: ' + (d.model || '-') +
'\nAPI Key: ' + (d.apiKeyMasked || '未配置') +
'\n\n(配置在 .env 的 AI_* 变量,或联系管理员填写)');
}).fail(function () {
addMsg('err', '读取 AI 配置失败');
});
});
});
</script>
@endif

View File

@@ -0,0 +1,36 @@
@props([
'userId' => null,
'username' => '',
'size' => 'md',
])
@php
$sizeMap = [
'xs' => ['dim' => '24px', 'font' => '11px'],
'sm' => ['dim' => '28px', 'font' => '13px'],
'md' => ['dim' => '32px', 'font' => '14px'],
'lg' => ['dim' => '40px', 'font' => '16px'],
'xl' => ['dim' => '50px', 'font' => '20px'],
];
$s = $sizeMap[$size] ?? $sizeMap['md'];
$initials = '';
if ($username) {
$parts = explode(' ', trim($username));
$initials = strtoupper(substr($parts[0], 0, 1));
if (count($parts) > 1) {
$initials .= strtoupper(substr(end($parts), 0, 1));
}
}
$useColor = $username && $username !== 'Unassigned';
$defaultBg = '#D1D5DB';
$textColor = $useColor ? '#FFFFFF' : '#6B7280';
@endphp
<div {{ $attributes->merge(['class' => 'user-avatar']) }}
style="display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; flex-shrink: 0; background: {{ $useColor ? 'var(--accent1)' : $defaultBg }}; width: {{ $s['dim'] }}; height: {{ $s['dim'] }}; font-size: {{ $s['font'] }};"
@if($username) data-tippy-content="{{ $username }}" @endif>
<span style="font-weight: 600; color: {{ $textColor }};">{{ $initials }}</span>
</div>

View File

@@ -0,0 +1,27 @@
@props([
'asLink' => false,
'color' => match ($color ?? null) {
'yellow' => ['tw-yellow-500', 'tw-bg-yellow-500'],
'red' => ['tw-red-500', 'tw-bg-red-500'],
'blue' => ['tw-blue-500', 'tw-bg-blue-500'],
'green' => ['tw-green', 'tw-bg-green'],
'primary' => ['tw-primary', 'tw-bg-primary'],
'gray' => ['tw-gray-500', 'tw-bg-gray-500'],
default => ['tw-gray-500', 'tw-bg-gray-500'],
},
])
@if ($asLink)
<a
@else
<span
@endif
{{ $attributes->merge([
'class' => 'tw-px-2.5 tw-py-0.5 tw-rounded ' . ($asLink ? 'text-white ' . $color[1] : $color[0] . ' tw-bg-gray-300'),
] + ($asLink ? ['href' => $url ?? '#'] : [])) }}>
{{ $slot }}
@if ($asLink)
</a>
@else
</span>
@endif

View File

@@ -0,0 +1,11 @@
@props([
'link' => '#',
'type' => 'primary',
'tag' => 'a',
])
<{{ $tag }} {{ $attributes->merge([
'class' => 'btn btn-' . $type
] + ($tag == 'a' ? ['href' => $link] : [])) }}>
{{ $slot }}
</{{ $tag }}>

View File

@@ -0,0 +1,54 @@
@props([
'type' => '',
'selectedClass' => '',
'selectedKey' => '',
'parentId' => '',
'options' => [],
'extraClass' => '',
'linkStyle' => '',
'submit' => "false"
])
<div {{ $attributes->merge([ 'class' => '' ]) }} >
<div class="dropdown ticketDropdown {{ $type }}Dropdown show {{ $extraClass }}">
<a style="{{ $linkStyle }}" class="dropdown-toggle f-left {{ $type }} {{ $selectedClass }}" href="javascript:void(0);" role="button" id="{{ $type }}DropdownMenuLink{{ $parentId }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">
@if(isset($options[$selectedKey]))
@if(is_array($options[$selectedKey]))
{{ $options[$selectedKey]['name'] }}
@else
{{ $options[$selectedKey] }}
@endif
@else
{{ __("label.".$type."_unknown") }}
@endif
</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="{{ $type }}DropdownMenuLink{{ $parentId }}">
<li class="nav-header border"> {{ __("label.select_".$type) }}</li>
@foreach ($options as $key => $value)
<li class='dropdown-item'>
<a href='javascript:void(0);' class="dropdownPillLink"
id='{{ $type }}Change{{ $parentId }}{{ $key }}'
onclick="jQuery('#dropdownPill-{{ $parentId }}-{{ $type }}').val('{{ $key }}'); @if($submit !== "false") document.querySelector('{{ $submit }}').submit(); @endif"
@if(is_array($value))
class='{{ $type }}-bg-{{ $key }} {{ $value["class"] }}'
data-label='{{ $value["name"] }}'
data-value='{{ $parentId }}_{{ $key }}_{{ $value["class"] }}'
>
{{ $value["name"] }}
@else
class='{{ $type }}-bg-{{ $key }}'
data-value='{{ $parentId }}_{{ $key }}'
>
{{ $value }}
@endif
</a>
</li>
@endforeach
</ul>
</div>
<input type="hidden" name="{{ $type }}" value="{{ $selectedKey }}" id="dropdownPill-{{ $parentId }}-{{ $type }}" />
</div>

View File

@@ -0,0 +1,78 @@
@props([
'value' => '',
'id' => '',
'placeholder' => '',
'class' => '',
'name' => ''
])
@php
$uniqueId = uniqid();
@endphp
<div class="emojiInput">
<input type="text" name="{{ $name }}" {{ $attributes->merge(['class' => 'emojifield emojiFieldId'.$uniqueId.' '.$class]) }} value="{{ $value }}" placeholder="{{ $placeholder }}" id="{{ $id }}" />
<a class="emojibtn emojibtnId{{ $uniqueId }} fa-regular fa-face-smile" href="javascript:void(0);">&nbsp;</a>
</div>
<script>
jQuery(document).ready(function(){
new EmojiPicker({
trigger: [
{
selector: '.emojibtnId{{ $uniqueId }}',
insertInto: '.emojiFieldId{{ $uniqueId }}'
}
],
closeButton: true,
specialButtons: 'green' // #008000, rgba(0, 128, 0);
});
});
</script>
<style>
.emojiInput {
position:relative;
}
.emojiInput a.emojibtn {
font-size:var(--font-size-xxl);
color:var(--neutral);
margin-left: -35px;
background:var(--secondary-background);
position:absolute;
right: 7px;
top: 8px;
}
.fg-emoji-container {
box-shadow:var(--large-shadow);
}
.fg-emoji-nav {
background-color: var(--secondary-background);
}
.fg-emoji-nav li a svg {
fill: var(--primary-font-color);
}
.fg-emoji-picker-search {
position: relative;
margin-top: 15px;
padding: 0px 10px;
}
.fg-emoji-list li {
height:30px;
}
.fg-picker-special-buttons {
display:none;
}
.fg-emoji-picker-category-title {
margin-top:10px;
}
.emojifield {
width:100%;
}
</style>

View File

@@ -0,0 +1,93 @@
@props([
'contentRole' => '', // ''(none) | default | primary | secondary | tertiary(=ghost) | accent | link
'state' => '', // info | warning | danger | success
'scale' => '', // xs | s | m | l | xl
'variant' => '', // 'outline' = outline style (btn-outline / btn-{state}-outline)
'tag' => 'button', // a | button | input (the polymorphic element)
'link' => null, // href, when tag="a"; null => emit no href (e.g. <a onclick> with no href)
'inputType' => null, // submit | button | reset, when tag="button"/"input" (default below)
'leadingVisual' => '', // icon class, e.g. "fa fa-plus"
'trailingVisual' => '', // icon class
'labelText' => '', // text label; falls back to the slot
])
{{--
forms.button — NO-OP button.
Renders the exact Bootstrap/forms.css classes the app uses TODAY (btn, btn-primary,
btn-danger, btn-small, …) so there is zero visual change. Call-sites are written against
the canonical prop vocabulary (contentRole/state/scale); at design time ONLY the maps
below + the CSS change, restyling every button from one place. See COMPONENTS.md.
Migration cheatsheet (today's class -> prop):
btn-primary -> contentRole="primary" btn-default -> contentRole="default"
btn-secondary -> contentRole="secondary" btn-transparent -> contentRole="ghost"
btn-link -> contentRole="link" btn-danger/info/success/warning -> state="…"
btn-small/btn-large -> scale="s"/"l" extra classes -> pass as class="…"
JS-coupled buttons (.dropdown-toggle) are migrated in the dropdown phase, not here.
--}}
@php
// Role carries the emphasis AND its default look:
// primary = filled, secondary = outline, tertiary/ghost = transparent (low-chrome).
// (variant="outline" is only needed to force outline on a non-secondary role, e.g. state+outline.)
$roleClass = match ($contentRole) {
'primary' => 'btn-primary',
'secondary' => 'btn-outline',
'default' => 'btn-default',
'tertiary', 'ghost' => 'btn-transparent',
'accent' => 'btn-primary',
'link' => 'btn-link',
default => '',
};
// State color is mutually exclusive with the role color today (a danger button is
// `btn btn-danger`, not `btn-primary btn-danger`). If a state is given, it wins.
$stateClass = match ($state) {
'danger' => 'btn-danger',
'warning' => 'btn-warning',
'success' => 'btn-success',
'info' => 'btn-info',
default => '',
};
$scaleClass = match ($scale) {
'xs', 's', 'sm' => 'btn-small',
'l', 'lg', 'xl' => 'btn-large',
default => '',
};
// variant="outline" selects the outline button style — btn-outline, or btn-{state}-outline
// (e.g. btn-danger-outline). This is the same style the edit-ticket save / "Save & Close"
// buttons use. Outline overrides the role color.
if ($variant === 'outline') {
// Only build btn-{state}-outline for a VALIDATED state (so state="default" -> btn-outline,
// never btn-default-outline). $stateClass is '' for default/unknown states.
$colorClass = $stateClass !== '' ? 'btn-'.$state.'-outline' : 'btn-outline';
} else {
$colorClass = $stateClass !== '' ? $stateClass : $roleClass;
}
$classes = trim('btn '.$colorClass.' '.$scaleClass);
// Inner content: leading icon + (labelText or slot) + trailing icon, matching the
// hand-written "<i class="fa …"></i> Label" markup buttons use today.
$hasLabel = trim($labelText) !== '';
@endphp
@if ($tag === 'input')
<input
type="{{ $inputType ?? 'submit' }}"
value="{{ $hasLabel ? $labelText : trim($slot) }}"
{{ $attributes->merge(['class' => $classes]) }}
/>
@elseif ($tag === 'a')
{{-- emit href only when a link is given, so <a onclick> without href stays href-less --}}
<a {{ $attributes->merge(['class' => $classes] + ($link !== null ? ['href' => $link] : [])) }}>
@if ($leadingVisual)<i class="{{ $leadingVisual }}"></i> @endif{{ $hasLabel ? $labelText : $slot }}@if ($trailingVisual) <i class="{{ $trailingVisual }}"></i>@endif
</a>
@else
{{-- Bare <button> emits NO type so the native default (submit inside a form) is preserved;
pass inputType only when the source had an explicit type. --}}
<button @if ($inputType !== null) type="{{ $inputType }}" @endif {{ $attributes->merge(['class' => $classes]) }}>
@if ($leadingVisual)<i class="{{ $leadingVisual }}"></i> @endif{{ $hasLabel ? $labelText : $slot }}@if ($trailingVisual) <i class="{{ $trailingVisual }}"></i>@endif
</button>
@endif

View File

@@ -0,0 +1,70 @@
@props([
// NO-OP variant -> the class the app renders TODAY. Default '' = a bare, unclassed input
// (the common case: ~206 inputs have no class and are styled by their form/context).
'variant' => '', // '' (bare) | headline | large | small
// Only EVIDENCE-BACKED, visually-distinct variants exist here:
// headline -> .main-title-input (large 24/26px title font, drop-shadow removed)
// large -> .input-large (fixed 210px width — width only)
// small -> .input-small (fixed 90px width — width only)
// NO "form" or "legacy" variant: `.form-control` and `.input` are pure Bootstrap
// cruft — forms.css element selectors override them, so a bare input is identical.
// (Ghost/inline-edit `.secretInput` is a real future variant, pending its async-save JS.)
'type' => 'text', // text | email | password | number | url | tel | search (HTML-native; Blade extracts it from $attributes so it never duplicates)
// --- design-system IDL: declared for the durable contract, but intentionally NOT rendered
// in no-op mode (a label/validation wrapper would change today's markup). They become
// active when the design phase introduces the field-row/label layout. ---
'contentRole' => '', // reserved
'state' => '', // info | warning | danger | success (validation) — reserved
'scale' => '', // xs | s | m | l | xl — reserved
'labelPosition' => 'top', // reserved
'labelText' => '', // reserved
'caption' => '', // reserved
'validationText' => '', // reserved
'validationState' => '', // reserved
'leadingVisual' => '', // reserved
'trailingVisual' => '', // reserved
])
{{--
forms.text-input — NO-OP text input.
Renders a plain <input> with the SAME class the app uses TODAY (default: NO class). Every
other attribute (name, id, value, placeholder, style, data-*, hx-*, autocomplete, required,
maxlength, autofocus, …) passes straight through via $attributes. Zero visual/behaviour change.
⚠️ DO NOT route JS-coupled inputs through this component — they will break. Keep these RAW:
• date pickers: .dates .duedates .quickDueDates .dateFrom .dateTo .editFrom .editTo
.startDate .endDate .projectDateFrom .projectDateTo .week-picker .hasDatepicker
#deadline #sprintStart #sprintEnd #event_date_* #date #startDate #endDate #timesheetdate …
• time: .timepicker, type="time" • tags: #tags .tagsinputField data-role="tagsinput"
• inline-edit: .secretInput .asyncInputUpdate (+ data-label / data-id)
• color: .simpleColorPicker • any inline onchange/onblur/onkeyup/oninput handler
See COMPONENTS.md for the full do-not-touch list.
Pass the input type via the HTML-native `type="…"` attribute. It is a declared @prop, so Blade
extracts it from the attribute bag — the component emits exactly one `type` (never duplicated).
Omit it for a plain text input (default "text").
Migration cheatsheet (source class -> variant):
<input type="text" name=…> -> <x-global::forms.text-input name=…> (bare, no class)
<input type="email" class="form-control"> -> type="email" (drop form-control; it's redundant)
<input class="main-title-input"> -> variant="headline"
<input class="input-large"> -> variant="large"
<input class="input"> (no CSS / cruft) -> (bare; .input has no backing rule)
--}}
@php
// No-op map: variant -> the exact class the markup uses today.
$variantClass = match ($variant) {
'headline' => 'main-title-input',
'large' => 'input-large',
'small' => 'input-small',
default => '', // bare / search: no class (styled by context / id / name)
};
// Only add a class attribute when there's actually a class — so a bare input stays
// class-less (no empty class="") exactly like today.
$attrs = $variantClass !== '' ? $attributes->merge(['class' => $variantClass]) : $attributes;
@endphp
<input type="{{ $type }}" {{ $attrs }} />

View File

@@ -0,0 +1,45 @@
@props([
// NO-OP textarea: renders a plain <textarea> with today's attributes + inner content.
// There is NO `variant` arm: the only textarea style-classes in the app (.tiptapSimple /
// .tiptapComplex / .wiki-editor-textarea) are JS rich-text EDITOR mounts — never route those
// through this component (see do-not-touch below). Plain textareas carry no distinct style
// class, so attribute + content passthrough is the whole no-op surface.
// --- design-system IDL: declared for the durable contract (shared with forms.text-input),
// intentionally NOT rendered in no-op mode (a label/validation wrapper would change
// today's markup). Activated in the design phase's field-row layout. ---
'contentRole' => '', // reserved
'state' => '', // info | warning | danger | success (validation) — reserved
'scale' => '', // xs | s | m | l | xl — reserved
'labelPosition' => 'top', // reserved
'labelText' => '', // reserved
'caption' => '', // reserved
'validationText' => '', // reserved
'validationState' => '', // reserved
])
{{--
forms.textarea — NO-OP textarea.
Renders a plain <textarea> with the SAME attributes the app uses today. Every attribute EXCEPT the
declared @props above (name, id, rows, cols, placeholder, style, class, data-*, hx-*, required, …)
passes through via $attributes — Blade extracts declared props (state, contentRole, scale, labelText, …)
so they are NOT emitted as HTML (they're reserved for the design phase). The field's value is the slot
(inner content), preserved EXACTLY.
⚠️ DO NOT route rich-text EDITOR textareas through this component — JS upgrades them to Tiptap
and they will break. Keep these RAW:
• Tiptap: class="tiptapSimple" / class="tiptapComplex" (JS scans `textarea.tiptapSimple` /
`textarea.tiptapComplex` and mounts an editor — public/assets/js/app/core/tiptap/index.js)
• Wiki editor: class="wiki-editor-textarea" (id="wikiArticleContent")
• any textarea with an inline on* handler or a data-*editor* attribute.
⚠️ Whitespace matters: a textarea's value IS its inner content. Keep the slot tight —
<x-global::forms.textarea …>{{ $value }}</x-global::forms.textarea> — never add newlines/indent
around the value, or you change the field's content.
Migration:
<textarea name="x"></textarea> -> <x-global::forms.textarea name="x"></x-global::forms.textarea>
<textarea name="x">{{ $v }}</textarea> -> <x-global::forms.textarea name="x">{{ $v }}</x-global::forms.textarea>
--}}
<textarea {{ $attributes }}>{{ $slot }}</textarea>

View File

@@ -0,0 +1,82 @@
{{--
Mount point for an HTMX-backed component (Type 2).
Renders the standard lazy-load wrapper + loading placeholder and fetches the component's content
via htmx. Works two ways:
Contract-driven (drift-proof) — pass an HxComponent class; route + refresh events are read
from the class so emit/listen sides share one enum:
<x-global::hx :for="\Leantime\Domain\Tickets\Hxcontrollers\Subtasks::class" :id="$ticketId" />
Attribute-driven (escape hatch for one-offs / plugins) — pass the endpoint + events explicitly:
<x-global::hx endpoint="comments/reactions/get" :id="$commentId"
:listen="[\Leantime\Domain\Tickets\Htmx\HtmxTicketEvents::UPDATE]" />
Props:
for FQCN of an HxComponent (contract-driven mode).
id Entity id, appended to the route and used to scope listen events.
action Override the mounted action (defaults to the component's $mountAction).
endpoint Route segment after /hx/ (attribute-driven mode), e.g. "comments/reactions/get".
trigger Initial load trigger. Default "revealed" (loads when scrolled into view).
listen Event(s) that should re-fetch the component (attribute-driven mode).
target/swap Standard htmx overrides. swap defaults to the component's $swap, else innerHTML.
vals Array serialized into hx-vals.
loader loadingText skeleton type. loaderCount number of skeleton rows.
--}}
@props([
'for' => null,
'id' => null,
'wrapperId' => null,
'action' => null,
'endpoint' => null,
'trigger' => 'revealed',
'listen' => [],
'target' => null,
'swap' => null,
'vals' => null,
'indicator' => '.htmx-indicator',
'loader' => 'text',
'loaderCount' => 1,
])
@php
$listenEvents = [];
$resolvedSwap = $swap;
if ($for && is_string($for) && is_subclass_of($for, \Leantime\Core\Controller\HxComponent::class)) {
$resolvedAction = \Illuminate\Support\Str::kebab($action ?? $for::$mountAction);
$path = trim($for::route(), '/').'/'.$resolvedAction.($id !== null ? '/'.$id : '');
$resolvedSwap = $resolvedSwap ?? $for::$swap;
foreach ($for::listensTo() as $event) {
$listenEvents[] = $id !== null ? $event->scoped($id) : $event->event();
}
} else {
$path = trim((string) $endpoint, '/');
foreach ((array) $listen as $event) {
$listenEvents[] = $event instanceof \Leantime\Core\Events\Htmx\HtmxEvent ? $event->event() : (string) $event;
}
}
$resolvedSwap = $resolvedSwap ?? 'innerHTML';
$triggerParts = array_merge([$trigger], array_map(fn ($event) => $event.' from:body', $listenEvents));
$triggerAttr = implode(', ', array_filter($triggerParts));
$url = rtrim(BASE_URL, '/').'/hx/'.$path;
@endphp
<div
@if($wrapperId) id="{{ $wrapperId }}" @endif
hx-get="{{ $url }}"
hx-trigger="{{ $triggerAttr }}"
@if($target) hx-target="{{ $target }}" @endif
hx-swap="{{ $resolvedSwap }}"
{{-- JSON_HEX_* escapes ' " < & so a value can't break out of the single-quoted attribute. --}}
@if($vals !== null) hx-vals='{!! json_encode($vals, JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP) !!}' @endif
hx-indicator="{{ $indicator }}"
{{ $attributes }}
>
<x-global::loadingText :type="$loader" :count="$loaderCount" includeHeadline="false" />
</div>

View File

@@ -0,0 +1,11 @@
<div class="tw-flex tw-gap-base tw-justify-start">
@foreach ($links as $link)
@if (empty($link['display']))
@continue
@endif
<span>
{{ $link['prefix'] ?? '' }} @if (! empty($link['link'])) <a href="{!! $link['link'] !!}">{!! $link['display'] !!}</a> @else {!! $link['display'] !!} @endif
</span>
@endforeach
</div>

View File

@@ -0,0 +1,50 @@
@props([
'id' => '',
'formName' => '',
'options' => [],
'selected' => '',
'noSelection' => ''
])
<span class="dropdown">
<a
href="javascript:void(0);"
role="button"
id="{{ $id }}-link"
{{ $attributes->merge(["class" => "dropdown-toggle"]) }}
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">
@if(empty($selected['value']))
<span style="opacity:0.4">{{ $noSelection }}</span>
@else
{{ $selected['value'] }}
@endif
</span>
<i class="fa fa-chevron-down"
style="font-size: 10px;
vertical-align: middle;"></i>
</a>
<ul class="dropdown-menu" id="{{ $id }}-options">
@foreach($options as $key => $option)
<li><a href="javascript:void(0);" data-id="{{ $key }}">{{ $option }}</a></li>
@endforeach
</ul>
</span>
<input type="hidden" name="{{ $formName }}" id="{{ $id }}-formField" value="{{ $selected['key'] }}" />
<script>
jQuery("#{{ $id }}-options li a").each(function() {
jQuery(this).click(function() {
var newText = jQuery(this).text();
var id = jQuery(this).attr("data-id");
jQuery('#{{ $id }}-link .text').text(newText);
jQuery("#{{ $id }}-formField").val(id).trigger("change");
htmx.trigger("#{{ $id }}-formField", "change");
});
});
</script>

View File

@@ -0,0 +1,66 @@
@props([
'statusCounts' => [],
'statusColumns' => [],
'totalCount' => 0,
'expandOnHover' => true,
'size' => 'md'
])
@php
// Create segments for ALL status columns (even with 0 count)
// This ensures JavaScript can update any segment when tickets move
$segments = [];
foreach ($statusColumns as $statusId => $label) {
$count = $statusCounts[$statusId] ?? 0;
$percentage = ($totalCount > 0 && $count > 0) ? ($count / $totalCount) * 100 : 0;
$segments[] = [
'id' => $statusId,
'count' => $count,
'percentage' => round($percentage, 1),
'label' => is_array($label) ? ($label['name'] ?? $label['label'] ?? "Status {$statusId}") : $label,
];
}
$heights = [
'collapsed' => ['sm' => '4px', 'md' => '5px', 'lg' => '6px'],
'expanded' => ['sm' => '18px', 'md' => '22px', 'lg' => '26px']
];
$collapsedHeight = $heights['collapsed'][$size] ?? '5px';
$expandedHeight = $heights['expanded'][$size] ?? '22px';
@endphp
<div {{ $attributes->merge(['class' => 'micro-progress-bar']) }}
role="progressbar"
aria-label="Status breakdown"
style="position: relative; width: 100%;"
onmouseenter="this.querySelector('.progress-segments').style.height='{{ $expandedHeight }}'; this.querySelector('.progress-segments').style.borderRadius='4px';"
onmouseleave="this.querySelector('.progress-segments').style.height='{{ $collapsedHeight }}'; this.querySelector('.progress-segments').style.borderRadius='2.5px';">
<div class="progress-segments"
style="display: flex; align-items: stretch; height: {{ $collapsedHeight }}; border-radius: 2.5px; overflow: hidden; background-color: #D4D4D4; width: 100%; transition: height 0.2s ease, border-radius 0.2s ease; cursor: {{ $expandOnHover && $totalCount > 0 ? 'pointer' : 'default' }};">
@foreach($segments as $segment)
{{-- Render ALL segments (including empty ones) so JavaScript can update them after card moves --}}
<div class="status-segment status-{{ $segment['id'] }}"
style="flex: {{ $segment['percentage'] }} 1 0%; overflow: hidden;"
data-tippy-content="{{ $segment['count'] > 0 ? $segment['label'] . ': ' . $segment['count'] : '' }}">
<span class="segment-count">{{ $segment['count'] > 0 ? $segment['count'] : '' }}</span>
</div>
@endforeach
</div>
@if($totalCount > 0)
<!-- Screen reader summary -->
<span style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0;">
@foreach($segments as $segment)
@if($segment['count'] > 0)
{{ $segment['label'] }}: {{ $segment['count'] }}.
@endif
@endforeach
</span>
@else
<!-- Empty state for screen readers -->
<span style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0;">
No tasks in this group.
</span>
@endif
</div>

View File

@@ -0,0 +1,15 @@
@props([
'label' => '',
'size' => 'md'
])
@php
$sizes = ['sm' => '14px', 'md' => '16px', 'lg' => '20px'];
$fontSize = $sizes[$size] ?? '16px';
@endphp
<span {{ $attributes->merge(['class' => 'milestone-icon']) }}
style="font-size: {{ $fontSize }}; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 24px;"
data-tippy-content="Milestone: {{ $label }}"
role="img"
aria-label="Milestone: {{ $label }}">🎯</span>

View File

@@ -0,0 +1,15 @@
@props([
'label' => '',
'size' => 'md'
])
@php
$sizes = ['sm' => '14px', 'md' => '16px', 'lg' => '20px'];
$fontSize = $sizes[$size] ?? '16px';
@endphp
<span {{ $attributes->merge(['class' => 'sprint-icon']) }}
style="font-size: {{ $fontSize }}; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 24px;"
data-tippy-content="Sprint: {{ $label }}"
role="img"
aria-label="Sprint: {{ $label }}">🏃</span>

View File

@@ -0,0 +1,150 @@
@props([
'groupBy' => 'priority',
'groupId' => null,
'label' => '',
'totalCount' => 0,
'statusCounts' => [],
'statusColumns' => [],
'expanded' => true,
'moreInfo' => null,
'timeAlert' => null
])
@php
use Leantime\Domain\Tickets\Models\TicketDesignTokens;
// Determine which icon component to use
$iconComponent = match($groupBy) {
'priority' => 'thermometer-icon',
'storypoints' => 'tshirt-icon',
'effort' => 'tshirt-icon',
'editorId' => 'user-avatar',
'milestoneid' => 'milestone-icon',
'type' => 'type-icon',
'sprint' => 'sprint-icon',
'dueDate' => null, // No icon for due date buckets - label is sufficient
default => null // Status and other groupings use FontAwesome icon below
};
// For groupBy types without a component, use FontAwesome icon
$faIcon = match($groupBy) {
'status' => 'fa-circle-dot',
'milestoneid' => null, // No icon for milestones
'dueDate' => null, // No icon for due date buckets - label is sufficient
default => 'fa-layer-group'
};
$iconProps = match($groupBy) {
'priority' => ['priority' => (int)$groupId],
'storypoints' => ['effort' => (float)$groupId],
'effort' => ['effort' => (float)$groupId],
'editorId' => ['userId' => $groupId, 'username' => $label],
'type' => ['type' => $groupId],
default => ['label' => $label]
};
// Effort groupby shows size label next to icon
$effortLabel = '';
if (in_array($groupBy, ['storypoints', 'effort'])) {
$effortLabel = TicketDesignTokens::getEffort((float)$groupId)['tshirtLabel'] ?? '';
}
// Strip existing profileImage HTML from label for editorId (we use user-avatar component instead)
if ($groupBy === 'editorId') {
$label = preg_replace('/<div class=[\'"]profileImage[\'"]>.*?<\/div>\s*/i', '', $label);
}
// Transform statusColumns for micro-progress-bar
$statusLabels = [];
foreach ($statusColumns as $statusId => $statusData) {
if (is_array($statusData)) {
$statusLabels[$statusId] = $statusData['name'] ?? $statusData['label'] ?? "Status $statusId";
} else {
$statusLabels[$statusId] = $statusData;
}
}
@endphp
{{-- PRD v2 Compliant: 150px horizontal layout with two rows --}}
{{-- Outer container stretches full height, inner content scrolls/sticks --}}
<div {{ $attributes->merge(['class' => 'kanban-swimlane-sidebar']) }}
data-swimlane-id="{{ $groupId }}"
tabindex="0"
role="button"
aria-expanded="{{ $expanded ? 'true' : 'false' }}"
aria-controls="swimlane-content-{{ $groupId }}"
aria-label="{{ strip_tags($label) }} - {{ $totalCount }} tasks - {{ $expanded ? 'Expanded' : 'Collapsed' }}"
onclick="leantime.kanbanController.toggleSwimlane('{{ $groupId }}')"
onkeydown="if(event.key === 'Enter' || event.key === ' ') { event.preventDefault(); leantime.kanbanController.toggleSwimlane('{{ $groupId }}'); }">
{{-- Inner content wrapper - this receives the transform for sticky behavior --}}
<div class="kanban-swimlane-sidebar-inner">
{{-- Row 1: Chevron + Icon + Label + Time Indicator --}}
<div class="swimlane-header-row1">
{{-- Chevron (▼ expanded, ▶ collapsed) --}}
<span class="kanban-lane-chevron">
<i class="fa fa-chevron-{{ $expanded ? 'down' : 'right' }}"></i>
</span>
{{-- Visual indicator (icon/avatar) --}}
@if($iconComponent)
<div class="kanban-indicator">
<x-dynamic-component
:component="'global::kanban.' . $iconComponent"
:attributes="new \Illuminate\View\ComponentAttributeBag($iconProps)"
size="md"
/>
</div>
@else
{{-- Default FontAwesome icon for status and other groupings --}}
<span class="kanban-indicator">
<i class="fa {{ $faIcon }} kanban-indicator-icon"></i>
</span>
@endif
{{-- Label - truncates with ellipsis --}}
<span class="swimlane-header-label" data-tippy-content="{{ strip_tags($label) }}">
{!! $label !!}
</span>
{{-- Time indicator (⏳ ⏰ 💤) - hidden when collapsed --}}
@if($timeAlert)
<span class="swimlane-time-indicator">
<x-global::kanban.time-indicator :type="$timeAlert" />
</span>
@endif
{{-- Count Badge (inline) - only visible when collapsed --}}
<span class="kanban-lane-count kanban-lane-count--inline" data-tippy-content="{{ $totalCount }} tasks">{{ $totalCount }}</span>
</div>
{{-- Row 2: Progress Bar + Count Badge --}}
<div class="swimlane-header-row2">
{{-- Micro Progress Bar (status breakdown) - always shown, gray when empty --}}
<div style="flex: 1; min-width: 0;">
<x-global::kanban.micro-progress-bar
:statusCounts="$statusCounts"
:statusColumns="$statusLabels"
:totalCount="$totalCount"
:expandOnHover="true"
size="lg"
/>
</div>
{{-- Count Badge --}}
<span class="kanban-lane-count" data-tippy-content="{{ $totalCount }} tasks">{{ $totalCount }}</span>
</div>
</div>{{-- .kanban-swimlane-sidebar-inner --}}
</div>
{{-- Tooltip shown on hover for long labels --}}
@if(strlen(strip_tags($label)) > 12 || $moreInfo)
<div class="kanban-sidebar-tooltip">
<div class="tooltip-label">{!! $label !!}</div>
@if($moreInfo)
<div class="tooltip-info">{!! $moreInfo !!}</div>
@endif
</div>
@endif

View File

@@ -0,0 +1,65 @@
@props([
'priority' => 3,
'size' => 'md',
'showLabel' => false
])
@php
use Leantime\Domain\Tickets\Models\TicketDesignTokens;
$token = TicketDesignTokens::getPriority($priority);
$fillPercent = $token['fill'] ?? 0.6;
$label = $token['label'] ?? 'Medium';
$color = $token['color'] ?? '#F5A623';
$sizes = [
'sm' => ['width' => 16, 'height' => 24],
'md' => ['width' => 18, 'height' => 28],
'lg' => ['width' => 22, 'height' => 34]
];
$sizeConfig = $sizes[$size] ?? $sizes['md'];
@endphp
<span {{ $attributes->merge(['class' => 'thermometer-icon']) }}
style="display: inline-flex; align-items: center; gap: 4px;"
data-tippy-content="{{ $label }} Priority">
<svg
width="{{ $sizeConfig['width'] }}"
height="{{ $sizeConfig['height'] }}"
viewBox="0 0 14 24"
style="flex-shrink: 0;">
<!-- Background/outline -->
<path
d="M7 2C5 2 3.5 3.5 3.5 5.5V14.5C1.8 15.5 1 17 1 18.5C1 21 3 23 7 23C11 23 13 21 13 18.5C13 17 12.2 15.5 10.5 14.5V5.5C10.5 3.5 9 2 7 2Z"
fill="#F5F5F0"
stroke="#D4D4D4"
stroke-width="1.5"/>
<!-- Colored fill (varies by priority) -->
<rect
x="5"
y="{{ 17 - ($fillPercent * 10) }}"
width="4"
height="{{ ($fillPercent * 10) + 1 }}"
fill="{{ $color }}"/>
<!-- Bulb (colored) -->
<circle cx="7" cy="18.5" r="3.5" fill="{{ $color }}"/>
<!-- Tick marks -->
<line x1="10.5" y1="7" x2="12" y2="7" stroke="#D4D4D4" stroke-width="1"/>
<line x1="10.5" y1="10" x2="12" y2="10" stroke="#D4D4D4" stroke-width="1"/>
<line x1="10.5" y1="13" x2="12" y2="13" stroke="#D4D4D4" stroke-width="1"/>
</svg>
@if($showLabel)
<span style="font-size: 14px; font-weight: 500; color: {{ $color }};">
{{ $label }}
</span>
@endif
<span style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0;">
{{ $label }} Priority
</span>
</span>

View File

@@ -0,0 +1,25 @@
@props([
'type' => null
])
@php
if (!$type) {
return;
}
$configs = [
'dueSoon' => ['icon' => '⏳', 'label' => 'Due Soon - Within 3 days'],
'overdue' => ['icon' => '⏰', 'label' => 'Overdue - Past due date'],
'stale' => ['icon' => '💤', 'label' => 'Stale - No activity for 14+ days']
];
$config = $configs[$type] ?? null;
if (!$config) {
return;
}
@endphp
<span {{ $attributes->merge(['class' => 'time-indicator']) }}
style="font-size: 18px; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;"
data-tippy-content="{{ $config['label'] }}"
aria-label="{{ $config['label'] }}">{{ $config['icon'] }}</span>

View File

@@ -0,0 +1,50 @@
@props([
'effort' => 3,
'size' => 'md',
'showLabel' => false
])
@php
use Leantime\Domain\Tickets\Models\TicketDesignTokens;
// Handle null/0/empty effort as "No Effort"
$isNoEffort = $effort === null || $effort === '' || $effort === 0 || $effort === '0';
$token = TicketDesignTokens::getEffort($effort);
$sizeLabel = $isNoEffort ? 'No Effort' : ($token['tshirtLabel'] ?? 'M');
$sizes = [
'sm' => ['width' => 20, 'height' => 18],
'md' => ['width' => 24, 'height' => 22],
'lg' => ['width' => 30, 'height' => 28]
];
$sizeConfig = $sizes[$size] ?? $sizes['md'];
$color = '#159A80'; // Brand teal from design
@endphp
<span {{ $attributes->merge(['class' => 'tshirt-icon']) }}
style="display: inline-flex; align-items: center; gap: 4px;"
data-tippy-content="{{ $sizeLabel }} Effort">
<svg
width="{{ $sizeConfig['width'] }}"
height="{{ $sizeConfig['height'] }}"
viewBox="0 0 24 22"
fill="none"
style="flex-shrink: 0;">
<!-- Plain t-shirt - NO text inside -->
<path
d="M8 1L4 1L1 5L4 7L4 21L20 21L20 7L23 5L20 1L16 1L14.5 3.5C14.5 3.5 13.5 5 12 5C10.5 5 9.5 3.5 9.5 3.5L8 1Z"
fill="{{ $color }}"
stroke="{{ $color }}"
stroke-width="1.5"
stroke-linejoin="round"/>
</svg>
@if($showLabel)
<span style="font-size: 14px; font-weight: 500;">{{ $sizeLabel }}</span>
@endif
<span style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0;">
{{ $sizeLabel }} Effort
</span>
</span>

View File

@@ -0,0 +1,21 @@
@props([
'type' => 'task',
'size' => 'md'
])
@php
use Leantime\Domain\Tickets\Models\TicketDesignTokens;
$token = TicketDesignTokens::getType($type);
$icon = $token['icon'] ?? '📋';
$label = $token['label'] ?? 'Task';
$sizes = ['sm' => '14px', 'md' => '16px', 'lg' => '20px'];
$fontSize = $sizes[$size] ?? '16px';
@endphp
<span {{ $attributes->merge(['class' => 'type-icon']) }}
style="font-size: {{ $fontSize }}; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 24px;"
data-tippy-content="Type: {{ $label }}"
role="img"
aria-label="Type: {{ $label }}">{{ $icon }}</span>

View File

@@ -0,0 +1,60 @@
@props([
'userId' => null,
'username' => '',
'size' => 'md'
])
@php
$sizeMap = [
'sm' => ['width' => '28px', 'height' => '28px', 'fontSize' => '13px'],
'md' => ['width' => '32px', 'height' => '32px', 'fontSize' => '14px'],
'lg' => ['width' => '40px', 'height' => '40px', 'fontSize' => '16px']
];
$sizeStyles = $sizeMap[$size] ?? $sizeMap['md'];
$initials = '';
if ($username) {
$parts = explode(' ', $username);
$initials = strtoupper(substr($parts[0], 0, 1));
if (count($parts) > 1) {
$initials .= strtoupper(substr($parts[1], 0, 1));
}
}
// Generate consistent color based on username
$colorPalette = [
['bg' => '#6B7A4D', 'text' => '#FFFFFF'], // Olive green
['bg' => '#5C8A8A', 'text' => '#FFFFFF'], // Teal
['bg' => '#8A6B5C', 'text' => '#FFFFFF'], // Brown
['bg' => '#7A6B8A', 'text' => '#FFFFFF'], // Purple
['bg' => '#6B8A7A', 'text' => '#FFFFFF'], // Sage
['bg' => '#8A7A6B', 'text' => '#FFFFFF'], // Tan
];
$defaultColor = ['bg' => '#D1D5DB', 'text' => '#6B7280']; // Gray for unassigned
if ($username && $username !== 'Unassigned') {
// Use hash to consistently assign color to same user
$hash = crc32($username);
$colorIndex = abs($hash) % count($colorPalette);
$colors = $colorPalette[$colorIndex];
} else {
$colors = $defaultColor;
}
@endphp
<div {{ $attributes->merge(['class' => 'user-avatar']) }}
style="display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; background-color: {{ $colors['bg'] }}; width: {{ $sizeStyles['width'] }}; height: {{ $sizeStyles['height'] }}; font-size: {{ $sizeStyles['fontSize'] }}; flex-shrink: 0;"
data-tippy-content="{{ $username }}">
@if($userId)
<img src="{{ BASE_URL }}/api/users?profileImage={{ $userId }}"
alt="{{ $username }}"
style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;"
onerror="this.style.display='none'; this.nextElementSibling.style.display='inline-flex';"
loading="lazy">
<span style="font-weight: 600; color: {{ $colors['text'] }}; display: none;">{{ $initials }}</span>
@else
<span style="font-weight: 600; color: {{ $colors['text'] }};">{{ $initials }}</span>
@endif
</div>

View File

@@ -0,0 +1,11 @@
@props([
"size",
])
<div style="
display:inline-block;
width:{{ $size }};
height: {{ $size }};
vertical-align: middle;
background:url({{ BASE_URL }}/dist/images/loading-animation.svg);
background-size: contain;"></div>

View File

@@ -0,0 +1,138 @@
@props([
'count' => 1,
'includeHeadline' => false,
'type' => 'text'
])
@if($includeHeadline == 'true')
<div class="loading-text">
<p style="width:40%">Loading...</p>
<br />
</div>
<br />
@endIf
@if($type == 'card')
@for ($i = 0; $i < $count; $i++)
<div class="loading-text tw-w-full">
<div class="row tw-mb-l">
<div class="col-md-6">
<p style="width:30%">Loading...</p>
<p style="width:60%">Loading...</p>
<p style="width:20%">Loading...</p>
</div>
<div class="col-md-6 tw-text-right">
<p style="width:5%" class="tw-float-right">Loading...</p><div class="clearall"></div>
<div class="clearall"></div><br />
<p style="width:20%" class="tw-float-right tw-ml-sm">Loading...</p>&nbsp;<p style="width:25%" class="tw-float-right tw-ml-sm">Loading...</p>&nbsp;<p style="width:10%" class="tw-float-right tw-ml-sm">Loading...</p>
</div>
</div>
</div>
@endfor
@endif
@if($type == 'text')
@for ($i = 0; $i < $count; $i++)
<div class="loading-text">
<p style="width:90%">Loading...</p>
<p style="width:90%">Loading...</p>
<br />
<p style="width:60%">Loading...</p>
<p style="width:65%">Loading...</p>
<p style="width:55%">Loading...</p>
<p style="width:50%">Loading...</p>
<p style="width:20%">Loading...</p>
<br />
<p style="width:90%">Loading...</p>
<p style="width:90%">Loading...</p>
</div>
@endfor
@endif
@if($type == 'longtext')
@for ($i = 0; $i < $count; $i++)
<div class="loading-text">
<p style="width:90%">Loading...</p>
<p style="width:90%">Loading...</p>
<br />
<p style="width:60%">Loading...</p>
<p style="width:65%">Loading...</p>
<p style="width:55%">Loading...</p>
<p style="width:50%">Loading...</p>
<p style="width:20%">Loading...</p>
<br />
<p style="width:90%">Loading...</p>
<p style="width:90%">Loading...</p>
<p style="width:90%">Loading...</p>
<p style="width:90%">Loading...</p>
<br />
<p style="width:60%">Loading...</p>
<p style="width:65%">Loading...</p>
<p style="width:55%">Loading...</p>
<p style="width:50%">Loading...</p>
<p style="width:20%">Loading...</p>
<br />
<p style="width:90%">Loading...</p>
<p style="width:90%">Loading...</p>
<p style="width:90%">Loading...</p>
<p style="width:90%">Loading...</p>
<br />
<p style="width:60%">Loading...</p>
<p style="width:65%">Loading...</p>
<p style="width:55%">Loading...</p>
<p style="width:50%">Loading...</p>
<p style="width:20%">Loading...</p>
<br />
<p style="width:90%">Loading...</p>
<p style="width:90%">Loading...</p>
</div>
@endfor
@endif
@if($type == 'line')
@for ($i = 0; $i < $count; $i++)
<div class="loading-text">
<p style="width:40%">Loading...</p>
<br />
</div>
@endfor
@endif
@if($type == 'project')
@for ($i = 0; $i < $count; $i++)
<div class="loading-text">
<p style="margin-left:10px; margin-right:10px; width:30px; height:30px; float:left;">Loading...</p>
<p style="width:200px; margin-left:50px;"></p>
<br />
</div>
@endfor
@endif
@if($type == 'plugincard')
<div class="row">
@for ($i = 0; $i < $count; $i++)
<div class="col-md-4">
<div class="loading-text">
<div class="row tw-mb-l">
<div class="col-md-12">
<p style="width:100%; height:80px;">Loading...</p>
</div>
</div>
<div class="row tw-mb-l">
<div class="col-md-6">
<p style="width:60%">Loading...</p>
<p style="width:20%">Loading...</p>
</div>
<div class="col-md-6 tw-text-right">
<p style="width:5%" class="tw-float-right">Loading...</p><div class="clearall"></div>
<div class="clearall"></div><br />
<p style="width:20%" class="tw-float-right tw-ml-sm">Loading...</p>&nbsp;<p style="width:25%" class="tw-float-right tw-ml-sm">Loading...</p>&nbsp;<p style="width:10%" class="tw-float-right tw-ml-sm">Loading...</p>
</div>
</div>
</div>
</div>
@endfor
</div>
@endif

View File

@@ -0,0 +1,146 @@
{{--
Shared tab-group — the ONE tablist implementation (navigation.tabs).
ARIA button-tablist with roving tabindex, Arrow/Home/End keyboard support,
optional persistence, and HTMX-safe vanilla-JS init (no jQuery). Replaces
the legacy jQuery-UI tabs wrapper and the four hand-rolled copies of the
same pattern (board tabs, report deck, goal dialog, Resource Allocation).
Props:
group string REQUIRED. Unique id prefix for this group on the page;
tab/panel ids derive from it ({group}-tab-{name} /
{group}-panel-{name}).
label string REQUIRED. aria-label for the tablist.
variant string 'attached' (default) — gradient accent band with a
translucent framed segment group (board/report look), or
'floating' — free-floating fully-rounded pills (RA look).
dark bool Floating variant only: dark-header color scheme
(server-stamped, mirrors the theme's color mode).
panels string 'toggle' (default) — the JS shows/hides elements marked
data-tabs-panel for this group; 'manual' — the JS only
manages tab state and dispatches the event below, and
the page's own JS moves content (e.g. a deck track).
storage string 'none' (default) | 'session' | 'local' — where the
active tab persists across reloads.
storageKey string Storage key; REQUIRED when storage != none. Scope it
per entity (e.g. "ra-tab-{programId}").
Slots:
default The x-global::navigation.tabs.tab children.
actions Optional right-side cluster (filters, period pickers, pager
arrows). Attached variant renders it inside the band.
Panel contract (caller-rendered):
<div id="{group}-panel-{name}" role="tabpanel"
aria-labelledby="{group}-tab-{name}" tabindex="0"
data-tabs-panel="{name}" data-tabs-group="{group}">…</div>
Event contract: on every activation the root dispatches a bubbling
CustomEvent "lt:tabs:changed" with detail {group, name, index} — pages with
special behavior (deck translation, lazy loads) listen for it.
--}}
@props([
'group',
'label',
'variant' => 'attached',
'dark' => false,
'panels' => 'toggle',
'storage' => 'none',
'storageKey' => null,
])
<div {{ $attributes->merge(['class' => 'lt-tabs lt-tabs--'.$variant.($dark ? ' lt-tabs--dark' : '')]) }}
data-lt-tabs="{{ $group }}"
data-tabs-panels="{{ $panels }}"
data-tabs-storage="{{ $storage }}"
@if ($storageKey) data-tabs-storage-key="{{ $storageKey }}" @endif>
<div class="lt-tabs-group" role="tablist" aria-label="{{ $label }}">
{{ $slot }}
</div>
@isset ($actions)
<div {{ $actions->attributes->merge(['class' => 'lt-tabs-actions']) }}>{{ $actions }}</div>
@endisset
</div>
@once('lt-tabs-script')
<script>
(function () {
'use strict';
function activate(root, tabs, idx, focus) {
tabs.forEach(function (t, i) {
var on = i === idx;
t.classList.toggle('on', on);
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.setAttribute('tabindex', on ? '0' : '-1');
});
var tab = tabs[idx];
if (focus) { tab.focus(); }
var group = root.getAttribute('data-lt-tabs');
var name = tab.getAttribute('data-tab-name');
if (root.getAttribute('data-tabs-panels') === 'toggle') {
document.querySelectorAll('[data-tabs-group="' + group + '"][data-tabs-panel]').forEach(function (p) {
p.hidden = p.getAttribute('data-tabs-panel') !== name;
});
}
var storage = root.getAttribute('data-tabs-storage');
var key = root.getAttribute('data-tabs-storage-key');
if (key && (storage === 'session' || storage === 'local')) {
try { (storage === 'session' ? sessionStorage : localStorage).setItem(key, name); } catch (e) { /* private mode */ }
}
root.dispatchEvent(new CustomEvent('lt:tabs:changed', {
bubbles: true,
detail: { group: group, name: name, index: idx },
}));
}
function init(scope) {
(scope.querySelectorAll ? scope : document).querySelectorAll('[data-lt-tabs]:not([data-tabs-init])').forEach(function (root) {
root.setAttribute('data-tabs-init', '1');
var tabs = Array.prototype.slice.call(root.querySelectorAll('[role="tab"]'));
if (!tabs.length) { return; }
tabs.forEach(function (t, i) {
t.addEventListener('click', function () { activate(root, tabs, i, false); });
});
// Roving focus on the tablist: Left/Right wrap, Home/End jump.
root.querySelector('[role="tablist"]').addEventListener('keydown', function (e) {
var current = tabs.findIndex(function (t) { return t.getAttribute('aria-selected') === 'true'; });
var next = null;
if (e.key === 'ArrowRight') { next = (current + 1) % tabs.length; }
else if (e.key === 'ArrowLeft') { next = (current - 1 + tabs.length) % tabs.length; }
else if (e.key === 'Home') { next = 0; }
else if (e.key === 'End') { next = tabs.length - 1; }
if (next !== null) { e.preventDefault(); activate(root, tabs, next, true); }
});
// Restore persisted selection (falls back to the server-rendered state).
var storage = root.getAttribute('data-tabs-storage');
var key = root.getAttribute('data-tabs-storage-key');
if (key && (storage === 'session' || storage === 'local')) {
try {
var saved = (storage === 'session' ? sessionStorage : localStorage).getItem(key);
var idx = tabs.findIndex(function (t) { return t.getAttribute('data-tab-name') === saved; });
if (idx >= 0) { activate(root, tabs, idx, false); return; }
} catch (e) { /* private mode */ }
}
// Sync panels to the server-rendered selection in toggle mode.
if (root.getAttribute('data-tabs-panels') === 'toggle') {
var current = tabs.findIndex(function (t) { return t.getAttribute('aria-selected') === 'true'; });
activate(root, tabs, current >= 0 ? current : 0, false);
}
});
}
if (document.readyState !== 'loading') { init(document); }
else { document.addEventListener('DOMContentLoaded', function () { init(document); }); }
if (window.htmx) { window.htmx.onLoad(init); }
})();
</script>
@endonce

View File

@@ -0,0 +1,31 @@
{{--
One tab panel paired with x-global::navigation.tabs.tab by `name`.
Renders the full panel contract (id/role/aria-labelledby/tabindex +
data attributes) so consumers never hand-write it. Works in both
component modes: `toggle` (the tabs script shows/hides these) and
`manual` (the page's own JS moves them — pass extra classes/attrs
through, e.g. class="ra-page" inside a deck track).
Props:
name string REQUIRED. Must match the paired tab's name.
group string Usually inherited via @aware from the parent tabs
component; pass explicitly when the panel renders
outside the component tree (deck tracks, separate
partials).
--}}
@props([
'name',
'group' => null,
])
@aware(['group' => null])
<div id="{{ $group }}-panel-{{ $name }}"
role="tabpanel"
aria-labelledby="{{ $group }}-tab-{{ $name }}"
tabindex="0"
data-tabs-panel="{{ $name }}"
data-tabs-group="{{ $group }}"
{{ $attributes }}>
{{ $slot }}
</div>

View File

@@ -0,0 +1,31 @@
{{--
One tab button inside x-global::navigation.tabs.
Props:
name string REQUIRED. Pairs the tab with its panel
({group}-tab-{name} controls {group}-panel-{name}).
icon string Optional Font Awesome classes (e.g. "fa-users").
count mixed Optional count badge; countLabel gives it an
accessible name ("7 people" instead of bare "7").
selected bool Server-rendered initial selection (exactly one tab
per group should pass true).
--}}
@props([
'name',
'icon' => null,
'count' => null,
'countLabel' => null,
'selected' => false,
])
@aware(['group'])
<button type="button"
role="tab"
id="{{ $group }}-tab-{{ $name }}"
aria-controls="{{ $group }}-panel-{{ $name }}"
aria-selected="{{ $selected ? 'true' : 'false' }}"
tabindex="{{ $selected ? '0' : '-1' }}"
data-tab-name="{{ $name }}"
{{ $attributes->merge(['class' => 'lt-tab'.($selected ? ' on' : '')]) }}>
@if ($icon)<i class="fa {{ $icon }}" aria-hidden="true"></i> @endif{{ $slot }}@if ($count !== null) <span class="ct" @if ($countLabel) aria-label="{{ $countLabel }}" @endif>{{ $count }}</span>@endif
</button>

View File

@@ -0,0 +1,17 @@
@dispatchEvent('beforePageHeaderOpen')
<div {{ $attributes->merge([ 'class' => 'pageheader' ]) }}>
@dispatchEvent('afterPageHeaderOpen')
<div class="pageicon"><span class="{{ $icon ?? 'fa fa-home'}}"></span></div>
<div class="pagetitle">
{{ $slot }}
</div>
@dispatchEvent('beforePageHeaderClose')
</div>
@dispatchEvent('afterPageHeaderClose')

View File

@@ -0,0 +1,94 @@
@once
@push('scripts')
<script>
(function () {
if (window.__onebotPdfPreviewInit) { return; }
window.__onebotPdfPreviewInit = true;
// 注入弹窗样式(内联,不依赖前端构建)
var css = ''
+ '.ob-pdf-overlay{position:fixed;inset:0;background:rgba(15,23,42,0.66);z-index:99990;display:flex;align-items:center;justify-content:center;padding:24px;animation:obPdfFade .15s ease;}'
+ '.ob-pdf-modal{position:relative;width:min(1100px,96vw);height:min(820px,92vh);background:#fff;border-radius:10px;box-shadow:0 20px 60px rgba(0,0,0,0.4);display:flex;flex-direction:column;overflow:hidden;}'
+ '.ob-pdf-header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid #e5e7eb;background:#f8fafc;}'
+ '.ob-pdf-title{font-size:14px;font-weight:600;color:#0f172a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}'
+ '.ob-pdf-close{flex:none;width:32px;height:32px;display:flex;align-items:center;justify-content:center;border-radius:6px;cursor:pointer;color:#475569;font-size:18px;line-height:1;text-decoration:none;}'
+ '.ob-pdf-close:hover{background:#e2e8f0;color:#0f172a;}'
+ '.ob-pdf-frame{flex:1;width:100%;border:0;background:#fff;}'
+ '@keyframes obPdfFade{from{opacity:0;}to{opacity:1;}}';
var styleEl = document.createElement('style');
styleEl.textContent = css;
document.head.appendChild(styleEl);
function param(name, url) {
var m = new RegExp('[?&]' + name + '=([^&]*)').exec(url || '');
return m ? decodeURIComponent(m[1]) : '';
}
function openPdf(url, title) {
var overlay = document.createElement('div');
overlay.className = 'ob-pdf-overlay';
var modal = document.createElement('div');
modal.className = 'ob-pdf-modal';
var header = document.createElement('div');
header.className = 'ob-pdf-header';
var t = document.createElement('span');
t.className = 'ob-pdf-title';
t.textContent = title || 'PDF';
var close = document.createElement('a');
close.className = 'ob-pdf-close';
close.setAttribute('role', 'button');
close.setAttribute('aria-label', 'Close');
close.innerHTML = '<i class="fa-solid fa-xmark"></i>';
header.appendChild(t);
header.appendChild(close);
var frame = document.createElement('iframe');
frame.className = 'ob-pdf-frame';
frame.setAttribute('src', url);
frame.setAttribute('title', title || 'PDF');
modal.appendChild(header);
modal.appendChild(frame);
overlay.appendChild(modal);
document.body.appendChild(overlay);
function closeModal() {
overlay.remove();
document.removeEventListener('keydown', onKey);
}
function onKey(e) {
if (e.key === 'Escape') { closeModal(); }
}
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { closeModal(); }
});
close.addEventListener('click', closeModal);
document.addEventListener('keydown', onKey);
}
// capture 阶段拦截,确保先于 nyroModal/colorbox 等其它绑定执行
document.addEventListener('click', function (e) {
var a = e.target && e.target.closest ? e.target.closest('a[href*="files/get"]') : null;
if (!a) { return; }
var href = a.getAttribute('href') || '';
if (!/[?&]ext=pdf/i.test(href)) { return; }
e.preventDefault();
e.stopImmediatePropagation();
e.stopPropagation();
var title = a.getAttribute('title')
|| param('realName', href)
|| (a.querySelector('.filename') ? a.querySelector('.filename').textContent.trim() : '')
|| 'PDF';
openPdf(href, title);
}, true);
// 暴露给其它模块(如 BOM 附件点击预览 PDF)复用
window.__onebotOpenPdf = openPdf;
})();
</script>
@endpush
@endonce

View File

@@ -0,0 +1,69 @@
{{--
Reporting period selector: quarter presets swap the report body via HTMX (falling back to a
full page load without JS), the custom range submits a plain GET form.
@props
period: \Leantime\Domain\Reports\Models\ReportPeriod - the active period
url: string - page URL (fallback links, pushed browser URL, custom-range form action)
hxUrl: string - HTMX endpoint rendering the report body partial
target: string - CSS selector of the report body element to swap
--}}
@props(['period', 'url', 'hxUrl', 'target' => '#reportBody'])
@php
$presets = [
\Leantime\Domain\Reports\Models\ReportPeriod::PRESET_LAST_QUARTER => __('label.period_last_quarter'),
\Leantime\Domain\Reports\Models\ReportPeriod::PRESET_THIS_QUARTER => __('label.period_this_quarter'),
\Leantime\Domain\Reports\Models\ReportPeriod::PRESET_NEXT_QUARTER => __('label.period_next_quarter'),
];
$isCustom = $period->preset === \Leantime\Domain\Reports\Models\ReportPeriod::PRESET_CUSTOM;
@endphp
<div {{ $attributes->merge(['class' => 'periodPicker tw-flex tw-items-center tw-gap-2 tw-flex-wrap']) }}>
<div class="btn-group" role="group">
@foreach ($presets as $presetKey => $presetLabel)
<a href="{{ $url }}?preset={{ $presetKey }}"
hx-get="{{ $hxUrl }}?preset={{ $presetKey }}"
hx-target="{{ $target }}"
hx-swap="outerHTML"
hx-push-url="{{ $url }}?preset={{ $presetKey }}"
class="btn btn-sm btn-secondary @if ($period->preset === $presetKey) active @endif">
{{ $presetLabel }}
</a>
@endforeach
<button type="button"
onclick="jQuery(this).closest('.periodPicker').find('.periodPickerCustom').toggle();"
aria-expanded="{{ $isCustom ? 'true' : 'false' }}"
class="btn btn-sm btn-secondary @if ($isCustom) active @endif">
{{ __('label.period_custom') }}
</button>
</div>
<form method="GET" action="{{ $url }}" class="periodPickerCustom tw-items-center tw-gap-1" style="display: {{ $isCustom ? 'flex' : 'none' }};">
<input type="hidden" name="preset" value="custom" />
<input type="text" name="from" class="periodPickerDate" style="width: 110px;"
placeholder="{{ __('label.period_from') }}"
value="{{ $isCustom ? $period->from->setToUserTimezone()->formatDateForUser() : '' }}" />
<span>–</span>
<input type="text" name="to" class="periodPickerDate" style="width: 110px;"
placeholder="{{ __('label.period_to') }}"
value="{{ $isCustom ? $period->to->setToUserTimezone()->formatDateForUser() : '' }}" />
<button type="submit" class="btn btn-sm btn-primary">{{ __('label.period_apply') }}</button>
</form>
<span class="tw-text-sm tw-opacity-70 periodLabel">{{ $period->label() }}</span>
</div>
@once
@push('scripts')
<script type="text/javascript">
jQuery(document).ready(function () {
jQuery('.periodPickerDate').datepicker({
dateFormat: leantime.dateHelper.getFormatFromSettings('dateformat', 'jquery')
});
});
</script>
@endpush
@endonce

View File

@@ -0,0 +1,69 @@
@dispatchEvent('beforeSelectable')
<div {{ $attributes->merge([ 'class' => 'selectable selectable-'.$name.' tw-center '.($selected == "true" ? 'active' : ''). '' ]) }} id="selectableWrapper-{{ $id }}">
<div class="selectableContent">
{{ $slot }}
</div>
<input type="{{ $type ?? 'radio' }}" name="{{ $name }}" {!! $selected == "true" ? "checked='checked'" : "" !!} id="selectable-{{ $id }}" value="{{ $value }}" class="selectableRadio tw-hidden"/>
<label for="selectable-{{ $id }}" class="selectable-label" >
{{ $label }}
</label>
</div>
@pushonce('scripts')
<script>
function setSelectables() {
jQuery(".selectable").each(function(){
jQuery(this).mousedown(function(){
jQuery(this).addClass("pushed");
});
jQuery(this).mouseup(function(){
jQuery(this).removeClass("pushed");
});
jQuery(this).click(function(){
var name = jQuery(this).find("input").attr("name");
var type = jQuery(this).find("input").attr("type");
if(type == 'radio') {
jQuery(".selectable-" + name).find("input.selectableRadio").removeProp("checked");
jQuery(".selectable-" + name).removeClass("active");
jQuery(this).addClass("active");
jQuery(this).find("input.selectableRadio").prop("checked", true);
}
if(type=='checkbox') {
if( jQuery(this).hasClass("active")) {
jQuery(this).removeClass("active");
jQuery(this).find("input.selectableRadio").prop("checked", false);
}else{
jQuery(this).addClass("active");
jQuery(this).find("input.selectableRadio").prop("checked", true);
}
}
});
});
}
jQuery(document).ready(function() {
setSelectables();
});
htmx.onLoad(function(){
setSelectables();
});
</script>
@endpushonce
@dispatchEvent('afterSelectableClose')

View File

@@ -0,0 +1,36 @@
@props([
'stageKey' => '',
'stageNum' => 1,
'color' => '#4A85B5',
'bgColor' => '#EDF3F8',
'icon' => 'fa-circle',
'title' => '',
'subtitle' => '',
'active' => false,
'itemCount' => 0,
'focusLabel' => 'Current Focus',
])
<div class="sf-stage {{ $active ? 'active' : '' }}"
data-s="{{ $stageNum }}"
data-stage="{{ $stageKey }}"
style="--stage-color: {{ $color }}; --stage-bg: {{ $bgColor }};">
<div class="sf-flag" style="background: {{ $color }};">{{ $focusLabel }}</div>
<div class="sf-hd">
<div class="sf-icon"><i class="fa {{ $icon }}"></i></div>
<div class="sf-title-row">
<span class="sf-name">{{ $title }}</span>
@if ($itemCount > 0)<span class="sf-count" style="color: {{ $color }};">{{ $itemCount }}</span>@endif
</div>
<div class="sf-sub">{{ $subtitle }}</div>
{{ $headerExtra ?? '' }}
</div>
{{ $beforeBody ?? '' }}
<div class="sf-body">
{{ $slot }}
</div>
</div>

View File

@@ -0,0 +1,71 @@
@props([
'itemId' => '',
'title' => '',
'description' => '',
'editUrl' => '',
'deleteUrl' => '',
'commentUrl' => '',
'commentCount' => 0,
'avatarUrl' => '',
'authorId' => null,
'authorName' => '',
'dotColor' => 'grey',
'canEdit' => false,
])
@php
$dotClass = match($dotColor) {
'blue' => 'sf-dot--blue',
'orange' => 'sf-dot--orange',
'green' => 'sf-dot--green',
'red' => 'sf-dot--red',
default => 'sf-dot--grey',
};
@endphp
<div class="sf-item" id="item_{{ $itemId }}">
@if ($canEdit && $editUrl)
<div class="inlineDropDownContainer" style="float:right; margin-left:4px;">
<a href="javascript:void(0)" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu">
<li class="nav-header">{{ __('subtitles.edit') }}</li>
<li><a href="{{ $editUrl }}" data="item_{{ $itemId }}">{!! __('links.edit_canvas_item') !!}</a></li>
@if ($deleteUrl)
<li><a href="{{ $deleteUrl }}" class="delete" data="item_{{ $itemId }}">{!! __('links.delete_canvas_item') !!}</a></li>
@endif
</ul>
</div>
@endif
<div class="sf-item-title">
<span class="sf-dot {{ $dotClass }}"></span>
@if ($editUrl)
<a href="{{ $editUrl }}" data="item_{{ $itemId }}">{{ $title }}</a>
@else
{{ $title }}
@endif
</div>
@if ($description)
<div class="sf-item-desc">{!! $description !!}</div>
@endif
<div class="sf-item-foot">
@if ($authorId || $authorName)
<x-global::avatar :userId="$authorId" :username="$authorName" size="sm" />
@elseif ($avatarUrl)
<img class="sf-avatar" src="{{ $avatarUrl }}" width="18" />
@endif
@if ($commentCount > 0 && $commentUrl)
<span class="sf-meta">
<a href="{{ $commentUrl }}" class="commentCountLink" data="item_{{ $itemId }}">
<i class="fa-regular fa-comment"></i>
</a>
{{ $commentCount }}
</span>
@endif
{{ $slot }}
</div>
</div>

View File

@@ -0,0 +1,416 @@
{{--
Stageflow component styles.
Include once per page: @include('global::components.stageflow.styles')
These styles power the stage-flow layout used by Logic Model and
other blueprint boards. Stage cards use inline CSS custom properties
(--stage-color, --stage-bg) set via the <x-global::stageflow.card>
component.
--}}
@once
<style>
/* ═══════════════════════════════════════════════════════
Stageflow — Reusable stage-flow layout
═══════════════════════════════════════════════════════ */
/* ── Flow container ── */
.sf-flow {
display: flex;
align-items: stretch;
gap: 8px;
padding: 8px 0;
}
/* ── Stage card ── */
.sf-stage {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
border-radius: var(--box-radius);
background: var(--secondary-background);
transition: box-shadow 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
background 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
border-color 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 200ms ease;
position: relative;
box-shadow: var(--min-shadow);
border: 1px solid var(--main-border-color);
overflow: visible;
z-index: 1;
}
.sf-stage:not(.active) {
cursor: pointer;
}
.sf-stage:not(.active):hover {
box-shadow: var(--regular-shadow);
}
.sf-stage.active {
z-index: 10;
box-shadow: var(--large-shadow);
border-color: transparent;
background: linear-gradient(180deg, var(--stage-bg) 0%, var(--secondary-background) 100px);
}
/* ── Spotlight on hover ──
When the user mouses over any stage in the flow, fade the others to ~40%
opacity (still readable, just secondary) and lift the hovered card with
extra shadow. Pure visual aid for guiding attention while reviewing or
presenting the board — useful when walking stakeholders through one
stage at a time without losing the surrounding context. No state change,
no persistence; the moment the mouse leaves the row, everything
rebalances back to the default everything-prominent state.
Scoped to `.sf-stage.active` to avoid compounding with the inactive-stage
opacity rules below (`:not(.active) .sf-name { opacity: 0.5 }` etc.) — a
plain `.sf-stage` selector here would multiply 0.4 × 0.5 = 0.2 and make
text on inactive stages unreadable during a row hover. */
.sf-flow:hover .sf-stage.active:not(:hover) {
opacity: 0.4;
}
.sf-flow:hover .sf-stage:hover {
box-shadow: var(--large-shadow);
z-index: 11;
}
/* ── Current Focus flag ── */
.sf-flag {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
padding: 2px 10px;
border-radius: 20px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: white;
z-index: 11;
white-space: nowrap;
opacity: 0;
transition: opacity 250ms;
box-shadow: var(--regular-shadow);
}
.sf-stage.active .sf-flag { opacity: 1; }
/* No focus label (e.g. all stages expanded) → don't render an empty pill. */
.sf-flag:empty { display: none; }
/* ── Stage header ── */
.sf-hd {
padding: 14px 10px 10px;
display: flex;
flex-direction: column;
align-items: center;
border-bottom: 2px solid var(--main-border-color);
transition: border-color 300ms, padding 300ms;
position: relative;
}
.sf-stage.active .sf-hd {
padding-top: 18px;
border-bottom-width: 3px;
border-bottom-color: var(--stage-color);
}
/* Icon box */
.sf-icon {
width: 32px;
height: 32px;
border-radius: var(--element-radius);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-m);
margin-bottom: 6px;
transition: background 300ms, color 300ms, width 300ms, height 300ms;
background: var(--stage-bg);
color: var(--stage-color);
}
.sf-stage:not(.active) .sf-icon {
width: 30px;
height: 30px;
font-size: var(--font-size-m);
margin-bottom: 5px;
}
.sf-stage.active .sf-icon {
background: var(--stage-color);
color: white;
}
/* Title row with count badge */
.sf-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
margin-bottom: 2px;
}
.sf-name {
font-size: var(--font-size-l);
font-weight: 700;
transition: font-size 300ms;
line-height: 1.2;
color: var(--primary-font-color);
}
.sf-stage:not(.active) .sf-name {
font-size: var(--font-size-m);
font-weight: 600;
opacity: 0.5;
}
/* Count — inline with title, kanban style */
.sf-count {
font-size: var(--font-size-l);
font-weight: 700;
line-height: 1.2;
}
.sf-stage:not(.active) .sf-count {
font-size: var(--font-size-m);
opacity: 0.4;
}
/* Subtitle */
.sf-sub {
font-size: var(--font-size-xs);
color: var(--primary-font-color);
opacity: 0.6;
transition: font-size 300ms;
text-align: center;
}
.sf-stage:not(.active) .sf-sub {
font-size: var(--font-size-xs);
opacity: 0.5;
}
/* ── Stage body ── */
.sf-body {
padding: 8px 8px 8px;
display: flex;
flex-direction: column;
gap: 5px;
flex: 1;
}
/* ── Items ── */
.sf-item {
padding: 7px 10px;
border-radius: var(--box-radius-small);
cursor: pointer;
transition: background 150ms, border-color 150ms;
border-left: 3px solid transparent;
position: relative;
}
.sf-item:hover { background: rgba(0,0,0,0.02); }
.sf-stage.active .sf-item { border-left-color: var(--stage-color); }
/* Item title */
.sf-item-title {
font-size: var(--font-size-s);
font-weight: 600;
line-height: 1.3;
color: var(--primary-font-color);
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
}
.sf-item-title a,
.sf-item-title span:not(.sf-dot) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sf-item-title a {
color: var(--primary-font-color);
text-decoration: none;
}
.sf-item-title a:hover { color: var(--accent1); }
/* Status dot */
.sf-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
display: inline-block;
vertical-align: middle;
margin-right: 6px;
position: relative;
top: -1px;
}
.sf-dot--blue { background: #1B75BB; }
.sf-dot--orange { background: #fdab3d; }
.sf-dot--green { background: #75BB1B; }
.sf-dot--red { background: #BB1B25; }
.sf-dot--grey { background: #c3ccd4; }
/* Item description */
.sf-item-desc {
font-size: var(--font-size-xs);
color: var(--primary-font-color);
opacity: 0.6;
line-height: 1.4;
margin-top: 2px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
/* Item footer */
.sf-item-foot {
display: flex;
align-items: center;
gap: 5px;
margin-top: 5px;
flex-wrap: wrap;
}
.sf-meta {
font-size: var(--font-size-xs);
color: var(--primary-font-color);
opacity: 0.5;
display: inline-flex;
align-items: center;
gap: 3px;
}
.sf-meta i { font-size: var(--font-size-xs); }
.sf-meta a { color: inherit; text-decoration: none; }
.sf-meta a:hover { color: var(--accent1); opacity: 1; }
.sf-avatar {
width: 18px;
height: 18px;
border-radius: 50%;
vertical-align: middle;
}
/* ── Inactive stage: compact view ── */
.sf-stage:not(.active) .sf-item {
padding: 4px 8px;
border-left-color: transparent;
}
.sf-stage:not(.active) .sf-item-title {
font-size: var(--font-size-s);
font-weight: 500;
color: var(--primary-font-color);
opacity: 1;
display: flex;
align-items: center;
}
.sf-stage:not(.active) .sf-item-title .sf-dot {
width: 6px;
height: 6px;
margin-right: 4px;
}
.sf-stage:not(.active) .sf-item-desc { display: none; }
.sf-stage:not(.active) .sf-item-foot { display: none; }
.sf-stage:not(.active) .sf-item .inlineDropDownContainer { display: none; }
/* Disable interactive elements in inactive stages */
.sf-stage:not(.active) a,
.sf-stage:not(.active) button,
.sf-stage:not(.active) .dropdown-toggle {
pointer-events: none;
}
/* ── Empty state ── */
.sf-empty {
text-align: center;
padding: 16px 8px;
font-size: var(--font-size-xs);
color: var(--primary-font-color);
opacity: 0.5;
}
.sf-empty-icon {
font-size: var(--font-size-xl);
opacity: 0.3;
margin-bottom: 6px;
display: block;
}
.sf-stage:not(.active) .sf-empty { display: none; }
/* ── Add item button ── */
.sf-add {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px;
color: var(--primary-font-color);
opacity: 0.5;
font-size: var(--base-font-size);
font-weight: 500;
cursor: pointer;
background: transparent;
text-decoration: none !important;
transition: all 150ms;
margin-top: auto;
border: none;
border-radius: var(--box-radius-small);
}
.sf-add:hover {
opacity: 1;
color: var(--accent1);
background: rgba(0,69,110,0.04);
}
.sf-stage:not(.active) .sf-add { display: none; }
/* ── Responsive ── */
@media (max-width: 1100px) {
.sf-flow { flex-wrap: wrap; gap: 8px; }
.sf-stage { flex: 1 1 calc(50% - 4px); min-width: 160px; }
}
/* ── Status pill (plugin enhancement) ── */
.sf-status-pill {
display: inline-block;
padding: 0 6px;
border-radius: 10px;
font-size: 9px;
font-weight: 600;
line-height: 1.7;
white-space: nowrap;
letter-spacing: 0.2px;
}
/* ── Project link icon (plugin enhancement) ── */
.sf-project-link-icon i {
color: var(--accent1);
opacity: 0.7;
}
/* ── Print ── */
@media print {
@page { size: landscape; margin: 0.5in; }
.sf-stage .sf-item-desc { display: block !important; }
.sf-stage .sf-item-foot { display: flex !important; }
.sf-stage .sf-add { display: none !important; }
.sf-flag { display: none !important; }
.sf-item-actions { display: none !important; }
.sf-health-badge { display: none !important; }
.sf-flow {
display: flex !important;
flex-wrap: wrap !important;
gap: 12px !important;
}
.sf-stage {
min-width: unset !important;
max-width: unset !important;
flex: 0 0 calc(33.33% - 8px) !important;
break-inside: avoid;
page-break-inside: avoid;
}
/* Force page break after the 3rd stage */
.sf-stage:nth-child(3) {
break-after: page;
page-break-after: always;
}
.sf-stage:nth-child(n+4) {
flex: 0 0 calc(50% - 6px) !important;
}
}
</style>
@endonce

View File

@@ -0,0 +1,59 @@
{{--
Subject switcher — the "Parent // Current ▾" page-title dropdown.
Consolidates the `header-title-dropdown` pattern that was hand-rolled inline
across ~18 templates (To-Dos sprint switcher, canvas boards, wiki, ideas,
goals, projects…). One place, one markup, reusable.
Renders an <h1> with an optional parent crumb, a separator, and a Bootstrap
dropdown whose toggle shows the current subject. The MENU ITEMS are the
slot — each consumer supplies its own <li> options (they carry their own
href/onclick), so the switch behavior stays domain-specific while the
chrome is shared.
Keeps the existing classes (.header-title-dropdown, .dropdown,
.dropdown-menu) so the established CSS (dropdowns.css) and Bootstrap
data-toggle behavior apply unchanged — migrating a consumer is a
zero-visual-change swap.
Props:
parent string|null Parent crumb label (e.g. "To-Dos"). Escaped by
default; pass an HtmlString/Htmlable if a caller
genuinely needs markup.
parentHref string|null Optional link for the parent crumb.
current string The current subject name (escaped — user data safe).
separator string House-style divider (escaped). Default "/".
switchStyle 'legacy'|'pill' Visual variant. 'legacy' = the established
underlined-caret look. 'pill' is reserved for the
modern treatment (styled in a follow-up); the prop
exists now so it's a one-line flip later.
Slot: the <li> dropdown-menu items.
--}}
@props([
'parent' => null,
'parentHref' => null,
'current' => '',
'separator' => '/',
'switchStyle' => 'legacy',
])
<h1 @class(['subjectSwitcher', 'subjectSwitcher--pill' => $switchStyle === 'pill'])>
@if (! empty($parent))
@if (! empty($parentHref))
<a href="{{ $parentHref }}" class="subjectSwitcher-parent">{{ $parent }}</a>
@else
<span class="subjectSwitcher-parent">{{ $parent }}</span>
@endif
<span class="subjectSwitcher-sep" aria-hidden="true">{{ $separator }}</span>
@endif
<span class="dropdown dropdownWrapper">
<a href="javascript:void(0)" role="button" class="dropdown-toggle header-title-dropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ $current }}
<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu">
{{ $slot }}
</ul>
</span>
</h1>

View File

@@ -0,0 +1,24 @@
@props([
"image",
"headline",
"maxWidth" => "30%",
"maxHeight" => "200px",
"height" => "auto",
"headlineSize" => "",
"align" => "center"
])
<div {{ $attributes->merge(['class' => 'tw-w-full tw-text-'.$align.' undrawContainer']) }}>
@if (file_exists($image_path = ROOT . "/dist/images/svg/$image"))
<div style='width:100%; display:flex; max-width: {{ $maxWidth }}; max-height:{{ $maxHeight }}; height: {{ $height }}; overflow:hidden;' class='svgContainer'>
{!! file_get_contents($image_path) !!}
</div>
@endif
@if (! empty($headline))
<h3 class="fancyLink" style="{{ $headlineSize !== "" ? "font-size:".$headlineSize : "" }}">{{ $headline }}</h3>
@endif
{!! $slot ?? '' !!}
</div>

View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html dir="{{ __('language.direction') }}" lang="{{ __('language.code') }}">
<head>
@include('global::sections.header')
@stack('styles')
</head>
<body class="" hx-ext="preload" hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
@include('global::sections.appAnnouncement')
<div class="mainwrapper menu{{ session("menuState") ?? "closed" }}">
<div class="header">
<div class="headerinner">
<a class="btnmenu" href="javascript:void(0);"></a>
<a class="barmenu" href="javascript:void(0);">
<span class="fa fa-bars"></span>
</a>
<div class="logo">
<a
href="{{ BASE_URL }}"
style="background-image: url('{{ BASE_URL }}/dist/images/logo.svg')"
>&nbsp;</a>
</div>
@include('menu::headMenu')
</div><!-- headerinner -->
</div><!-- header -->
<div class="overlay" style="position: relative">
<div class="leftpanel">
<div class="leftmenu">
@include('menu::menu')
</div><!-- leftmenu -->
</div>
<div class="rightpanel {{ $section }}">
<div class="primaryContent">
@isset($action, $module)
@include("$module::$action")
@else
@yield('content')
@endisset
<div class="clearfix"></div>
@include('global::sections.footer')
</div>
</div>
</div><!-- rightpanel -->
<div class="menu-backdrop" aria-hidden="true"></div>
</div><!-- mainwrapper -->
@include('global::sections.pageBottom')
@stack('scripts')
@include('help::helpermodal')
@include('global::components.aiPanel')
</body>
</html>

View File

@@ -0,0 +1,5 @@
@isset($action, $module)
@include("$module::$action")
@else
@yield('content')
@endisset

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html dir="{{ __('language.direction') }}" lang="{{ __('language.code') }}">
<head>
@include('global::sections.header')
<style>
.onebotLogo { position: fixed; bottom: 10px; right: 10px; }
</style>
@stack('styles')
</head>
<body class="loginpage" style="height:100%;" hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
<div class="header hidden-gt-sm tw-p-[10px]" style="background:var(--header-gradient)">
<a href="{!! BASE_URL !!}" target="_blank">
<img src="{{ BASE_URL }}/dist/images/logo.svg" class="tw-h-full "/>
</a>
</div>
<div class="row" style="min-height:100vh; max-width: 98vw; height: auto;">
<div class="col-md-4 hidden-phone regLeft">
<div class="logo">
<a href="{!! BASE_URL !!}" target="_blank">
<img src="{{ BASE_URL }}/dist/images/logo.svg" />
</a>
</div>
<div class="welcomeContent">
@dispatchFilter('welcomeText', '<h1 class="mainWelcome">'.$language->__("headlines.welcome_back").'</h1>')
</div>
@dispatchFilter('belowWelcomeText', '')
</div>
<div class="col-md-8 col-sm-12 regRight">
<div class="regpanel">
<div class="regpanelinner">
@if($logoPath != '')
<a href="{!! BASE_URL !!}" target="_blank">
@if(!str_ends_with($logoPath, "dist/images/logo.svg" ))
<img src="{{ $logoPath }}" class="tw-h-full "/>
@endif
</a>
@endif
@isset($action, $module)
@include("$module::$action")
@else
@yield('content')
@endisset
</div>
</div>
</div>
<div class="onebotLogo">
<img style="height: 25px;" src="{!! BASE_URL !!}/dist/images/logo.png">
</div>
</div>
@include('global::sections.pageBottom')
@stack('scripts')
</body>
</html>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html dir="{{ __('language.direction') }}" lang="{{ __('language.code') }}">
<head>
@include('global::sections.header')
<style>
.onebotLogo { position: fixed; bottom: 10px; right: 10px; }
</style>
@stack('styles')
</head>
<body class="loginpage" style="height:100%;" hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
<div class="header hidden-gt-sm tw-p-[10px]" style="background:var(--header-gradient)">
<a href="{!! BASE_URL !!}" target="_blank">
<img src="{{ BASE_URL }}/dist/images/logo.svg" class="tw-h-full "/>
</a>
</div>
<div class="row " style="height:100%; width: 99%;">
<div class="col-md-4 hidden-phone regLeft">
<div class="logo">
<a href="{!! BASE_URL !!}" target="_blank"><img src="{{ BASE_URL }}/dist/images/logo.svg" /></a>
</div>
<div class="welcomeContent">
<h1 class="mainWelcome">
Oops, something is off.
</h1>
</div>
</div>
<div class="col-md-8 col-sm-12 regRight">
<div class="regpanel">
<div class="regpanelinner">
@isset($action, $module)
@include("$module::$action")
@else
@yield('content')
@endisset
</div>
</div>
</div>
<div class="onebotLogo">
<img style="height: 25px;" src="{!! BASE_URL !!}/dist/images/logo.png">
</div>
</div>
@include('global::sections.pageBottom')
@stack('scripts')
</body>
</html>

View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html dir="{{ __('language.direction') }}" lang="{{ __('language.code') }}">
<head>
@include('global::sections.header')
@stack('styles')
<style>
.leantimeLogo { position: fixed; bottom: 10px; right: 10px; }
.regcontent {
width: auto;
}
</style>
</head>
<body class="loginpage" style="height:100%; " hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
<div class="" style="background:url({{BASE_URL}}/assets/images/spotlightBg.png); background-size: cover; height:100%; background-attachment: fixed;">
<div style=" width: 100%;
height: 100%;
background: rgba(0,0,0,0.2);
backdrop-filter: blur(3px);
padding-top: 150px;
overflow: hidden;">
<div class="regpanel" style="
margin: auto;
background: #fff;
max-width: 50%;
box-shadow: 0px 0px 50px rgba(0,0,0,0.4);
border-radius: 10px;
overflow:hidden;">
<div class="row">
<div class="col-md-7">
<div class="regpanelinner" style="padding:30px;">
<a href=""><img src="{{ BASE_URL }}/dist/images/logo_blue.svg" style="width:50%;"/></a><br /><br />
@isset($action, $module)
@include("$module::$action")
@else
@yield('content')
@endisset
</div>
</div>
<div class="col-md-5 regLeft" style="position:relative; background:var(--element-gradient); padding:20px; height:auto;">
<h1 style="position: relative; z-index: 5; width:100%; font-size:16px;">
<span style="font-size:26px">Sign Up</span><br /><br />
No set up required!<br />Enjoy the extra time. 🎉<br />
</h1>
</div>
</div>
</div>
</div>
</div>
@include('global::sections.pageBottom')
@stack('scripts')
</body>
</html>

View File

@@ -0,0 +1,5 @@
@if(isset($appAnnouncement) && $appAnnouncement)
<div class="announcementBanner">
{!! $appAnnouncement !!}
</div>
@endif

View File

@@ -0,0 +1,28 @@
@dispatchEvent('beforeFooterOpen')
{{--<div class="footer">--}}
<span style="color:var(--main-titles-color); padding-left:15px; opacity:0.5;">
@dispatchEvent('afterFooterOpen')
</span>
{{-- <div class="row">--}}
{{-- <div class="col-md-6">--}}
{{-- © {{ date("Y") }} by <a href="http://leantime.io" target="_blank">Leantime</a>--}}
{{-- </div>--}}
{{-- <div class="col-md-6 align-right">--}}
{{-- <a href="http://leantime.io" target="_blank">--}}
{{-- <img--}}
{{-- style="height: 18px; opacity:0.5; vertical-align:sub;"--}}
{{-- src="{!! BASE_URL !!}/dist/images/logo-powered-by-leantime.png"--}}
{{-- />--}}
{{-- <span style="color:var(--primary-font-color); opacity:0.5;">v{{ $version }}</span>--}}
{{-- </a>--}}
{{-- </div>--}}
{{-- </div>--}}
@dispatchEvent('beforeFooterClose')
{{--</div>--}}
@dispatchEvent('afterFooter')

View File

@@ -0,0 +1,163 @@
<title>@dispatchFilter('page_title', $sitename)</title>
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="requestId" content="{{ \Illuminate\Support\Str::random(4) }}">
<meta name="description" content="{{ $sitename }}">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-touch-fullscreen" content="yes">
<meta name="theme-color" content="{{ $primaryColor }}">
<meta name="color-scheme" content="{{ $themeColorMode }}">
<meta name="theme" content="{{ $theme }}">
<meta name="identifier-URL" content="{!! BASE_URL !!}">
<meta name="leantime-version" content="{{ $version }}">
@dispatchEvent('afterMetaTags')
<link rel="shortcut icon" href="{!! BASE_URL !!}/dist/images/favicon.png"/>
<link rel="apple-touch-icon" href="{!! BASE_URL !!}/dist/images/apple-touch-icon.png">
@php
// Cache-buster: the filenames only change per app version, so rebuilds of
// the SAME version were served stale from browser cache (no query hash in
// the mix manifest — core mix does not version() the css). The bundle's
// mtime changes on every build, which busts exactly when needed.
$mainCssPath = APP_ROOT.'/public/dist/css/main.'.$version.'.min.css';
$cssBust = is_file($mainCssPath) ? filemtime($mainCssPath) : $version;
@endphp
<link rel="stylesheet" href="{!! BASE_URL !!}/dist/css/main.{!! $version !!}.min.css?v={!! $cssBust !!}"/>
<link rel="stylesheet" href="{!! BASE_URL !!}/dist/css/app.{!! $version !!}.min.css?v={!! $cssBust !!}"/>
@if($tpl->needsComponent('tiptap'))
<link rel="stylesheet" href="{!! BASE_URL !!}/dist/css/tiptap-editor.{!! $version !!}.min.css?v={!! $cssBust !!}"/>
<link rel="stylesheet" href="{!! BASE_URL !!}/dist/css/katex.min.css?v={!! $cssBust !!}"/>
@endif
@dispatchEvent('afterLinkTags')
<script src="{!! BASE_URL !!}/api/i18n?v={!! $version !!}"></script>
<script src="{!! BASE_URL !!}/dist/js/compiled-htmx.{!! $version !!}.min.js"></script>
<script src="{!! BASE_URL !!}/dist/js/compiled-htmx-extensions.{!! $version !!}.min.js"></script>
<!-- libs -->
<script src="{!! BASE_URL !!}/dist/js/compiled-frameworks.{!! $version !!}.min.js"></script>
<script src="{!! BASE_URL !!}/dist/js/compiled-framework-plugins.{!! $version !!}.min.js"></script>
<script src="{!! BASE_URL !!}/dist/js/compiled-global-component.{!! $version !!}.min.js"></script>
{{-- Feature-component libraries are leaf bundles used only inside controller
functions that run on/after DOMContentLoaded (e.g. initCalendar()), never at
module-load. Deferring them keeps these heavy bundles (the tiptap editor alone
is ~6.8MB) from blocking first render; deferred scripts still execute before
DOMContentLoaded, so the init handlers find them ready. --}}
@if($tpl->needsComponent('calendar'))
<script defer src="{!! BASE_URL !!}/dist/js/compiled-calendar-component.{!! $version !!}.min.js"></script>
@endif
@if($tpl->needsComponent('table'))
<script defer src="{!! BASE_URL !!}/dist/js/compiled-table-component.{!! $version !!}.min.js"></script>
@endif
@if($tpl->needsComponent('tiptap'))
<script defer src="{!! BASE_URL !!}/dist/js/compiled-tiptap-toolbar.{!! $version !!}.min.js"></script>
<script defer src="{!! BASE_URL !!}/dist/js/compiled-tiptap-editor.{!! $version !!}.min.js"></script>
@endif
@if($tpl->needsComponent('gantt'))
<script defer src="{!! BASE_URL !!}/dist/js/compiled-gantt-component.{!! $version !!}.min.js"></script>
@endif
@if($tpl->needsComponent('chart'))
<script defer src="{!! BASE_URL !!}/dist/js/compiled-chart-component.{!! $version !!}.min.js"></script>
@endif
@dispatchEvent('afterScriptLibTags')
<!-- app -->
<script src="{!! BASE_URL !!}/dist/js/compiled-app.{!! $version !!}.min.js"></script>
@dispatchEvent('afterMainScriptTag')
<!--
//For future file based ref js loading
<script src="{!! BASE_URL !!}/dist/js/{{ ucwords(\Leantime\Core\Controller\Frontcontroller::getModuleName()) }}/Js/{{ \Leantime\Core\Controller\Frontcontroller::getModuleName() }}Controller.js"></script>
-->
<!-- theme & custom -->
@foreach ($themeScripts as $script)
<script src="{!! $script !!}"></script>
@endforeach
@foreach ($themeStyles as $style)
<link rel="stylesheet" @isset($style['id']) id="{{{ $style['id'] }}}" @endisset href="{!! $style['url'] !!}"/>
@endforeach
@dispatchEvent('afterScriptsAndStyles')
<!-- Replace main theme colors -->
@php
// The nav bar sets white controls on the accent gradient. A light accent is
// a mid-tone that fails WCAG AA for white text; detect that per-theme and
// enable a dark scrim only then, so dark-accent themes stay fully vivid.
$navNeedsScrim = false;
foreach ($accents as $accentColor) {
// No usable accent value here — nothing to reason about, skip.
if ($accentColor === false || $accentColor === null || $accentColor === '') {
continue;
}
// A real accent we can't parse as 6-digit hex (e.g. rgb()/hsl()/named
// colors): we can't measure its luminance, so fail closed and enable the
// scrim to protect white nav-text contrast rather than assuming it's safe.
if (! is_string($accentColor) || ! preg_match('/^#?([0-9a-fA-F]{6})$/', $accentColor, $accentHex)) {
$navNeedsScrim = true;
break;
}
[$ar, $ag, $ab] = sscanf($accentHex[1], '%02x%02x%02x');
$linChannel = static fn ($v) => ($v /= 255) <= 0.03928 ? $v / 12.92 : (($v + 0.055) / 1.055) ** 2.4;
$accentLum = 0.2126 * $linChannel($ar) + 0.7152 * $linChannel($ag) + 0.0722 * $linChannel($ab);
if (1.05 / ($accentLum + 0.05) < 4.5) { // white-on-accent below AA
$navNeedsScrim = true;
break;
}
}
@endphp
<style id="colorSchemeSetter">
:root { --nav-scrim: {{ $navNeedsScrim ? '0.22' : '0' }}; }
@foreach ($accents as $accent)
@if($accent !== false)
:root {
--accent{{ $loop->iteration }}: {{{ $accent }}};
}
@endif
@endforeach
</style>
<style id="fontStyleSetter">
:root {
--primary-font-family: '{{{ $themeFont }}}', 'Helvetica Neue', Helvetica, sans-serif;
}
</style>
<style id="backgroundImageSetter">
@if(!empty($themeBg))
.rightpanel {
background-image: url({!! filter_var($themeBg, FILTER_SANITIZE_URL) !!});
opacity: {{ $themeOpacity }};
mix-blend-mode: {{ $themeType == 'image' ? 'normal' : 'multiply' }};
background-size: var(--background-size, cover);
background-position: center;
background-attachment: fixed;
}
@if($themeType === 'image')
.rightpanel:before {
background: none;
}
@endif
@endif
</style>
@dispatchEvent('afterThemeColors')
<script>
window.leantime.currentProject = '{{ session("currentProject") }}';
</script>
@include('global::components.pdfPreview')

View File

@@ -0,0 +1,34 @@
@if ($poorMansCron && $loggedIn)
<script>
jQuery(document).ready(function() {
let now = Date.now();
let lastCronExecution = localStorage.getItem("lastCronRun");
if(Number.isInteger(lastCronExecution)){
var difference = Math.floor((now - lastCronExecution) / 1000);
if(difference > 300) {
jQuery.get('{!! BASE_URL !!}/cron/run');
localStorage.setItem("lastCronRun", Date.now());
}
}else{
jQuery.get('{!! BASE_URL !!}/cron/run');
localStorage.setItem("lastCronRun", Date.now());
}
//1 min time to run cron
setInterval(function(){
jQuery.get('{!! BASE_URL !!}/cron/run');
localStorage.setItem("lastCronRun", Date.now());
}, 300000);
});
</script>
@endif
<script src="{!! BASE_URL !!}/dist/js/compiled-footer.{!! $version !!}.min.js"></script>
@dispatchEvent('beforeBodyClose')