OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
52
app/Views/Composers/App.php
Normal file
52
app/Views/Composers/App.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Views\Composers;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\UI\Composer;
|
||||
use Leantime\Core\UI\Theme;
|
||||
use Leantime\Domain\Menu\Repositories\Menu;
|
||||
|
||||
class App extends Composer
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
public static array $views = [
|
||||
'global::layouts.app',
|
||||
];
|
||||
|
||||
private Menu $menuRepo;
|
||||
|
||||
private Theme $themeCore;
|
||||
|
||||
public function init(Menu $menuRepo, Theme $themeCore): void
|
||||
{
|
||||
$this->menuRepo = $menuRepo;
|
||||
$this->themeCore = $themeCore;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function with(): array
|
||||
{
|
||||
// These needs to live in the main app since the menu open or closed changes the entire html layout
|
||||
if (session()->exists('userdata')) {
|
||||
session(['menuState' => $this->menuRepo->getSubmenuState('mainMenu') ?: 'open']);
|
||||
}
|
||||
|
||||
$menuType = $this->menuRepo->getSectionMenuType(FrontcontrollerCore::getCurrentRoute(), 'project');
|
||||
|
||||
$announcement = null;
|
||||
$announcement = self::dispatch_filter('appAnnouncement', $announcement);
|
||||
|
||||
return [
|
||||
'module' => strtolower(FrontcontrollerCore::getModuleName()),
|
||||
'section' => $menuType,
|
||||
'appAnnouncement' => $announcement,
|
||||
'themeBgUrl' => $this->themeCore->getBackgroundImage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Views/Composers/Entry.php
Normal file
30
app/Views/Composers/Entry.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Views\Composers;
|
||||
|
||||
use Leantime\Core\UI\Composer;
|
||||
use Leantime\Core\UI\Theme;
|
||||
|
||||
class Entry extends Composer
|
||||
{
|
||||
public static array $views = [
|
||||
'global::layouts.entry',
|
||||
];
|
||||
|
||||
private Theme $themeCore;
|
||||
|
||||
public function init(Theme $themeCore): void
|
||||
{
|
||||
$this->themeCore = $themeCore;
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$this->themeCore->getActive();
|
||||
$logoUrl = $this->themeCore->getLogoUrl();
|
||||
|
||||
return [
|
||||
'logoPath' => $logoUrl ?: BASE_URL.'/dist/images/logo.svg',
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Views/Composers/Footer.php
Normal file
27
app/Views/Composers/Footer.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Views\Composers;
|
||||
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
use Leantime\Core\UI\Composer;
|
||||
|
||||
class Footer extends Composer
|
||||
{
|
||||
public static array $views = [
|
||||
'global::sections.footer',
|
||||
];
|
||||
|
||||
protected AppSettings $settings;
|
||||
|
||||
public function init(AppSettings $settings): void
|
||||
{
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'version' => $this->settings->appVersion,
|
||||
];
|
||||
}
|
||||
}
|
||||
96
app/Views/Composers/Header.php
Normal file
96
app/Views/Composers/Header.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Views\Composers;
|
||||
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\UI\Composer;
|
||||
use Leantime\Core\UI\Theme;
|
||||
use Leantime\Domain\Setting\Repositories\Setting;
|
||||
|
||||
class Header extends Composer
|
||||
{
|
||||
public static array $views = [
|
||||
'global::sections.header',
|
||||
];
|
||||
|
||||
private Environment $config;
|
||||
|
||||
private Theme $themeCore;
|
||||
|
||||
private AppSettings $appSettings;
|
||||
|
||||
private Setting $settingsRepo;
|
||||
|
||||
public function init(
|
||||
Setting $settingsRepo,
|
||||
Environment $config,
|
||||
AppSettings $appSettings,
|
||||
Theme $themeCore
|
||||
): void {
|
||||
$this->settingsRepo = $settingsRepo;
|
||||
$this->config = $config;
|
||||
$this->appSettings = $appSettings;
|
||||
$this->themeCore = $themeCore;
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
// Batch-preload all theme settings in a single query on first load.
|
||||
// This populates SettingCache's in-memory tier so all subsequent
|
||||
// getSetting() calls from getActive(), getColorMode(), etc. are instant.
|
||||
$this->themeCore->preloadUserSettings();
|
||||
|
||||
$theme = $this->themeCore->getActive();
|
||||
$colorMode = $this->themeCore->getColorMode();
|
||||
$colorScheme = $this->themeCore->getColorScheme();
|
||||
$themeFont = $this->themeCore->getFont();
|
||||
|
||||
// Set colors to use
|
||||
if (! session()->exists('companysettings.sitename')) {
|
||||
$sitename = $this->settingsRepo->getSetting('companysettings.sitename');
|
||||
if ($sitename !== false) {
|
||||
session(['companysettings.sitename' => $sitename]);
|
||||
} else {
|
||||
session(['companysettings.sitename' => $this->config->sitename]);
|
||||
}
|
||||
}
|
||||
|
||||
$backgroundOpacity = 0.1;
|
||||
if ($this->themeCore->getBackgroundType() == 'image') {
|
||||
$backgroundOpacity = 1;
|
||||
}
|
||||
|
||||
return [
|
||||
'sitename' => session('companysettings.sitename') ?? '',
|
||||
'primaryColor' => $this->themeCore->getPrimaryColor(),
|
||||
'theme' => $theme,
|
||||
'version' => $this->appSettings->appVersion ?? '',
|
||||
'themeScripts' => [
|
||||
$this->themeCore->getJsUrl(),
|
||||
$this->themeCore->getCustomJsUrl(),
|
||||
],
|
||||
'themeColorMode' => $colorMode,
|
||||
'themeColorScheme' => $colorScheme,
|
||||
'themeFont' => $themeFont,
|
||||
'themeStyles' => [
|
||||
[
|
||||
'id' => 'themeStyleSheet',
|
||||
'url' => $this->themeCore->getStyleUrl(),
|
||||
],
|
||||
[
|
||||
'url' => $this->themeCore->getCustomStyleUrl(),
|
||||
],
|
||||
],
|
||||
'accents' => [
|
||||
$this->themeCore->getPrimaryColor(),
|
||||
$this->themeCore->getSecondaryColor(),
|
||||
false, // accent3 uses CSS default
|
||||
false, // accent4 uses CSS default
|
||||
],
|
||||
'themeBg' => $this->themeCore->getBackgroundImage(),
|
||||
'themeOpacity' => $backgroundOpacity,
|
||||
'themeType' => $this->themeCore->getBackgroundType(),
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Views/Composers/PageBottom.php
Normal file
36
app/Views/Composers/PageBottom.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Views\Composers;
|
||||
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\UI\Composer;
|
||||
|
||||
class PageBottom extends Composer
|
||||
{
|
||||
/**
|
||||
* @var array|string[]
|
||||
*/
|
||||
public static array $views = [
|
||||
'global::sections.pageBottom',
|
||||
];
|
||||
|
||||
protected AppSettings $settings;
|
||||
|
||||
protected Environment $environment;
|
||||
|
||||
public function init(AppSettings $settings, Environment $environment): void
|
||||
{
|
||||
$this->settings = $settings;
|
||||
$this->environment = $environment;
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'version' => $this->settings->appVersion,
|
||||
'poorMansCron' => $this->environment->get('poorMansCron'),
|
||||
'loggedIn' => session()->exists('userdata'),
|
||||
];
|
||||
}
|
||||
}
|
||||
339
app/Views/Templates/components/COMPONENTS.md
Normal file
339
app/Views/Templates/components/COMPONENTS.md
Normal 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.
|
||||
37
app/Views/Templates/components/accordion.blade.php
Normal file
37
app/Views/Templates/components/accordion.blade.php
Normal 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>
|
||||
197
app/Views/Templates/components/aiPanel.blade.php
Normal file
197
app/Views/Templates/components/aiPanel.blade.php
Normal 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="关闭">×</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
|
||||
36
app/Views/Templates/components/avatar.blade.php
Normal file
36
app/Views/Templates/components/avatar.blade.php
Normal 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>
|
||||
27
app/Views/Templates/components/badge.blade.php
Normal file
27
app/Views/Templates/components/badge.blade.php
Normal 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
|
||||
11
app/Views/Templates/components/button.blade.php
Normal file
11
app/Views/Templates/components/button.blade.php
Normal file
@@ -0,0 +1,11 @@
|
||||
@props([
|
||||
'link' => '#',
|
||||
'type' => 'primary',
|
||||
'tag' => 'a',
|
||||
])
|
||||
|
||||
<{{ $tag }} {{ $attributes->merge([
|
||||
'class' => 'btn btn-' . $type
|
||||
] + ($tag == 'a' ? ['href' => $link] : [])) }}>
|
||||
{{ $slot }}
|
||||
</{{ $tag }}>
|
||||
54
app/Views/Templates/components/dropdownPill.blade.php
Normal file
54
app/Views/Templates/components/dropdownPill.blade.php
Normal 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>
|
||||
<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>
|
||||
78
app/Views/Templates/components/emojiinput.blade.php
Normal file
78
app/Views/Templates/components/emojiinput.blade.php
Normal 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);"> </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>
|
||||
93
app/Views/Templates/components/forms/button.blade.php
Normal file
93
app/Views/Templates/components/forms/button.blade.php
Normal 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
|
||||
70
app/Views/Templates/components/forms/text-input.blade.php
Normal file
70
app/Views/Templates/components/forms/text-input.blade.php
Normal 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 }} />
|
||||
45
app/Views/Templates/components/forms/textarea.blade.php
Normal file
45
app/Views/Templates/components/forms/textarea.blade.php
Normal 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>
|
||||
82
app/Views/Templates/components/hx.blade.php
Normal file
82
app/Views/Templates/components/hx.blade.php
Normal 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>
|
||||
11
app/Views/Templates/components/inlineLinks.blade.php
Normal file
11
app/Views/Templates/components/inlineLinks.blade.php
Normal 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>
|
||||
50
app/Views/Templates/components/inlineSelect.blade.php
Normal file
50
app/Views/Templates/components/inlineSelect.blade.php
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
15
app/Views/Templates/components/kanban/sprint-icon.blade.php
Normal file
15
app/Views/Templates/components/kanban/sprint-icon.blade.php
Normal 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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
50
app/Views/Templates/components/kanban/tshirt-icon.blade.php
Normal file
50
app/Views/Templates/components/kanban/tshirt-icon.blade.php
Normal 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>
|
||||
21
app/Views/Templates/components/kanban/type-icon.blade.php
Normal file
21
app/Views/Templates/components/kanban/type-icon.blade.php
Normal 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>
|
||||
60
app/Views/Templates/components/kanban/user-avatar.blade.php
Normal file
60
app/Views/Templates/components/kanban/user-avatar.blade.php
Normal 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>
|
||||
11
app/Views/Templates/components/loader.blade.php
Normal file
11
app/Views/Templates/components/loader.blade.php
Normal 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>
|
||||
138
app/Views/Templates/components/loadingText.blade.php
Normal file
138
app/Views/Templates/components/loadingText.blade.php
Normal 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> <p style="width:25%" class="tw-float-right tw-ml-sm">Loading...</p> <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> <p style="width:25%" class="tw-float-right tw-ml-sm">Loading...</p> <p style="width:10%" class="tw-float-right tw-ml-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endfor
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
146
app/Views/Templates/components/navigation/tabs.blade.php
Normal file
146
app/Views/Templates/components/navigation/tabs.blade.php
Normal 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
|
||||
@@ -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>
|
||||
31
app/Views/Templates/components/navigation/tabs/tab.blade.php
Normal file
31
app/Views/Templates/components/navigation/tabs/tab.blade.php
Normal 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>
|
||||
17
app/Views/Templates/components/pageheader.blade.php
Normal file
17
app/Views/Templates/components/pageheader.blade.php
Normal 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')
|
||||
94
app/Views/Templates/components/pdfPreview.blade.php
Normal file
94
app/Views/Templates/components/pdfPreview.blade.php
Normal 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
|
||||
69
app/Views/Templates/components/periodpicker.blade.php
Normal file
69
app/Views/Templates/components/periodpicker.blade.php
Normal 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
|
||||
69
app/Views/Templates/components/selectable.blade.php
Normal file
69
app/Views/Templates/components/selectable.blade.php
Normal 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')
|
||||
36
app/Views/Templates/components/stageflow/card.blade.php
Normal file
36
app/Views/Templates/components/stageflow/card.blade.php
Normal 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>
|
||||
71
app/Views/Templates/components/stageflow/item.blade.php
Normal file
71
app/Views/Templates/components/stageflow/item.blade.php
Normal 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>
|
||||
416
app/Views/Templates/components/stageflow/styles.blade.php
Normal file
416
app/Views/Templates/components/stageflow/styles.blade.php
Normal 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
|
||||
59
app/Views/Templates/components/subjectSwitcher.blade.php
Normal file
59
app/Views/Templates/components/subjectSwitcher.blade.php
Normal 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>
|
||||
24
app/Views/Templates/components/undrawSvg.blade.php
Normal file
24
app/Views/Templates/components/undrawSvg.blade.php
Normal 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>
|
||||
68
app/Views/Templates/layouts/app.blade.php
Normal file
68
app/Views/Templates/layouts/app.blade.php
Normal 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')"
|
||||
> </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>
|
||||
5
app/Views/Templates/layouts/blank.blade.php
Normal file
5
app/Views/Templates/layouts/blank.blade.php
Normal file
@@ -0,0 +1,5 @@
|
||||
@isset($action, $module)
|
||||
@include("$module::$action")
|
||||
@else
|
||||
@yield('content')
|
||||
@endisset
|
||||
67
app/Views/Templates/layouts/entry.blade.php
Normal file
67
app/Views/Templates/layouts/entry.blade.php
Normal 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>
|
||||
55
app/Views/Templates/layouts/error.blade.php
Normal file
55
app/Views/Templates/layouts/error.blade.php
Normal 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>
|
||||
62
app/Views/Templates/layouts/registration.blade.php
Normal file
62
app/Views/Templates/layouts/registration.blade.php
Normal 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>
|
||||
5
app/Views/Templates/sections/appAnnouncement.blade.php
Normal file
5
app/Views/Templates/sections/appAnnouncement.blade.php
Normal file
@@ -0,0 +1,5 @@
|
||||
@if(isset($appAnnouncement) && $appAnnouncement)
|
||||
<div class="announcementBanner">
|
||||
{!! $appAnnouncement !!}
|
||||
</div>
|
||||
@endif
|
||||
28
app/Views/Templates/sections/footer.blade.php
Normal file
28
app/Views/Templates/sections/footer.blade.php
Normal 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')
|
||||
163
app/Views/Templates/sections/header.blade.php
Normal file
163
app/Views/Templates/sections/header.blade.php
Normal 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')
|
||||
34
app/Views/Templates/sections/pageBottom.blade.php
Normal file
34
app/Views/Templates/sections/pageBottom.blade.php
Normal 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')
|
||||
Reference in New Issue
Block a user