This commit is contained in:
F4ilji
2026-04-07 17:54:26 +05:00
parent 04adba6682
commit 936b1fb5b0
468 changed files with 43498 additions and 4176 deletions
+2 -1
View File
@@ -13,5 +13,6 @@ dump.sql
.DS_Store .DS_Store
**/. **/.
.cursor .cursor
hot .cache
QWEN.md QWEN.md
hot
+30
View File
@@ -0,0 +1,30 @@
{
"permissions": {
"allow": [
"Bash(mkdir *)",
"Bash(docker exec *)",
"Bash(rm *)",
"Bash(npm install)",
"Bash(npm run *)",
"Bash(sed *)",
"Bash(mv *)",
"Bash(git checkout *)",
"Bash(cp *)",
"Bash(find *)",
"Bash(grep *)",
"Bash(php *)",
"Bash(ls *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git show *)",
"Bash(git *)",
"Bash(docker compose up *)",
"Bash(docker compose restart *)",
"Bash(docker compose ps)",
"Bash(docker compose build *)",
"Bash(curl *)",
"Bash(cat *)"
]
},
"$version": 3
}
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(mkdir *)"
]
}
}
+74
View File
@@ -0,0 +1,74 @@
# Design System Specification
This document formalizes the design system extracted from the Preline CMS Dashboard. The system relies heavily on **semantic CSS variables** mapped to Tailwind CSS classes, allowing for seamless dark mode support and theme switching.
## 1. Color Palette (Semantic)
The system uses a layered approach to backgrounds and text to create visual hierarchy.
| Name | Tailwind Class | Usage |
| --- | --- | --- |
| **Primary** | `bg-primary` / `text-primary` | Main actions, active states, and brand highlights. |
| **Background (Base)** | `bg-background-2` | The lowest level background (page body). |
| **Surface/Layer** | `bg-layer` | Main content containers and cards. |
| **Surface Muted** | `bg-surface` | Secondary containers or highlighted internal areas. |
| **Navbar/Sidebar** | `bg-navbar-2` / `bg-sidebar-2` | Specialized background for navigation components. |
| **Foreground (Base)** | `text-foreground` | High-contrast primary text. |
| **Muted 1** | `text-muted-foreground-1` | Secondary text, labels, and descriptions. |
| **Muted 2** | `text-muted-foreground-2` | Placeholder text or tertiary information. |
| **Border (Subtle)** | `border-line-2` | Internal dividers and low-contrast separators. |
| **Border (Strong)** | `border-layer-line` | Main component boundaries and container edges. |
## 2. Typography
The system uses a sans-serif stack (Inter) as the default for the UI, with specific sizing for administrative density.
- **Heading 1 (Page):** `font-medium text-lg text-foreground` (Used for dashboard titles).
- **Heading 2 (Section):** `font-medium text-foreground` (Used for card titles).
- **Body Text (Standard):** `text-sm text-foreground` (The default UI scale).
- **Body Text (Condensed):** `text-[13px] text-muted-foreground-1` (Used for metadata, authors, and side-info).
- **Labels/Captions:** `font-medium text-xs uppercase text-muted-foreground-1` (Sidebar headers).
- **Navigation Items:** `text-sm font-medium` (Active) or `text-sm font-normal`.
## 3. Spacing & Layout
- **Layout Shell:**
- Header height: `pt-13.5` (~54px offset).
- Sidebar width: `w-60` (240px).
- Main container: `h-[calc(100dvh-62px)]` (Full height minus header).
- **Standard Card Padding:** `p-4` (Standardizes internal white space).
- **Internal Gaps:**
- `gap-x-1.5`: Icon + Text pairing.
- `gap-2`: Small grid elements or button groups.
- `gap-5`: Large content sections (e.g., Image + Text blocks).
## 4. Components Library (Tailwind Patterns)
### Buttons
- **Primary (Solid):** `bg-primary text-primary-foreground rounded-md text-xl font-semibold` (Icon-heavy).
- **Secondary (Soft):** `bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary-hover` (Profile actions).
- **Ghost/Outline:** `border border-layer-line text-layer-foreground rounded-lg hover:bg-primary-50` (Tools/Refresh).
- **Soft Badge Link:** `bg-primary-500/10 border border-primary-200 text-primary-700 rounded-full` (Upsell/Badges).
### Navigation (Sidebar)
- **Container:** `flex flex-col gap-y-1`
- **Active Item:** `bg-sidebar-2-nav-active font-medium text-sidebar-2-nav-foreground`
- **Inactive Item:** `text-sidebar-2-nav-foreground hover:bg-sidebar-2-nav-hover`
- **Group Divider:** `pt-3 mt-3 border-t border-sidebar-2-divider`
### Card / Container
- **Main Wrapper:** `bg-layer border border-layer-line shadow-xs rounded-lg`
- **Header:** `py-3 px-4 border-b border-card-line`
- **Nested Card:** `p-4 bg-surface rounded-lg` (Used for CTAs inside sidebars).
### Input / Select (Pseudo)
- **Search Bar:** `p-1.5 ps-2.5 w-full inline-flex items-center rounded-lg bg-layer border border-layer-line shadow-xs`
- **KBD Shortcut:** `py-px px-1.5 border border-line-2 rounded-md text-[11px]`
## 5. Global Styles
- **Border Radius:**
- `rounded-lg`: Standard for cards, buttons, and inputs.
- `rounded-xl`: Used for floating dropdowns and modals.
- `rounded-full`: Used for avatars and pill-style badges.
- **Shadows:**
- `shadow-xs`: Default for persistent surface elements.
- `shadow-xl`: Elevated states for dropdown menus.
- **Transitions:** `transition-all duration-300` (Applied to sidebar and interactive overlays).
- **Interactive States:** Uses custom semantic hover/focus classes: `hover:bg-muted-hover`, `focus:bg-muted-focus`.
+659
View File
@@ -0,0 +1,659 @@
---
name: ui-ux-pro-max
description: "UI/UX design intelligence for web and mobile. Includes 50+ styles, 161 color palettes, 57 font pairings, 161 product types, 99 UX guidelines, and 25 chart types across 10 stacks (React, Next.js, Vue, Svelte, SwiftUI, React Native, Flutter, Tailwind, shadcn/ui, and HTML/CSS). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, and check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, and mobile app. Elements: button, modal, navbar, sidebar, card, table, form, and chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, and flat design. Topics: color systems, accessibility, animation, layout, typography, font pairing, spacing, interaction states, shadow, and gradient. Integrations: shadcn/ui MCP for component search and examples."
---
# UI/UX Pro Max - Design Intelligence
Comprehensive design guide for web and mobile applications. Contains 50+ styles, 161 color palettes, 57 font pairings, 161 product types with reasoning rules, 99 UX guidelines, and 25 chart types across 10 technology stacks. Searchable database with priority-based recommendations.
## When to Apply
This Skill should be used when the task involves **UI structure, visual design decisions, interaction patterns, or user experience quality control**.
### Must Use
This Skill must be invoked in the following situations:
- Designing new pages (Landing Page, Dashboard, Admin, SaaS, Mobile App)
- Creating or refactoring UI components (buttons, modals, forms, tables, charts, etc.)
- Choosing color schemes, typography systems, spacing standards, or layout systems
- Reviewing UI code for user experience, accessibility, or visual consistency
- Implementing navigation structures, animations, or responsive behavior
- Making product-level design decisions (style, information hierarchy, brand expression)
- Improving perceived quality, clarity, or usability of interfaces
### Recommended
This Skill is recommended in the following situations:
- UI looks "not professional enough" but the reason is unclear
- Receiving feedback on usability or experience
- Pre-launch UI quality optimization
- Aligning cross-platform design (Web / iOS / Android)
- Building design systems or reusable component libraries
### Skip
This Skill is not needed in the following situations:
- Pure backend logic development
- Only involving API or database design
- Performance optimization unrelated to the interface
- Infrastructure or DevOps work
- Non-visual scripts or automation tasks
**Decision criteria**: If the task will change how a feature **looks, feels, moves, or is interacted with**, this Skill should be used.
## Rule Categories by Priority
*For human/AI reference: follow priority 1→10 to decide which rule category to focus on first; use `--domain <Domain>` to query details when needed. Scripts do not read this table.*
| Priority | Category | Impact | Domain | Key Checks (Must Have) | Anti-Patterns (Avoid) |
|----------|----------|--------|--------|------------------------|------------------------|
| 1 | Accessibility | CRITICAL | `ux` | Contrast 4.5:1, Alt text, Keyboard nav, Aria-labels | Removing focus rings, Icon-only buttons without labels |
| 2 | Touch & Interaction | CRITICAL | `ux` | Min size 44×44px, 8px+ spacing, Loading feedback | Reliance on hover only, Instant state changes (0ms) |
| 3 | Performance | HIGH | `ux` | WebP/AVIF, Lazy loading, Reserve space (CLS &lt; 0.1) | Layout thrashing, Cumulative Layout Shift |
| 4 | Style Selection | HIGH | `style`, `product` | Match product type, Consistency, SVG icons (no emoji) | Mixing flat & skeuomorphic randomly, Emoji as icons |
| 5 | Layout & Responsive | HIGH | `ux` | Mobile-first breakpoints, Viewport meta, No horizontal scroll | Horizontal scroll, Fixed px container widths, Disable zoom |
| 6 | Typography & Color | MEDIUM | `typography`, `color` | Base 16px, Line-height 1.5, Semantic color tokens | Text &lt; 12px body, Gray-on-gray, Raw hex in components |
| 7 | Animation | MEDIUM | `ux` | Duration 150300ms, Motion conveys meaning, Spatial continuity | Decorative-only animation, Animating width/height, No reduced-motion |
| 8 | Forms & Feedback | MEDIUM | `ux` | Visible labels, Error near field, Helper text, Progressive disclosure | Placeholder-only label, Errors only at top, Overwhelm upfront |
| 9 | Navigation Patterns | HIGH | `ux` | Predictable back, Bottom nav ≤5, Deep linking | Overloaded nav, Broken back behavior, No deep links |
| 10 | Charts & Data | LOW | `chart` | Legends, Tooltips, Accessible colors | Relying on color alone to convey meaning |
## Quick Reference
### 1. Accessibility (CRITICAL)
- `color-contrast` - Minimum 4.5:1 ratio for normal text (large text 3:1); Material Design
- `focus-states` - Visible focus rings on interactive elements (24px; Apple HIG, MD)
- `alt-text` - Descriptive alt text for meaningful images
- `aria-labels` - aria-label for icon-only buttons; accessibilityLabel in native (Apple HIG)
- `keyboard-nav` - Tab order matches visual order; full keyboard support (Apple HIG)
- `form-labels` - Use label with for attribute
- `skip-links` - Skip to main content for keyboard users
- `heading-hierarchy` - Sequential h1→h6, no level skip
- `color-not-only` - Don't convey info by color alone (add icon/text)
- `dynamic-type` - Support system text scaling; avoid truncation as text grows (Apple Dynamic Type, MD)
- `reduced-motion` - Respect prefers-reduced-motion; reduce/disable animations when requested (Apple Reduced Motion API, MD)
- `voiceover-sr` - Meaningful accessibilityLabel/accessibilityHint; logical reading order for VoiceOver/screen readers (Apple HIG, MD)
- `escape-routes` - Provide cancel/back in modals and multi-step flows (Apple HIG)
- `keyboard-shortcuts` - Preserve system and a11y shortcuts; offer keyboard alternatives for drag-and-drop (Apple HIG)
### 2. Touch & Interaction (CRITICAL)
- `touch-target-size` - Min 44×44pt (Apple) / 48×48dp (Material); extend hit area beyond visual bounds if needed
- `touch-spacing` - Minimum 8px/8dp gap between touch targets (Apple HIG, MD)
- `hover-vs-tap` - Use click/tap for primary interactions; don't rely on hover alone
- `loading-buttons` - Disable button during async operations; show spinner or progress
- `error-feedback` - Clear error messages near problem
- `cursor-pointer` - Add cursor-pointer to clickable elements (Web)
- `gesture-conflicts` - Avoid horizontal swipe on main content; prefer vertical scroll
- `tap-delay` - Use touch-action: manipulation to reduce 300ms delay (Web)
- `standard-gestures` - Use platform standard gestures consistently; don't redefine (e.g. swipe-back, pinch-zoom) (Apple HIG)
- `system-gestures` - Don't block system gestures (Control Center, back swipe, etc.) (Apple HIG)
- `press-feedback` - Visual feedback on press (ripple/highlight; MD state layers)
- `haptic-feedback` - Use haptic for confirmations and important actions; avoid overuse (Apple HIG)
- `gesture-alternative` - Don't rely on gesture-only interactions; always provide visible controls for critical actions
- `safe-area-awareness` - Keep primary touch targets away from notch, Dynamic Island, gesture bar and screen edges
- `no-precision-required` - Avoid requiring pixel-perfect taps on small icons or thin edges
- `swipe-clarity` - Swipe actions must show clear affordance or hint (chevron, label, tutorial)
- `drag-threshold` - Use a movement threshold before starting drag to avoid accidental drags
### 3. Performance (HIGH)
- `image-optimization` - Use WebP/AVIF, responsive images (srcset/sizes), lazy load non-critical assets
- `image-dimension` - Declare width/height or use aspect-ratio to prevent layout shift (Core Web Vitals: CLS)
- `font-loading` - Use font-display: swap/optional to avoid invisible text (FOIT); reserve space to reduce layout shift (MD)
- `font-preload` - Preload only critical fonts; avoid overusing preload on every variant
- `critical-css` - Prioritize above-the-fold CSS (inline critical CSS or early-loaded stylesheet)
- `lazy-loading` - Lazy load non-hero components via dynamic import / route-level splitting
- `bundle-splitting` - Split code by route/feature (React Suspense / Next.js dynamic) to reduce initial load and TTI
- `third-party-scripts` - Load third-party scripts async/defer; audit and remove unnecessary ones (MD)
- `reduce-reflows` - Avoid frequent layout reads/writes; batch DOM reads then writes
- `content-jumping` - Reserve space for async content to avoid layout jumps (Core Web Vitals: CLS)
- `lazy-load-below-fold` - Use loading="lazy" for below-the-fold images and heavy media
- `virtualize-lists` - Virtualize lists with 50+ items to improve memory efficiency and scroll performance
- `main-thread-budget` - Keep per-frame work under ~16ms for 60fps; move heavy tasks off main thread (HIG, MD)
- `progressive-loading` - Use skeleton screens / shimmer instead of long blocking spinners for >1s operations (Apple HIG)
- `input-latency` - Keep input latency under ~100ms for taps/scrolls (Material responsiveness standard)
- `tap-feedback-speed` - Provide visual feedback within 100ms of tap (Apple HIG)
- `debounce-throttle` - Use debounce/throttle for high-frequency events (scroll, resize, input)
- `offline-support` - Provide offline state messaging and basic fallback (PWA / mobile)
- `network-fallback` - Offer degraded modes for slow networks (lower-res images, fewer animations)
### 4. Style Selection (HIGH)
- `style-match` - Match style to product type (use `--design-system` for recommendations)
- `consistency` - Use same style across all pages
- `no-emoji-icons` - Use SVG icons (Heroicons, Lucide), not emojis
- `color-palette-from-product` - Choose palette from product/industry (search `--domain color`)
- `effects-match-style` - Shadows, blur, radius aligned with chosen style (glass / flat / clay etc.)
- `platform-adaptive` - Respect platform idioms (iOS HIG vs Material): navigation, controls, typography, motion
- `state-clarity` - Make hover/pressed/disabled states visually distinct while staying on-style (Material state layers)
- `elevation-consistent` - Use a consistent elevation/shadow scale for cards, sheets, modals; avoid random shadow values
- `dark-mode-pairing` - Design light/dark variants together to keep brand, contrast, and style consistent
- `icon-style-consistent` - Use one icon set/visual language (stroke width, corner radius) across the product
- `system-controls` - Prefer native/system controls over fully custom ones; only customize when branding requires it (Apple HIG)
- `blur-purpose` - Use blur to indicate background dismissal (modals, sheets), not as decoration (Apple HIG)
- `primary-action` - Each screen should have only one primary CTA; secondary actions visually subordinate (Apple HIG)
### 5. Layout & Responsive (HIGH)
- `viewport-meta` - width=device-width initial-scale=1 (never disable zoom)
- `mobile-first` - Design mobile-first, then scale up to tablet and desktop
- `breakpoint-consistency` - Use systematic breakpoints (e.g. 375 / 768 / 1024 / 1440)
- `readable-font-size` - Minimum 16px body text on mobile (avoids iOS auto-zoom)
- `line-length-control` - Mobile 3560 chars per line; desktop 6075 chars
- `horizontal-scroll` - No horizontal scroll on mobile; ensure content fits viewport width
- `spacing-scale` - Use 4pt/8dp incremental spacing system (Material Design)
- `touch-density` - Keep component spacing comfortable for touch: not cramped, not causing mis-taps
- `container-width` - Consistent max-width on desktop (max-w-6xl / 7xl)
- `z-index-management` - Define layered z-index scale (e.g. 0 / 10 / 20 / 40 / 100 / 1000)
- `fixed-element-offset` - Fixed navbar/bottom bar must reserve safe padding for underlying content
- `scroll-behavior` - Avoid nested scroll regions that interfere with the main scroll experience
- `viewport-units` - Prefer min-h-dvh over 100vh on mobile
- `orientation-support` - Keep layout readable and operable in landscape mode
- `content-priority` - Show core content first on mobile; fold or hide secondary content
- `visual-hierarchy` - Establish hierarchy via size, spacing, contrast — not color alone
### 6. Typography & Color (MEDIUM)
- `line-height` - Use 1.5-1.75 for body text
- `line-length` - Limit to 65-75 characters per line
- `font-pairing` - Match heading/body font personalities
- `font-scale` - Consistent type scale (e.g. 12 14 16 18 24 32)
- `contrast-readability` - Darker text on light backgrounds (e.g. slate-900 on white)
- `text-styles-system` - Use platform type system: iOS 11 Dynamic Type styles / Material 5 type roles (display, headline, title, body, label) (HIG, MD)
- `weight-hierarchy` - Use font-weight to reinforce hierarchy: Bold headings (600700), Regular body (400), Medium labels (500) (MD)
- `color-semantic` - Define semantic color tokens (primary, secondary, error, surface, on-surface) not raw hex in components (Material color system)
- `color-dark-mode` - Dark mode uses desaturated / lighter tonal variants, not inverted colors; test contrast separately (HIG, MD)
- `color-accessible-pairs` - Foreground/background pairs must meet 4.5:1 (AA) or 7:1 (AAA); use tools to verify (WCAG, MD)
- `color-not-decorative-only` - Functional color (error red, success green) must include icon/text; avoid color-only meaning (HIG, MD)
- `truncation-strategy` - Prefer wrapping over truncation; when truncating use ellipsis and provide full text via tooltip/expand (Apple HIG)
- `letter-spacing` - Respect default letter-spacing per platform; avoid tight tracking on body text (HIG, MD)
- `number-tabular` - Use tabular/monospaced figures for data columns, prices, and timers to prevent layout shift
- `whitespace-balance` - Use whitespace intentionally to group related items and separate sections; avoid visual clutter (Apple HIG)
### 7. Animation (MEDIUM)
- `duration-timing` - Use 150300ms for micro-interactions; complex transitions ≤400ms; avoid >500ms (MD)
- `transform-performance` - Use transform/opacity only; avoid animating width/height/top/left
- `loading-states` - Show skeleton or progress indicator when loading exceeds 300ms
- `excessive-motion` - Animate 1-2 key elements per view max
- `easing` - Use ease-out for entering, ease-in for exiting; avoid linear for UI transitions
- `motion-meaning` - Every animation must express a cause-effect relationship, not just be decorative (Apple HIG)
- `state-transition` - State changes (hover / active / expanded / collapsed / modal) should animate smoothly, not snap
- `continuity` - Page/screen transitions should maintain spatial continuity (shared element, directional slide) (Apple HIG)
- `parallax-subtle` - Use parallax sparingly; must respect reduced-motion and not cause disorientation (Apple HIG)
- `spring-physics` - Prefer spring/physics-based curves over linear or cubic-bezier for natural feel (Apple HIG fluid animations)
- `exit-faster-than-enter` - Exit animations shorter than enter (~6070% of enter duration) to feel responsive (MD motion)
- `stagger-sequence` - Stagger list/grid item entrance by 3050ms per item; avoid all-at-once or too-slow reveals (MD)
- `shared-element-transition` - Use shared element / hero transitions for visual continuity between screens (MD, HIG)
- `interruptible` - Animations must be interruptible; user tap/gesture cancels in-progress animation immediately (Apple HIG)
- `no-blocking-animation` - Never block user input during an animation; UI must stay interactive (Apple HIG)
- `fade-crossfade` - Use crossfade for content replacement within the same container (MD)
- `scale-feedback` - Subtle scale (0.951.05) on press for tappable cards/buttons; restore on release (HIG, MD)
- `gesture-feedback` - Drag, swipe, and pinch must provide real-time visual response tracking the finger (MD Motion)
- `hierarchy-motion` - Use translate/scale direction to express hierarchy: enter from below = deeper, exit upward = back (MD)
- `motion-consistency` - Unify duration/easing tokens globally; all animations share the same rhythm and feel
- `opacity-threshold` - Fading elements should not linger below opacity 0.2; either fade fully or remain visible
- `modal-motion` - Modals/sheets should animate from their trigger source (scale+fade or slide-in) for spatial context (HIG, MD)
- `navigation-direction` - Forward navigation animates left/up; backward animates right/down — keep direction logically consistent (HIG)
- `layout-shift-avoid` - Animations must not cause layout reflow or CLS; use transform for position changes
### 8. Forms & Feedback (MEDIUM)
- `input-labels` - Visible label per input (not placeholder-only)
- `error-placement` - Show error below the related field
- `submit-feedback` - Loading then success/error state on submit
- `required-indicators` - Mark required fields (e.g. asterisk)
- `empty-states` - Helpful message and action when no content
- `toast-dismiss` - Auto-dismiss toasts in 3-5s
- `confirmation-dialogs` - Confirm before destructive actions
- `input-helper-text` - Provide persistent helper text below complex inputs, not just placeholder (Material Design)
- `disabled-states` - Disabled elements use reduced opacity (0.380.5) + cursor change + semantic attribute (MD)
- `progressive-disclosure` - Reveal complex options progressively; don't overwhelm users upfront (Apple HIG)
- `inline-validation` - Validate on blur (not keystroke); show error only after user finishes input (MD)
- `input-type-keyboard` - Use semantic input types (email, tel, number) to trigger the correct mobile keyboard (HIG, MD)
- `password-toggle` - Provide show/hide toggle for password fields (MD)
- `autofill-support` - Use autocomplete / textContentType attributes so the system can autofill (HIG, MD)
- `undo-support` - Allow undo for destructive or bulk actions (e.g. "Undo delete" toast) (Apple HIG)
- `success-feedback` - Confirm completed actions with brief visual feedback (checkmark, toast, color flash) (MD)
- `error-recovery` - Error messages must include a clear recovery path (retry, edit, help link) (HIG, MD)
- `multi-step-progress` - Multi-step flows show step indicator or progress bar; allow back navigation (MD)
- `form-autosave` - Long forms should auto-save drafts to prevent data loss on accidental dismissal (Apple HIG)
- `sheet-dismiss-confirm` - Confirm before dismissing a sheet/modal with unsaved changes (Apple HIG)
- `error-clarity` - Error messages must state cause + how to fix (not just "Invalid input") (HIG, MD)
- `field-grouping` - Group related fields logically (fieldset/legend or visual grouping) (MD)
- `read-only-distinction` - Read-only state should be visually and semantically different from disabled (MD)
- `focus-management` - After submit error, auto-focus the first invalid field (WCAG, MD)
- `error-summary` - For multiple errors, show summary at top with anchor links to each field (WCAG)
- `touch-friendly-input` - Mobile input height ≥44px to meet touch target requirements (Apple HIG)
- `destructive-emphasis` - Destructive actions use semantic danger color (red) and are visually separated from primary actions (HIG, MD)
- `toast-accessibility` - Toasts must not steal focus; use aria-live="polite" for screen reader announcement (WCAG)
- `aria-live-errors` - Form errors use aria-live region or role="alert" to notify screen readers (WCAG)
- `contrast-feedback` - Error and success state colors must meet 4.5:1 contrast ratio (WCAG, MD)
- `timeout-feedback` - Request timeout must show clear feedback with retry option (MD)
### 9. Navigation Patterns (HIGH)
- `bottom-nav-limit` - Bottom navigation max 5 items; use labels with icons (Material Design)
- `drawer-usage` - Use drawer/sidebar for secondary navigation, not primary actions (Material Design)
- `back-behavior` - Back navigation must be predictable and consistent; preserve scroll/state (Apple HIG, MD)
- `deep-linking` - All key screens must be reachable via deep link / URL for sharing and notifications (Apple HIG, MD)
- `tab-bar-ios` - iOS: use bottom Tab Bar for top-level navigation (Apple HIG)
- `top-app-bar-android` - Android: use Top App Bar with navigation icon for primary structure (Material Design)
- `nav-label-icon` - Navigation items must have both icon and text label; icon-only nav harms discoverability (MD)
- `nav-state-active` - Current location must be visually highlighted (color, weight, indicator) in navigation (HIG, MD)
- `nav-hierarchy` - Primary nav (tabs/bottom bar) vs secondary nav (drawer/settings) must be clearly separated (MD)
- `modal-escape` - Modals and sheets must offer a clear close/dismiss affordance; swipe-down to dismiss on mobile (Apple HIG)
- `search-accessible` - Search must be easily reachable (top bar or tab); provide recent/suggested queries (MD)
- `breadcrumb-web` - Web: use breadcrumbs for 3+ level deep hierarchies to aid orientation (MD)
- `state-preservation` - Navigating back must restore previous scroll position, filter state, and input (HIG, MD)
- `gesture-nav-support` - Support system gesture navigation (iOS swipe-back, Android predictive back) without conflict (HIG, MD)
- `tab-badge` - Use badges on nav items sparingly to indicate unread/pending; clear after user visits (HIG, MD)
- `overflow-menu` - When actions exceed available space, use overflow/more menu instead of cramming (MD)
- `bottom-nav-top-level` - Bottom nav is for top-level screens only; never nest sub-navigation inside it (MD)
- `adaptive-navigation` - Large screens (≥1024px) prefer sidebar; small screens use bottom/top nav (Material Adaptive)
- `back-stack-integrity` - Never silently reset the navigation stack or unexpectedly jump to home (HIG, MD)
- `navigation-consistency` - Navigation placement must stay the same across all pages; don't change by page type
- `avoid-mixed-patterns` - Don't mix Tab + Sidebar + Bottom Nav at the same hierarchy level
- `modal-vs-navigation` - Modals must not be used for primary navigation flows; they break the user's path (HIG)
- `focus-on-route-change` - After page transition, move focus to main content region for screen reader users (WCAG)
- `persistent-nav` - Core navigation must remain reachable from deep pages; don't hide it entirely in sub-flows (HIG, MD)
- `destructive-nav-separation` - Dangerous actions (delete account, logout) must be visually and spatially separated from normal nav items (HIG, MD)
- `empty-nav-state` - When a nav destination is unavailable, explain why instead of silently hiding it (MD)
### 10. Charts & Data (LOW)
- `chart-type` - Match chart type to data type (trend → line, comparison → bar, proportion → pie/donut)
- `color-guidance` - Use accessible color palettes; avoid red/green only pairs for colorblind users (WCAG, MD)
- `data-table` - Provide table alternative for accessibility; charts alone are not screen-reader friendly (WCAG)
- `pattern-texture` - Supplement color with patterns, textures, or shapes so data is distinguishable without color (WCAG, MD)
- `legend-visible` - Always show legend; position near the chart, not detached below a scroll fold (MD)
- `tooltip-on-interact` - Provide tooltips/data labels on hover (Web) or tap (mobile) showing exact values (HIG, MD)
- `axis-labels` - Label axes with units and readable scale; avoid truncated or rotated labels on mobile
- `responsive-chart` - Charts must reflow or simplify on small screens (e.g. horizontal bar instead of vertical, fewer ticks)
- `empty-data-state` - Show meaningful empty state when no data exists ("No data yet" + guidance), not a blank chart (MD)
- `loading-chart` - Use skeleton or shimmer placeholder while chart data loads; don't show an empty axis frame
- `animation-optional` - Chart entrance animations must respect prefers-reduced-motion; data should be readable immediately (HIG)
- `large-dataset` - For 1000+ data points, aggregate or sample; provide drill-down for detail instead of rendering all (MD)
- `number-formatting` - Use locale-aware formatting for numbers, dates, currencies on axes and labels (HIG, MD)
- `touch-target-chart` - Interactive chart elements (points, segments) must have ≥44pt tap area or expand on touch (Apple HIG)
- `no-pie-overuse` - Avoid pie/donut for >5 categories; switch to bar chart for clarity
- `contrast-data` - Data lines/bars vs background ≥3:1; data text labels ≥4.5:1 (WCAG)
- `legend-interactive` - Legends should be clickable to toggle series visibility (MD)
- `direct-labeling` - For small datasets, label values directly on the chart to reduce eye travel
- `tooltip-keyboard` - Tooltip content must be keyboard-reachable and not rely on hover alone (WCAG)
- `sortable-table` - Data tables must support sorting with aria-sort indicating current sort state (WCAG)
- `axis-readability` - Axis ticks must not be cramped; maintain readable spacing, auto-skip on small screens
- `data-density` - Limit information density per chart to avoid cognitive overload; split into multiple charts if needed
- `trend-emphasis` - Emphasize data trends over decoration; avoid heavy gradients/shadows that obscure the data
- `gridline-subtle` - Grid lines should be low-contrast (e.g. gray-200) so they don't compete with data
- `focusable-elements` - Interactive chart elements (points, bars, slices) must be keyboard-navigable (WCAG)
- `screen-reader-summary` - Provide a text summary or aria-label describing the chart's key insight for screen readers (WCAG)
- `error-state-chart` - Data load failure must show error message with retry action, not a broken/empty chart
- `export-option` - For data-heavy products, offer CSV/image export of chart data
- `drill-down-consistency` - Drill-down interactions must maintain a clear back-path and hierarchy breadcrumb
- `time-scale-clarity` - Time series charts must clearly label time granularity (day/week/month) and allow switching
## How to Use
Search specific domains using the CLI tool below.
---
## Prerequisites
Check if Python is installed:
```bash
python3 --version || python --version
```
If Python is not installed, install it based on user's OS:
**macOS:**
```bash
brew install python3
```
**Ubuntu/Debian:**
```bash
sudo apt update && sudo apt install python3
```
**Windows:**
```powershell
winget install Python.Python.3.12
```
---
## How to Use This Skill
Use this skill when the user requests any of the following:
| Scenario | Trigger Examples | Start From |
|----------|-----------------|------------|
| **New project / page** | "Build a landing page", "Build a dashboard" | Step 1 → Step 2 (design system) |
| **New component** | "Create a pricing card", "Add a modal" | Step 3 (domain search: style, ux) |
| **Choose style / color / font** | "What style fits a fintech app?", "Recommend a color palette" | Step 2 (design system) |
| **Review existing UI** | "Review this page for UX issues", "Check accessibility" | Quick Reference checklist above |
| **Fix a UI bug** | "Button hover is broken", "Layout shifts on load" | Quick Reference → relevant section |
| **Improve / optimize** | "Make this faster", "Improve mobile experience" | Step 3 (domain search: ux, react) |
| **Implement dark mode** | "Add dark mode support" | Step 3 (domain: style "dark mode") |
| **Add charts / data viz** | "Add an analytics dashboard chart" | Step 3 (domain: chart) |
| **Stack best practices** | "React performance tips"、"SwiftUI navigation" | Step 4 (stack search) |
Follow this workflow:
### Step 1: Analyze User Requirements
Extract key information from user request:
- **Product type**: Entertainment (social, video, music, gaming), Tool (scanner, editor, converter), Productivity (task manager, notes, calendar), or hybrid
- **Target audience**: C-end consumer users; consider age group, usage context (commute, leisure, work)
- **Style keywords**: playful, vibrant, minimal, dark mode, content-first, immersive, etc.
- **Stack**: React Native (this project's only tech stack)
### Step 2: Generate Design System (REQUIRED)
**Always start with `--design-system`** to get comprehensive recommendations with reasoning:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<product_type> <industry> <keywords>" --design-system [-p "Project Name"]
```
This command:
1. Searches domains in parallel (product, style, color, landing, typography)
2. Applies reasoning rules from `ui-reasoning.csv` to select best matches
3. Returns complete design system: pattern, style, colors, typography, effects
4. Includes anti-patterns to avoid
**Example:**
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "beauty spa wellness service" --design-system -p "Serenity Spa"
```
### Step 2b: Persist Design System (Master + Overrides Pattern)
To save the design system for **hierarchical retrieval across sessions**, add `--persist`:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name"
```
This creates:
- `design-system/MASTER.md` — Global Source of Truth with all design rules
- `design-system/pages/` — Folder for page-specific overrides
**With page-specific override:**
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name" --page "dashboard"
```
This also creates:
- `design-system/pages/dashboard.md` — Page-specific deviations from Master
**How hierarchical retrieval works:**
1. When building a specific page (e.g., "Checkout"), first check `design-system/pages/checkout.md`
2. If the page file exists, its rules **override** the Master file
3. If not, use `design-system/MASTER.md` exclusively
**Context-aware retrieval prompt:**
```
I am building the [Page Name] page. Please read design-system/MASTER.md.
Also check if design-system/pages/[page-name].md exists.
If the page file exists, prioritize its rules.
If not, use the Master rules exclusively.
Now, generate the code...
```
### Step 3: Supplement with Detailed Searches (as needed)
After getting the design system, use domain searches to get additional details:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --domain <domain> [-n <max_results>]
```
**When to use detailed searches:**
| Need | Domain | Example |
|------|--------|---------|
| Product type patterns | `product` | `--domain product "entertainment social"` |
| More style options | `style` | `--domain style "glassmorphism dark"` |
| Color palettes | `color` | `--domain color "entertainment vibrant"` |
| Font pairings | `typography` | `--domain typography "playful modern"` |
| Chart recommendations | `chart` | `--domain chart "real-time dashboard"` |
| UX best practices | `ux` | `--domain ux "animation accessibility"` |
| Alternative fonts | `typography` | `--domain typography "elegant luxury"` |
| Individual Google Fonts | `google-fonts` | `--domain google-fonts "sans serif popular variable"` |
| Landing structure | `landing` | `--domain landing "hero social-proof"` |
| React Native perf | `react` | `--domain react "rerender memo list"` |
| App interface a11y | `web` | `--domain web "accessibilityLabel touch safe-areas"` |
| AI prompt / CSS keywords | `prompt` | `--domain prompt "minimalism"` |
### Step 4: Stack Guidelines (React Native)
Get React Native implementation-specific best practices:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack react-native
```
---
## Search Reference
### Available Domains
| Domain | Use For | Example Keywords |
|--------|---------|------------------|
| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service |
| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism |
| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern |
| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service |
| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof |
| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie |
| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading |
| `google-fonts` | Individual Google Fonts lookup | sans serif, monospace, japanese, variable font, popular |
| `react` | React/Next.js performance | waterfall, bundle, suspense, memo, rerender, cache |
| `web` | App interface guidelines (iOS/Android/React Native) | accessibilityLabel, touch targets, safe areas, Dynamic Type |
| `prompt` | AI prompts, CSS keywords | (style name) |
### Available Stacks
| Stack | Focus |
|-------|-------|
| `react-native` | Components, Navigation, Lists |
---
## Example Workflow
**User request:** "Make an AI search homepage."
### Step 1: Analyze Requirements
- Product type: Tool (AI search engine)
- Target audience: C-end users looking for fast, intelligent search
- Style keywords: modern, minimal, content-first, dark mode
- Stack: React Native
### Step 2: Generate Design System (REQUIRED)
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "AI search tool modern minimal" --design-system -p "AI Search"
```
**Output:** Complete design system with pattern, style, colors, typography, effects, and anti-patterns.
### Step 3: Supplement with Detailed Searches (as needed)
```bash
# Get style options for a modern tool product
python3 skills/ui-ux-pro-max/scripts/search.py "minimalism dark mode" --domain style
# Get UX best practices for search interaction and loading
python3 skills/ui-ux-pro-max/scripts/search.py "search loading animation" --domain ux
```
### Step 4: Stack Guidelines
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "list performance navigation" --stack react-native
```
**Then:** Synthesize design system + detailed searches and implement the design.
---
## Output Formats
The `--design-system` flag supports two output formats:
```bash
# ASCII box (default) - best for terminal display
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system
# Markdown - best for documentation
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system -f markdown
```
---
## Tips for Better Results
### Query Strategy
- Use **multi-dimensional keywords** — combine product + industry + tone + density: `"entertainment social vibrant content-dense"` not just `"app"`
- Try different keywords for the same need: `"playful neon"``"vibrant dark"``"content-first minimal"`
- Use `--design-system` first for full recommendations, then `--domain` to deep-dive any dimension you're unsure about
- Always add `--stack react-native` for implementation-specific guidance
### Common Sticking Points
| Problem | What to Do |
|---------|------------|
| Can't decide on style/color | Re-run `--design-system` with different keywords |
| Dark mode contrast issues | Quick Reference §6: `color-dark-mode` + `color-accessible-pairs` |
| Animations feel unnatural | Quick Reference §7: `spring-physics` + `easing` + `exit-faster-than-enter` |
| Form UX is poor | Quick Reference §8: `inline-validation` + `error-clarity` + `focus-management` |
| Navigation feels confusing | Quick Reference §9: `nav-hierarchy` + `bottom-nav-limit` + `back-behavior` |
| Layout breaks on small screens | Quick Reference §5: `mobile-first` + `breakpoint-consistency` |
| Performance / jank | Quick Reference §3: `virtualize-lists` + `main-thread-budget` + `debounce-throttle` |
### Pre-Delivery Checklist
- Run `--domain ux "animation accessibility z-index loading"` as a UX validation pass before implementation
- Run through Quick Reference **§1–§3** (CRITICAL + HIGH) as a final review
- Test on 375px (small phone) and landscape orientation
- Verify behavior with **reduced-motion** enabled and **Dynamic Type** at largest size
- Check dark mode contrast independently (don't assume light mode values work)
- Confirm all touch targets ≥44pt and no content hidden behind safe areas
---
## Common Rules for Professional UI
These are frequently overlooked issues that make UI look unprofessional:
Scope notice: The rules below are for App UI (iOS/Android/React Native/Flutter), not desktop-web interaction patterns.
### Icons & Visual Elements
| Rule | Standard | Avoid | Why It Matters |
|------|----------|--------|----------------|
| **No Emoji as Structural Icons** | Use vector-based icons (e.g., Lucide, react-native-vector-icons, @expo/vector-icons). | Using emojis (🎨 🚀 ⚙️) for navigation, settings, or system controls. | Emojis are font-dependent, inconsistent across platforms, and cannot be controlled via design tokens. |
| **Vector-Only Assets** | Use SVG or platform vector icons that scale cleanly and support theming. | Raster PNG icons that blur or pixelate. | Ensures scalability, crisp rendering, and dark/light mode adaptability. |
| **Stable Interaction States** | Use color, opacity, or elevation transitions for press states without changing layout bounds. | Layout-shifting transforms that move surrounding content or trigger visual jitter. | Prevents unstable interactions and preserves smooth motion/perceived quality on mobile. |
| **Correct Brand Logos** | Use official brand assets and follow their usage guidelines (spacing, color, clear space). | Guessing logo paths, recoloring unofficially, or modifying proportions. | Prevents brand misuse and ensures legal/platform compliance. |
| **Consistent Icon Sizing** | Define icon sizes as design tokens (e.g., icon-sm, icon-md = 24pt, icon-lg). | Mixing arbitrary values like 20pt / 24pt / 28pt randomly. | Maintains rhythm and visual hierarchy across the interface. |
| **Stroke Consistency** | Use a consistent stroke width within the same visual layer (e.g., 1.5px or 2px). | Mixing thick and thin stroke styles arbitrarily. | Inconsistent strokes reduce perceived polish and cohesion. |
| **Filled vs Outline Discipline** | Use one icon style per hierarchy level. | Mixing filled and outline icons at the same hierarchy level. | Maintains semantic clarity and stylistic coherence. |
| **Touch Target Minimum** | Minimum 44×44pt interactive area (use hitSlop if icon is smaller). | Small icons without expanded tap area. | Meets accessibility and platform usability standards. |
| **Icon Alignment** | Align icons to text baseline and maintain consistent padding. | Misaligned icons or inconsistent spacing around them. | Prevents subtle visual imbalance that reduces perceived quality. |
| **Icon Contrast** | Follow WCAG contrast standards: 4.5:1 for small elements, 3:1 minimum for larger UI glyphs. | Low-contrast icons that blend into the background. | Ensures accessibility in both light and dark modes. |
### Interaction (App)
| Rule | Do | Don't |
|------|----|----- |
| **Tap feedback** | Provide clear pressed feedback (ripple/opacity/elevation) within 80-150ms | No visual response on tap |
| **Animation timing** | Keep micro-interactions around 150-300ms with platform-native easing | Instant transitions or slow animations (>500ms) |
| **Accessibility focus** | Ensure screen reader focus order matches visual order and labels are descriptive | Unlabeled controls or confusing focus traversal |
| **Disabled state clarity** | Use disabled semantics (`disabled`/native disabled props), reduced emphasis, and no tap action | Controls that look tappable but do nothing |
| **Touch target minimum** | Keep tap areas >=44x44pt (iOS) or >=48x48dp (Android), expand hit area when icon is smaller | Tiny tap targets or icon-only hit areas without padding |
| **Gesture conflict prevention** | Keep one primary gesture per region and avoid nested tap/drag conflicts | Overlapping gestures causing accidental actions |
| **Semantic native controls** | Prefer native interactive primitives (`Button`, `Pressable`, platform equivalents) with proper accessibility roles | Generic containers used as primary controls without semantics |
### Light/Dark Mode Contrast
| Rule | Do | Don't |
|------|----|----- |
| **Surface readability (light)** | Keep cards/surfaces clearly separated from background with sufficient opacity/elevation | Overly transparent surfaces that blur hierarchy |
| **Text contrast (light)** | Maintain body text contrast >=4.5:1 against light surfaces | Low-contrast gray body text |
| **Text contrast (dark)** | Maintain primary text contrast >=4.5:1 and secondary text >=3:1 on dark surfaces | Dark mode text that blends into background |
| **Border and divider visibility** | Ensure separators are visible in both themes (not just light mode) | Theme-specific borders disappearing in one mode |
| **State contrast parity** | Keep pressed/focused/disabled states equally distinguishable in light and dark themes | Defining interaction states for one theme only |
| **Token-driven theming** | Use semantic color tokens mapped per theme across app surfaces/text/icons | Hardcoded per-screen hex values |
| **Scrim and modal legibility** | Use a modal scrim strong enough to isolate foreground content (typically 40-60% black) | Weak scrim that leaves background visually competing |
### Layout & Spacing
| Rule | Do | Don't |
|------|----|----- |
| **Safe-area compliance** | Respect top/bottom safe areas for all fixed headers, tab bars, and CTA bars | Placing fixed UI under notch, status bar, or gesture area |
| **System bar clearance** | Add spacing for status/navigation bars and gesture home indicator | Let tappable content collide with OS chrome |
| **Consistent content width** | Keep predictable content width per device class (phone/tablet) | Mixing arbitrary widths between screens |
| **8dp spacing rhythm** | Use a consistent 4/8dp spacing system for padding/gaps/section spacing | Random spacing increments with no rhythm |
| **Readable text measure** | Keep long-form text readable on large devices (avoid edge-to-edge paragraphs on tablets) | Full-width long text that hurts readability |
| **Section spacing hierarchy** | Define clear vertical rhythm tiers (e.g., 16/24/32/48) by hierarchy | Similar UI levels with inconsistent spacing |
| **Adaptive gutters by breakpoint** | Increase horizontal insets on larger widths and in landscape | Same narrow gutter on all device sizes/orientations |
| **Scroll and fixed element coexistence** | Add bottom/top content insets so lists are not hidden behind fixed bars | Scroll content obscured by sticky headers/footers |
---
## Pre-Delivery Checklist
Before delivering UI code, verify these items:
Scope notice: This checklist is for App UI (iOS/Android/React Native/Flutter).
### Visual Quality
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons come from a consistent icon family and style
- [ ] Official brand assets are used with correct proportions and clear space
- [ ] Pressed-state visuals do not shift layout bounds or cause jitter
- [ ] Semantic theme tokens are used consistently (no ad-hoc per-screen hardcoded colors)
### Interaction
- [ ] All tappable elements provide clear pressed feedback (ripple/opacity/elevation)
- [ ] Touch targets meet minimum size (>=44x44pt iOS, >=48x48dp Android)
- [ ] Micro-interaction timing stays in the 150-300ms range with native-feeling easing
- [ ] Disabled states are visually clear and non-interactive
- [ ] Screen reader focus order matches visual order, and interactive labels are descriptive
- [ ] Gesture regions avoid nested/conflicting interactions (tap/drag/back-swipe conflicts)
### Light/Dark Mode
- [ ] Primary text contrast >=4.5:1 in both light and dark mode
- [ ] Secondary text contrast >=3:1 in both light and dark mode
- [ ] Dividers/borders and interaction states are distinguishable in both modes
- [ ] Modal/drawer scrim opacity is strong enough to preserve foreground legibility (typically 40-60% black)
- [ ] Both themes are tested before delivery (not inferred from a single theme)
### Layout
- [ ] Safe areas are respected for headers, tab bars, and bottom CTA bars
- [ ] Scroll content is not hidden behind fixed/sticky bars
- [ ] Verified on small phone, large phone, and tablet (portrait + landscape)
- [ ] Horizontal insets/gutters adapt correctly by device size and orientation
- [ ] 4/8dp spacing rhythm is maintained across component, section, and page levels
- [ ] Long-form text measure remains readable on larger devices (no edge-to-edge paragraphs)
### Accessibility
- [ ] All meaningful images/icons have accessibility labels
- [ ] Form fields have labels, hints, and clear error messages
- [ ] Color is not the only indicator
- [ ] Reduced motion and dynamic text size are supported without layout breakage
- [ ] Accessibility traits/roles/states (selected, disabled, expanded) are announced correctly
+1
View File
@@ -0,0 +1 @@
../../../src/ui-ux-pro-max/data
+1
View File
@@ -0,0 +1 @@
../../../src/ui-ux-pro-max/scripts
@@ -9,6 +9,3 @@ Route::middleware('access-check')->group(function () {
Route::get('/news', IndexPostController::class)->name('client.post.index'); Route::get('/news', IndexPostController::class)->name('client.post.index');
Route::get('/news/{slug}', ShowPostController::class)->name('client.post.show'); Route::get('/news/{slug}', ShowPostController::class)->name('client.post.show');
}); });
@@ -0,0 +1,32 @@
<?php
namespace App\Containers\Article\UI\WEB\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateSlideRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => 'nullable|string|max:255',
'content' => 'nullable|string|max:1000',
'image' => 'required|array',
'image.url' => 'required|string',
'link' => 'required|string|max:255',
'settings' => 'nullable|array',
'settings.text_position' => 'nullable|string|in:left,center,right',
'settings.link_text' => 'nullable|string|max:50',
'settings.shading' => 'nullable|string',
'color_theme' => 'required|string',
'is_active' => 'boolean',
'start_time' => 'nullable|date',
'end_time' => 'nullable|date|after_or_equal:start_time',
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Containers\Article\UI\WEB\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateSlideRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => 'nullable|string|max:255',
'content' => 'nullable|string|max:1000',
'image' => 'sometimes|array',
'image.url' => 'sometimes|required|string',
'link' => 'sometimes|required|string|max:255',
'settings' => 'nullable|array',
'settings.text_position' => 'nullable|string|in:left,center,right',
'settings.link_text' => 'nullable|string|max:50',
'settings.shading' => 'nullable|string',
'color_theme' => 'sometimes|required|string',
'is_active' => 'sometimes|boolean',
'start_time' => 'nullable|date',
'end_time' => 'nullable|date|after_or_equal:start_time',
];
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\AcademicJournals;
use App\Containers\Science\Models\AcademicJournal;
class CreateAcademicJournalAction
{
public function run(array $data): AcademicJournal
{
return AcademicJournal::create($data);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\AcademicJournals;
use App\Containers\Science\Models\AcademicJournal;
class DeleteAcademicJournalAction
{
public function run(AcademicJournal $journal): bool
{
return $journal->delete();
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Containers\Dashboard\Actions\AcademicJournals;
use App\Containers\Science\Models\AcademicJournal;
class ListAcademicJournalsAction
{
public function run(array $filters = []): array
{
$query = AcademicJournal::query();
// Поиск по названию
if (!empty($filters['search'])) {
$query->where('title', 'like', '%' . $filters['search'] . '%');
}
$journals = $query->orderBy('created_at', 'desc')->paginate(20)->withQueryString();
return [
'journals' => $journals,
'filters' => $filters,
];
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Containers\Dashboard\Actions\AcademicJournals;
use App\Containers\Science\Models\AcademicJournal;
class UpdateAcademicJournalAction
{
public function run(AcademicJournal $journal, array $data): AcademicJournal
{
$journal->update($data);
return $journal->fresh();
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Categories;
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
class CreateCategoryAction
{
public function run(array $data): AdditionalEducationCategory
{
return AdditionalEducationCategory::create($data);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Categories;
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
class DeleteCategoryAction
{
public function run(AdditionalEducationCategory $category): bool
{
return $category->delete();
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Categories;
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
class ListCategoriesAction
{
public function run(array $filters = []): array
{
$query = AdditionalEducationCategory::with(['direction']);
// Фильтр по направлению
if (!empty($filters['direction_id'])) {
$query->where('dir_addit_educat_id', $filters['direction_id']);
}
// Фильтр по активности
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN));
}
// Поиск по названию
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where('title', 'like', '%' . $search . '%');
}
$categories = $query->orderBy('title')->paginate(20)->withQueryString();
return [
'categories' => $categories,
'filters' => $filters,
'directions' => DirectionAdditionalEducation::where('is_active', true)
->orderBy('title')
->get(['id', 'title']),
];
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Categories;
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
class UpdateCategoryAction
{
public function run(AdditionalEducationCategory $category, array $data): AdditionalEducationCategory
{
$category->update($data);
return $category;
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Containers\Dashboard\Actions\AdditionalEducations;
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
use App\Containers\Dashboard\Tasks\AdditionalEducations\GenerateSearchDataTask;
class CreateAdditionalEducationAction
{
public function __construct(
private readonly GenerateSearchDataTask $generateSearchDataTask,
) {}
public function run(array $data): AdditionalEducation
{
$data['search_data'] = $this->generateSearchDataTask->run($data['content'] ?? []);
return AdditionalEducation::create($data);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\AdditionalEducations;
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
class DeleteAdditionalEducationAction
{
public function run(AdditionalEducation $education): bool
{
return $education->delete();
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Directions;
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
class CreateDirectionAction
{
public function run(array $data): DirectionAdditionalEducation
{
return DirectionAdditionalEducation::create($data);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Directions;
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
class DeleteDirectionAction
{
public function run(DirectionAdditionalEducation $direction): bool
{
return $direction->delete();
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Directions;
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
class ListDirectionsAction
{
public function run(array $filters = []): array
{
$query = DirectionAdditionalEducation::query();
// Фильтр по активности
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN));
}
// Поиск по названию
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where('title', 'like', '%' . $search . '%');
}
$directions = $query->orderBy('title')->paginate(20)->withQueryString();
return [
'directions' => $directions,
'filters' => $filters,
];
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Directions;
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
class UpdateDirectionAction
{
public function run(DirectionAdditionalEducation $direction, array $data): DirectionAdditionalEducation
{
$direction->update($data);
return $direction;
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Containers\Dashboard\Actions\AdditionalEducations;
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
use App\Ship\Enums\Education\FormEducation;
class ListAdditionalEducationsAction
{
public function run(array $filters = []): array
{
$query = AdditionalEducation::with(['category']);
// Фильтр по категории
if (!empty($filters['category_id'])) {
$query->where('category_id', $filters['category_id']);
}
// Фильтр по форме обучения
if (!empty($filters['form_education'])) {
$query->where('form_education', $filters['form_education']);
}
// Фильтр по активности
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN));
}
// Поиск по названию или целевой аудитории
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('title', 'like', '%' . $search . '%')
->orWhere('target_group', 'like', '%' . $search . '%');
});
}
$educations = $query->orderBy('title')->paginate(20)->withQueryString();
return [
'educations' => $educations,
'filters' => $filters,
'categories' => AdditionalEducationCategory::where('is_active', true)
->orderBy('title')
->get(['id', 'title']),
'educationForms' => array_map(fn($form) => [
'value' => $form->value,
'label' => $form->getLabel(),
'color' => $form->getColor(),
'name' => $form->name,
], FormEducation::cases()),
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Containers\Dashboard\Actions\AdditionalEducations;
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
use App\Containers\Dashboard\Tasks\AdditionalEducations\GenerateSearchDataTask;
class UpdateAdditionalEducationAction
{
public function __construct(
private readonly GenerateSearchDataTask $generateSearchDataTask,
) {}
public function run(AdditionalEducation $education, array $data): AdditionalEducation
{
if (isset($data['content'])) {
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
}
$education->update($data);
return $education;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\AdmissionCampaigns;
use App\Containers\Education\Models\AdmissionCampaign;
class CreateAdmissionCampaignAction
{
public function run(array $data): AdmissionCampaign
{
return AdmissionCampaign::create($data);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\AdmissionCampaigns;
use App\Containers\Education\Models\AdmissionCampaign;
class DeleteAdmissionCampaignAction
{
public function run(AdmissionCampaign $campaign): bool
{
return $campaign->delete();
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Containers\Dashboard\Actions\AdmissionCampaigns;
use App\Containers\Education\Models\AdmissionCampaign;
use App\Ship\Enums\Education\AdmissionCampaignStatus;
class ListAdmissionCampaignsAction
{
public function run(array $filters = []): array
{
$query = AdmissionCampaign::query();
// Фильтр по статусу
if (!empty($filters['status'])) {
$query->where('status', $filters['status']);
}
// Фильтр по учебному году
if (!empty($filters['academic_year'])) {
$query->where('academic_year', $filters['academic_year']);
}
// Поиск по названию
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where('name', 'like', '%' . $search . '%');
}
$campaigns = $query->orderBy('academic_year', 'desc')->paginate(20)->withQueryString();
return [
'campaigns' => $campaigns,
'filters' => $filters,
'statuses' => array_map(fn($status) => [
'value' => $status->value,
'label' => $status->getLabel(),
'color' => $status->getColor(),
], AdmissionCampaignStatus::cases()),
'academicYears' => $this->generateAcademicYears(),
];
}
private function generateAcademicYears(): array
{
$currentYear = (int) date('Y') - 5;
$yearsAhead = 10;
$academicYears = [];
for ($i = 0; $i < $yearsAhead; $i++) {
$startYear = $currentYear + $i;
$endYear = $startYear + 1;
$academicYears[] = "{$startYear}/{$endYear}";
}
return $academicYears;
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Containers\Dashboard\Actions\AdmissionCampaigns;
use App\Containers\Education\Models\AdmissionCampaign;
class UpdateAdmissionCampaignAction
{
public function run(AdmissionCampaign $campaign, array $data): AdmissionCampaign
{
$campaign->update($data);
return $campaign;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\AdmissionPlans;
use App\Containers\Education\Models\AdmissionPlan;
class CreateAdmissionPlanAction
{
public function run(array $data): AdmissionPlan
{
return AdmissionPlan::create($data);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\AdmissionPlans;
use App\Containers\Education\Models\AdmissionPlan;
class DeleteAdmissionPlanAction
{
public function run(AdmissionPlan $plan): bool
{
return $plan->delete();
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Containers\Dashboard\Actions\AdmissionPlans;
use App\Containers\Education\Models\AdmissionPlan;
use App\Containers\Education\Models\AdmissionCampaign;
use App\Containers\Education\Models\EducationalProgram;
use App\Ship\Enums\Education\EducationalProgramStatus;
class ListAdmissionPlansAction
{
public function run(array $filters = []): array
{
$query = AdmissionPlan::with(['educationalProgram', 'admissionCampaign']);
// Фильтр по приемной кампании
if (!empty($filters['admission_campaigns_id'])) {
$query->where('admission_campaigns_id', $filters['admission_campaigns_id']);
}
// Фильтр по образовательной программе
if (!empty($filters['educational_programs_id'])) {
$query->where('educational_programs_id', $filters['educational_programs_id']);
}
$plans = $query->orderBy('id', 'desc')->paginate(20)->withQueryString();
return [
'plans' => $plans,
'filters' => $filters,
'admissionCampaigns' => AdmissionCampaign::orderBy('name')->get(['id', 'name', 'academic_year']),
'educationalPrograms' => EducationalProgram::whereIn('status', [
EducationalProgramStatus::PUBLISHED,
EducationalProgramStatus::IN_PROGRESS
])->orderBy('name')->get(['id', 'name']),
];
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Containers\Dashboard\Actions\AdmissionPlans;
use App\Containers\Education\Models\AdmissionPlan;
class UpdateAdmissionPlanAction
{
public function run(AdmissionPlan $plan, array $data): AdmissionPlan
{
$plan->update($data);
return $plan;
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Containers\Dashboard\Actions\Categories;
use App\Containers\Article\Models\Category;
use Illuminate\Support\Str;
class CreateCategoryAction
{
public function run(array $data): Category
{
$data['slug'] = Str::slug($data['title']);
return Category::create($data);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\Categories;
use App\Containers\Article\Models\Category;
class DeleteCategoryAction
{
public function run(Category $category): bool
{
return $category->delete();
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Containers\Dashboard\Actions\Categories;
use App\Containers\Article\Models\Category;
class ListCategoriesAction
{
public function run(array $filters = []): array
{
$query = Category::query();
// Поиск по названию
if (!empty($filters['search'])) {
$query->where('title', 'like', '%' . $filters['search'] . '%');
}
// Фильтр по статусу
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query->where('is_active', (bool) $filters['is_active']);
}
$categories = $query->orderBy('title')->paginate(20)->withQueryString();
return [
'categories' => $categories,
'filters' => $filters,
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Containers\Dashboard\Actions\Categories;
use App\Containers\Article\Models\Category;
use Illuminate\Support\Str;
class UpdateCategoryAction
{
public function run(Category $category, array $data): Category
{
if (isset($data['title'])) {
$data['slug'] = Str::slug($data['title']);
}
$category->update($data);
return $category->fresh();
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Containers\Dashboard\Actions\ContactWidgets;
use App\Containers\Widget\Models\ContactWidget;
use Illuminate\Support\Str;
class CreateContactWidgetAction
{
public function run(array $data): ContactWidget
{
$data['slug'] = $data['slug'] ?? Str::slug($data['title']);
$data['is_active'] = $data['is_active'] ?? true;
return ContactWidget::create($data);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\ContactWidgets;
use App\Containers\Widget\Models\ContactWidget;
class DeleteContactWidgetAction
{
public function run(ContactWidget $widget): bool
{
return $widget->delete();
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Containers\Dashboard\Actions\ContactWidgets;
use App\Containers\Widget\Models\ContactWidget;
class ListContactWidgetsAction
{
public function run(array $filters = []): array
{
$query = ContactWidget::query();
// Поиск по названию
if (!empty($filters['search'])) {
$query->where('title', 'like', '%' . $filters['search'] . '%');
}
// Фильтр по статусу
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query->where('is_active', (bool) $filters['is_active']);
}
$widgets = $query->orderByDesc('created_at')->paginate(20)->withQueryString();
return [
'widgets' => $widgets,
'filters' => $filters,
];
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Containers\Dashboard\Actions\ContactWidgets;
use App\Containers\Widget\Models\ContactWidget;
class UpdateContactWidgetAction
{
public function run(ContactWidget $widget, array $data): ContactWidget
{
$widget->update($data);
return $widget;
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Containers\Dashboard\Actions\CustomForms;
use App\Containers\Widget\Models\CustomForm;
use Illuminate\Support\Str;
class CreateCustomFormAction
{
public function run(array $data): CustomForm
{
$data['form_id'] = $data['form_id'] ?? Str::slug($data['title']) . time();
$data['status'] = $data['status'] ?? 'published';
$data['settings'] = $data['settings'] ?? [];
$data['mail_settings'] = $data['mail_settings'] ?? [];
$data['columns'] = $data['columns'] ?? [];
return CustomForm::create($data);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\CustomForms;
use App\Containers\Widget\Models\CustomForm;
class DeleteCustomFormAction
{
public function run(CustomForm $form): bool
{
return $form->delete();
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\CustomForms;
use App\Containers\Widget\Models\CustomFormResponse;
class DeleteFormResponseAction
{
public function run(CustomFormResponse $response): bool
{
return $response->delete();
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Containers\Dashboard\Actions\CustomForms;
use App\Containers\Widget\Models\CustomForm;
class ListCustomFormsAction
{
public function run(array $filters = []): array
{
$query = CustomForm::query();
// Поиск по названию
if (!empty($filters['search'])) {
$query->where('title', 'like', '%' . $filters['search'] . '%');
}
// Фильтр по статусу
if (!empty($filters['status'])) {
$query->where('status', $filters['status']);
}
$forms = $query->orderByDesc('created_at')->paginate(20)->withQueryString();
return [
'forms' => $forms,
'filters' => $filters,
'statuses' => [
['value' => 'published', 'label' => 'Опубликовано'],
['value' => 'hidden', 'label' => 'Скрыто'],
],
];
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Containers\Dashboard\Actions\CustomForms;
use App\Containers\Widget\Models\CustomForm;
class ListFormResponsesAction
{
public function run(CustomForm $form, array $filters = []): array
{
$query = $form->responses()->with('form');
// Фильтр по статусу просмотра
if (isset($filters['checked']) && $filters['checked'] !== '') {
$query->where('checked', (bool) $filters['checked']);
}
// Поиск по ID
if (!empty($filters['search'])) {
$query->where('id', 'like', '%' . $filters['search'] . '%');
}
$responses = $query->orderByDesc('created_at')->paginate(20)->withQueryString();
// Динамические колонки на основе полей формы
$columns = collect($form->columns ?? [])->map(function ($field) {
return [
'name' => $field['data']['name_field'] ?? '',
'title' => $field['data']['title_field'] ?? '',
'type' => $field['type'] ?? 'text',
'options' => $this->extractOptions($field),
];
})->filter(fn($col) => !empty($col['name']))->values()->toArray();
return [
'form' => $form,
'responses' => $responses,
'columns' => $columns,
'filters' => $filters,
];
}
private function extractOptions(array $field): array
{
if (!in_array($field['type'], ['single_choice', 'multiple_choice'])) {
return [];
}
return collect($field['data']['columns'] ?? [])
->mapWithKeys(fn($opt) => [$opt['name_field'] ?? '' => $opt['title_field'] ?? ''])
->toArray();
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Containers\Dashboard\Actions\CustomForms;
use App\Containers\Widget\Models\CustomForm;
class UpdateCustomFormAction
{
public function run(CustomForm $form, array $data): CustomForm
{
$form->update($data);
return $form;
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\Education\Models\EducationalProgram;
use App\Containers\InstituteStructure\Models\Department;
use App\Services\App\Cache\DepartmentCacheService;
class AttachDepartmentProgramAction
{
public function __construct(
private readonly DepartmentCacheService $cacheService,
) {}
public function run(Department $department, EducationalProgram $program): void
{
$department->programs()->attach($program->id);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\InstituteStructure\Models\Department;
use App\Containers\User\Models\User;
use App\Services\App\Cache\DepartmentCacheService;
class AttachDepartmentTeacherAction
{
public function __construct(
private readonly DepartmentCacheService $cacheService,
) {}
public function run(Department $department, User $user, array $data): void
{
$department->teachers()->attach($user->id, [
'teaching_position' => $data['teaching_position'],
'service_email' => $data['service_email'] ?? null,
'service_phone' => $data['service_phone'] ?? null,
'cabinet' => $data['cabinet'] ?? null,
'sort' => $department->teachers()->count() + 1,
]);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\InstituteStructure\Models\Department;
use App\Containers\User\Models\User;
use App\Services\App\Cache\DepartmentCacheService;
class AttachDepartmentWorkerAction
{
public function __construct(
private readonly DepartmentCacheService $cacheService,
) {}
public function run(Department $department, User $user, array $data): void
{
$department->workers()->attach($user->id, [
'position' => $data['position'],
'service_email' => $data['service_email'] ?? null,
'service_phone' => $data['service_phone'] ?? null,
'cabinet' => $data['cabinet'] ?? null,
'sort' => $department->workers()->count() + 1,
]);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\Dashboard\Tasks\Content\GenerateSearchDataTask;
use App\Containers\InstituteStructure\Models\Department;
use Illuminate\Support\Str;
class CreateDepartmentAction
{
public function __construct(
private readonly GenerateSearchDataTask $generateSearchDataTask,
) {}
public function run(array $data): Department
{
// Генерируем slug если не передан
if (empty($data['slug'])) {
$data['slug'] = Str::slug($data['title']);
}
// Генерируем search_data из контента
if (!empty($data['content'])) {
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
}
$department = Department::create($data);
// Создаем SEO
$this->generateSeo($department, $data);
return $department;
}
private function generateSeo(Department $department, array $data): void
{
$title = $data['title'] ?? '';
$description = null;
// Извлекаем description из первого paragraph блока
if (!empty($data['content'])) {
foreach ($data['content'] as $block) {
if ($block['type'] === 'paragraph') {
$description = strip_tags($block['data']['content']);
break;
}
}
}
$department->seo()->create([
'title' => $title,
'description' => Str::limit(htmlspecialchars($description ?? '', ENT_QUOTES, 'UTF-8'), 160),
]);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\InstituteStructure\Models\Department;
class DeleteDepartmentAction
{
public function run(Department $department): void
{
$department->delete();
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\Education\Models\EducationalProgram;
use App\Containers\InstituteStructure\Models\Department;
use App\Services\App\Cache\DepartmentCacheService;
class DetachDepartmentProgramAction
{
public function __construct(
private readonly DepartmentCacheService $cacheService,
) {}
public function run(Department $department, EducationalProgram $program): void
{
$department->programs()->detach($program->id);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\InstituteStructure\Models\Department;
use App\Containers\User\Models\User;
use App\Services\App\Cache\DepartmentCacheService;
class DetachDepartmentTeacherAction
{
public function __construct(
private readonly DepartmentCacheService $cacheService,
) {}
public function run(Department $department, User $user): void
{
$department->teachers()->detach($user->id);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\InstituteStructure\Models\Department;
use App\Containers\User\Models\User;
use App\Services\App\Cache\DepartmentCacheService;
class DetachDepartmentWorkerAction
{
public function __construct(
private readonly DepartmentCacheService $cacheService,
) {}
public function run(Department $department, User $user): void
{
$department->workers()->detach($user->id);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\InstituteStructure\Models\Department;
use App\Services\App\Cache\DepartmentCacheService;
class ListDepartmentProgramsAction
{
public function run(Department $department, array $filters = []): array
{
$query = $department->programs();
// Фильтр по статусу
if (!empty($filters['status'])) {
$query->where('status', $filters['status']);
}
// Поиск по названию
if (!empty($filters['search'])) {
$query->where('name', 'like', '%' . $filters['search'] . '%');
}
$programs = $query->orderBy('name')->paginate(20)->withQueryString();
return [
'programs' => $programs,
'filters' => $filters,
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\InstituteStructure\Models\Department;
class ListDepartmentTeachersAction
{
public function run(Department $department, array $filters = []): array
{
$query = $department->teachers();
// Поиск по имени
if (!empty($filters['search'])) {
$query->where('name', 'like', '%' . $filters['search'] . '%');
}
// Фильтр по должности
if (!empty($filters['position'])) {
$query->where('teaching_position', 'like', '%' . $filters['position'] . '%');
}
$teachers = $query->orderBy('teachers_departments.sort')->paginate(20)->withQueryString();
return [
'teachers' => $teachers,
'filters' => $filters,
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\InstituteStructure\Models\Department;
use App\Containers\User\Models\User;
class ListDepartmentWorkersAction
{
public function run(Department $department, array $filters = []): array
{
$query = $department->workers();
// Поиск по имени
if (!empty($filters['search'])) {
$query->where('name', 'like', '%' . $filters['search'] . '%');
}
// Фильтр по должности
if (!empty($filters['position'])) {
$query->where('position', 'like', '%' . $filters['position'] . '%');
}
$workers = $query->orderBy('workers_departments.sort')->paginate(20)->withQueryString();
return [
'workers' => $workers,
'filters' => $filters,
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\InstituteStructure\Models\Department;
class ListDepartmentsAction
{
public function run(array $filters = []): array
{
$query = Department::query()->with(['faculty']);
// Фильтр по факультету
if (isset($filters['faculty_id']) && $filters['faculty_id'] !== '') {
$query->where('faculty_id', (int) $filters['faculty_id']);
}
// Фильтр по статусу
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query->where('is_active', (bool) $filters['is_active']);
}
// Поиск по названию
if (!empty($filters['search'])) {
$query->where('title', 'like', '%' . $filters['search'] . '%');
}
$departments = $query->orderBy('title')->paginate(20)->withQueryString();
return [
'departments' => $departments,
'filters' => $filters,
];
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\Dashboard\Tasks\Content\GenerateSearchDataTask;
use App\Containers\InstituteStructure\Models\Department;
use Illuminate\Support\Str;
class UpdateDepartmentAction
{
public function __construct(
private readonly GenerateSearchDataTask $generateSearchDataTask,
) {}
public function run(Department $department, array $data): Department
{
// Генерируем search_data из контента только если контент передан и не пуст
if (isset($data['content']) && !empty($data['content'])) {
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
}
$department->update($data);
// Обновляем SEO
$this->updateSeo($department, $data);
return $department;
}
private function updateSeo(Department $department, array $data): void
{
$title = $data['title'] ?? $department->title;
$description = null;
// Извлекаем description из первого paragraph блока
if (!empty($data['content'])) {
foreach ($data['content'] as $block) {
if ($block['type'] === 'paragraph') {
$description = strip_tags($block['data']['content']);
break;
}
}
}
$seoData = [
'title' => $title,
'description' => Str::limit(htmlspecialchars($description ?? '', ENT_QUOTES, 'UTF-8'), 160),
];
if ($department->seo) {
$department->seo->update($seoData);
} else {
$department->seo()->create($seoData);
}
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\InstituteStructure\Models\Department;
use App\Containers\User\Models\User;
use App\Services\App\Cache\DepartmentCacheService;
class UpdateDepartmentTeacherAction
{
public function __construct(
private readonly DepartmentCacheService $cacheService,
) {}
public function run(Department $department, User $user, array $data): void
{
$department->teachers()->updateExistingPivot($user->id, [
'teaching_position' => $data['teaching_position'],
'service_email' => $data['service_email'] ?? null,
'service_phone' => $data['service_phone'] ?? null,
'cabinet' => $data['cabinet'] ?? null,
]);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Containers\Dashboard\Actions\Departments;
use App\Containers\InstituteStructure\Models\Department;
use App\Containers\User\Models\User;
use App\Services\App\Cache\DepartmentCacheService;
class UpdateDepartmentWorkerAction
{
public function __construct(
private readonly DepartmentCacheService $cacheService,
) {}
public function run(Department $department, User $user, array $data): void
{
$department->workers()->updateExistingPivot($user->id, [
'position' => $data['position'],
'service_email' => $data['service_email'] ?? null,
'service_phone' => $data['service_phone'] ?? null,
'cabinet' => $data['cabinet'] ?? null,
]);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\DirectionStudies;
use App\Containers\Education\Models\DirectionStudy;
class CreateDirectionStudyAction
{
public function run(array $data): DirectionStudy
{
return DirectionStudy::create($data);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\DirectionStudies;
use App\Containers\Education\Models\DirectionStudy;
class DeleteDirectionStudyAction
{
public function run(DirectionStudy $direction): bool
{
return $direction->delete();
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Containers\Dashboard\Actions\DirectionStudies;
use App\Containers\Education\Models\DirectionStudy;
use App\Ship\Enums\Education\LevelEducational;
class ListDirectionStudiesAction
{
public function run(array $filters = []): array
{
$query = DirectionStudy::query();
// Фильтр по уровню образования
if (!empty($filters['lvl_edu'])) {
$query->where('lvl_edu', $filters['lvl_edu']);
}
// Поиск по коду или названию
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('code', 'like', '%' . $search . '%')
->orWhere('name', 'like', '%' . $search . '%');
});
}
$directions = $query->orderBy('code')->paginate(20)->withQueryString();
return [
'directions' => $directions,
'filters' => $filters,
'educationLevels' => array_map(fn($level) => [
'value' => $level->value,
'label' => $level->getLabel(),
'color' => $level->getColor(),
], LevelEducational::cases()),
];
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Containers\Dashboard\Actions\DirectionStudies;
use App\Containers\Education\Models\DirectionStudy;
class UpdateDirectionStudyAction
{
public function run(DirectionStudy $direction, array $data): DirectionStudy
{
$direction->update($data);
return $direction;
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Containers\Dashboard\Actions\Divisions;
use App\Containers\InstituteStructure\Models\Division;
use App\Containers\User\Models\User;
use App\Services\App\Cache\DivisionCacheService;
class AttachDivisionWorkerAction
{
public function __construct(
private readonly DivisionCacheService $cacheService,
) {}
public function run(Division $division, User $user, array $data): void
{
$division->workers()->attach($user->id, [
'administrativePosition' => $data['administrativePosition'],
'service_email' => $data['service_email'] ?? null,
'service_phone' => $data['service_phone'] ?? null,
'cabinet' => $data['cabinet'] ?? null,
'sort' => $data['sort'] ?? $division->workers()->count(),
]);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Containers\Dashboard\Actions\Divisions;
use App\Containers\Dashboard\Tasks\Content\GenerateSearchDataTask;
use App\Containers\InstituteStructure\Models\Division;
use Illuminate\Support\Str;
class CreateDivisionAction
{
public function __construct(
private readonly GenerateSearchDataTask $generateSearchDataTask,
) {}
public function run(array $data): Division
{
// Генерируем slug если не передан
if (empty($data['slug'])) {
$data['slug'] = Str::slug($data['title']);
}
// Генерируем search_data из описания
if (!empty($data['description'])) {
$data['search_data'] = $this->generateSearchDataTask->run($data['description']);
}
$division = Division::create($data);
// Создаем SEO
$this->generateSeo($division, $data);
return $division;
}
private function generateSeo(Division $division, array $data): void
{
$title = $data['title'] ?? '';
$description = null;
// Извлекаем description из первого paragraph блока
if (!empty($data['description'])) {
foreach ($data['description'] as $block) {
if ($block['type'] === 'paragraph') {
$description = strip_tags($block['data']['content']);
break;
}
}
}
$division->seo()->create([
'title' => $title,
'description' => Str::limit(htmlspecialchars($description ?? '', ENT_QUOTES, 'UTF-8'), 160),
]);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\Divisions;
use App\Containers\InstituteStructure\Models\Division;
class DeleteDivisionAction
{
public function run(Division $division): void
{
$division->delete();
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Containers\Dashboard\Actions\Divisions;
use App\Containers\InstituteStructure\Models\Division;
use App\Containers\User\Models\User;
use App\Services\App\Cache\DivisionCacheService;
class DetachDivisionWorkerAction
{
public function __construct(
private readonly DivisionCacheService $cacheService,
) {}
public function run(Division $division, User $worker): void
{
$division->workers()->detach($worker->id);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Containers\Dashboard\Actions\Divisions;
use App\Containers\InstituteStructure\Models\Division;
use Illuminate\Database\Eloquent\Builder;
class ListDivisionWorkersAction
{
public function run(Division $division, array $filters = []): array
{
$query = $division->workers()
->withPivot(['administrativePosition', 'sort', 'service_email', 'service_phone', 'cabinet'])
->whereHas('userDetail');
// Поиск по ФИО
if (!empty($filters['search'])) {
$query->where('name', 'like', '%' . $filters['search'] . '%');
}
// Фильтр по должности
if (!empty($filters['position'])) {
$query->where('division_user.administrativePosition', 'like', '%' . $filters['position'] . '%');
}
$workers = $query->orderBy('division_user.sort')->paginate(20)->withQueryString();
return [
'workers' => $workers,
'filters' => $filters,
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Containers\Dashboard\Actions\Divisions;
use App\Containers\InstituteStructure\Models\Division;
class ListDivisionsAction
{
public function run(array $filters = []): array
{
$query = Division::query();
// Фильтр по статусу (по умолчанию только активные)
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query->where('is_active', (bool) $filters['is_active']);
} elseif (!isset($filters['is_active'])) {
$query->where('is_active', true);
}
// Поиск по названию
if (!empty($filters['search'])) {
$query->where('title', 'like', '%' . $filters['search'] . '%')
->orWhere('slug', 'like', '%' . $filters['search'] . '%');
}
$divisions = $query->orderBy('created_at', 'desc')->paginate(20)->withQueryString();
return [
'divisions' => $divisions,
'filters' => $filters,
];
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Containers\Dashboard\Actions\Divisions;
use App\Containers\Dashboard\Tasks\Content\GenerateSearchDataTask;
use App\Containers\InstituteStructure\Models\Division;
use Illuminate\Support\Str;
class UpdateDivisionAction
{
public function __construct(
private readonly GenerateSearchDataTask $generateSearchDataTask,
) {}
public function run(Division $division, array $data): Division
{
// Генерируем search_data из описания только если описание передано и не пусто
if (isset($data['description']) && !empty($data['description'])) {
$data['search_data'] = $this->generateSearchDataTask->run($data['description']);
}
$division->update($data);
// Обновляем SEO
$this->updateSeo($division, $data);
return $division;
}
private function updateSeo(Division $division, array $data): void
{
$title = $data['title'] ?? $division->title;
$description = null;
// Извлекаем description из первого paragraph блока
if (!empty($data['description'])) {
foreach ($data['description'] as $block) {
if ($block['type'] === 'paragraph') {
$description = strip_tags($block['data']['content']);
break;
}
}
}
$seoData = [
'title' => $title,
'description' => Str::limit(htmlspecialchars($description ?? '', ENT_QUOTES, 'UTF-8'), 160),
];
if ($division->seo) {
$division->seo->update($seoData);
} else {
$division->seo()->create($seoData);
}
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Containers\Dashboard\Actions\Divisions;
use App\Containers\InstituteStructure\Models\Division;
use App\Containers\User\Models\User;
use App\Services\App\Cache\DivisionCacheService;
class UpdateDivisionWorkerAction
{
public function __construct(
private readonly DivisionCacheService $cacheService,
) {}
public function run(Division $division, User $worker, array $data): void
{
$division->workers()->updateExistingPivot($worker->id, [
'administrativePosition' => $data['administrativePosition'],
'service_email' => $data['service_email'] ?? null,
'service_phone' => $data['service_phone'] ?? null,
'cabinet' => $data['cabinet'] ?? null,
]);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\EducationalGroups;
use App\Containers\Schedule\Models\EducationalGroup;
class CreateEducationalGroupAction
{
public function run(array $data): EducationalGroup
{
return EducationalGroup::create($data);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\EducationalGroups;
use App\Containers\Schedule\Models\EducationalGroup;
class DeleteEducationalGroupAction
{
public function run(EducationalGroup $group): bool
{
return $group->delete();
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Containers\Dashboard\Actions\EducationalGroups;
use App\Containers\Schedule\Models\EducationalGroup;
use App\Ship\Enums\Education\FormEducation;
use App\Containers\InstituteStructure\Models\Faculty;
class ListEducationalGroupsAction
{
public function run(array $filters = []): array
{
$query = EducationalGroup::with(['faculty']);
// Фильтр по факультету
if (!empty($filters['faculty_id'])) {
$query->where('faculty_id', $filters['faculty_id']);
}
// Фильтр по форме обучения
if (!empty($filters['education_form_id'])) {
$query->where('education_form_id', $filters['education_form_id']);
}
// Поиск по названию группы
if (!empty($filters['search'])) {
$query->where('title', 'like', '%' . $filters['search'] . '%');
}
$groups = $query->orderBy('title')->paginate(20)->withQueryString();
return [
'groups' => $groups,
'filters' => $filters,
'faculties' => Faculty::orderBy('title')->get(['id', 'title']),
'educationForms' => array_map(fn($form) => [
'value' => $form->value,
'label' => $form->getLabel(),
'color' => $form->getColor(),
'name' => $form->name,
], FormEducation::cases()),
];
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Containers\Dashboard\Actions\EducationalGroups;
use App\Containers\Schedule\Models\EducationalGroup;
class UpdateEducationalGroupAction
{
public function run(EducationalGroup $group, array $data): EducationalGroup
{
$group->update($data);
return $group->fresh();
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\EducationalPrograms;
use App\Containers\Education\Models\EducationalProgram;
class CreateEducationalProgramAction
{
public function run(array $data): EducationalProgram
{
return EducationalProgram::create($data);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\EducationalPrograms;
use App\Containers\Education\Models\EducationalProgram;
class DeleteEducationalProgramAction
{
public function run(EducationalProgram $program): bool
{
return $program->delete();
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Containers\Dashboard\Actions\EducationalPrograms;
use App\Containers\Education\Models\EducationalProgram;
use App\Containers\Education\Models\DirectionStudy;
use App\Ship\Enums\Education\EducationalProgramStatus;
use App\Ship\Enums\Education\LevelEducational;
class ListEducationalProgramsAction
{
public function run(array $filters = []): array
{
$query = EducationalProgram::with(['directionStudy']);
// Фильтр по уровню образования
if (!empty($filters['lvl_edu'])) {
$query->where('lvl_edu', $filters['lvl_edu']);
}
// Фильтр по статусу
if (!empty($filters['status'])) {
$query->where('status', $filters['status']);
}
// Фильтр по направлению подготовки
if (!empty($filters['direction_study_id'])) {
$query->where('direction_study_id', $filters['direction_study_id']);
}
// Поиск по названию
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where('name', 'like', '%' . $search . '%');
}
$programs = $query->orderBy('name')->paginate(20)->withQueryString();
return [
'programs' => $programs,
'filters' => $filters,
'statuses' => array_map(fn($status) => [
'value' => $status->value,
'label' => $status->getLabel(),
'color' => $status->getColor(),
], EducationalProgramStatus::cases()),
'educationLevels' => array_map(fn($level) => [
'value' => $level->value,
'label' => $level->getLabel(),
'color' => $level->getColor(),
], LevelEducational::cases()),
'directionStudies' => DirectionStudy::orderBy('code')->get(['id', 'code', 'name']),
];
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Containers\Dashboard\Actions\EducationalPrograms;
use App\Containers\Education\Models\EducationalProgram;
class UpdateEducationalProgramAction
{
public function run(EducationalProgram $program, array $data): EducationalProgram
{
$program->update($data);
return $program;
}
}
@@ -189,6 +189,9 @@ class FetchEmailNewsAction
'subject' => $email['subject'] ?? 'unknown', 'subject' => $email['subject'] ?? 'unknown',
]); ]);
// Помечаем письмо как прочитанное чтобы не обрабатывать повторно
$this->markEmail($email['message'], $folder);
return [ return [
'success' => false, 'success' => false,
'error' => 'Нет DOC/DOCX файла для извлечения текста', 'error' => 'Нет DOC/DOCX файла для извлечения текста',
@@ -0,0 +1,27 @@
<?php
namespace App\Containers\Dashboard\Actions\Faculties;
use App\Containers\InstituteStructure\Models\Faculty;
use App\Containers\User\Models\User;
use App\Services\App\Cache\FacultyCacheService;
class AttachFacultyWorkerAction
{
public function __construct(
private readonly FacultyCacheService $cacheService,
) {}
public function run(Faculty $faculty, User $user, array $data): void
{
$faculty->workers()->attach($user->id, [
'position' => $data['position'],
'service_email' => $data['service_email'] ?? null,
'service_phone' => $data['service_phone'] ?? null,
'cabinet' => $data['cabinet'] ?? null,
'sort' => $data['sort'] ?? $faculty->workers()->count(),
]);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Containers\Dashboard\Actions\Faculties;
use App\Containers\Dashboard\Tasks\Content\GenerateSearchDataTask;
use App\Containers\InstituteStructure\Models\Faculty;
use Illuminate\Support\Str;
class CreateFacultyAction
{
public function __construct(
private readonly GenerateSearchDataTask $generateSearchDataTask,
) {}
public function run(array $data): Faculty
{
// Генерируем slug если не передан
if (empty($data['slug'])) {
$data['slug'] = Str::slug($data['title']);
}
// Генерируем search_data из контента
if (!empty($data['content'])) {
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
}
$faculty = Faculty::create($data);
// Создаем SEO
$this->generateSeo($faculty, $data);
return $faculty;
}
private function generateSeo(Faculty $faculty, array $data): void
{
$title = $data['title'] ?? '';
$description = null;
// Извлекаем description из первого paragraph блока
if (!empty($data['content'])) {
foreach ($data['content'] as $block) {
if ($block['type'] === 'paragraph') {
$description = strip_tags($block['data']['content']);
break;
}
}
}
$faculty->seo()->create([
'title' => $title,
'description' => Str::limit(htmlspecialchars($description ?? '', ENT_QUOTES, 'UTF-8'), 160),
]);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\Faculties;
use App\Containers\InstituteStructure\Models\Faculty;
class DeleteFacultyAction
{
public function run(Faculty $faculty): void
{
$faculty->delete();
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Containers\Dashboard\Actions\Faculties;
use App\Containers\InstituteStructure\Models\Faculty;
use App\Containers\User\Models\User;
use App\Services\App\Cache\FacultyCacheService;
class DetachFacultyWorkerAction
{
public function __construct(
private readonly FacultyCacheService $cacheService,
) {}
public function run(Faculty $faculty, User $worker): void
{
$faculty->workers()->detach($worker->id);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Containers\Dashboard\Actions\Faculties;
use App\Containers\InstituteStructure\Models\Faculty;
class ListFacultiesAction
{
public function run(array $filters = []): array
{
$query = Faculty::query();
// Фильтр по статусу
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query->where('is_active', (bool) $filters['is_active']);
}
// Поиск по названию
if (!empty($filters['search'])) {
$query->where('title', 'like', '%' . $filters['search'] . '%');
}
$faculties = $query->orderBy('title')->paginate(20)->withQueryString();
return [
'faculties' => $faculties,
'filters' => $filters,
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Containers\Dashboard\Actions\Faculties;
use App\Containers\InstituteStructure\Models\Faculty;
use Illuminate\Database\Eloquent\Builder;
class ListFacultyWorkersAction
{
public function run(Faculty $faculty, array $filters = []): array
{
$query = $faculty->workers()
->withPivot(['position', 'sort', 'service_email', 'service_phone', 'cabinet'])
->whereHas('userDetail');
// Поиск по ФИО
if (!empty($filters['search'])) {
$query->where('name', 'like', '%' . $filters['search'] . '%');
}
// Фильтр по должности
if (!empty($filters['position'])) {
$query->where('workers_faculties.position', 'like', '%' . $filters['position'] . '%');
}
$workers = $query->orderBy('workers_faculties.sort')->paginate(20)->withQueryString();
return [
'workers' => $workers,
'filters' => $filters,
];
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Containers\Dashboard\Actions\Faculties;
use App\Containers\Dashboard\Tasks\Content\GenerateSearchDataTask;
use App\Containers\InstituteStructure\Models\Faculty;
use Illuminate\Support\Str;
class UpdateFacultyAction
{
public function __construct(
private readonly GenerateSearchDataTask $generateSearchDataTask,
) {}
public function run(Faculty $faculty, array $data): Faculty
{
// Генерируем search_data из контента только если контент передан и не пуст
if (isset($data['content']) && !empty($data['content'])) {
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
}
$faculty->update($data);
// Обновляем SEO
$this->updateSeo($faculty, $data);
return $faculty;
}
private function updateSeo(Faculty $faculty, array $data): void
{
$title = $data['title'] ?? $faculty->title;
$description = null;
// Извлекаем description из первого paragraph блока
if (!empty($data['content'])) {
foreach ($data['content'] as $block) {
if ($block['type'] === 'paragraph') {
$description = strip_tags($block['data']['content']);
break;
}
}
}
$seoData = [
'title' => $title,
'description' => Str::limit(htmlspecialchars($description ?? '', ENT_QUOTES, 'UTF-8'), 160),
];
if ($faculty->seo) {
$faculty->seo->update($seoData);
} else {
$faculty->seo()->create($seoData);
}
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Containers\Dashboard\Actions\Faculties;
use App\Containers\InstituteStructure\Models\Faculty;
use App\Containers\User\Models\User;
use App\Services\App\Cache\FacultyCacheService;
class UpdateFacultyWorkerAction
{
public function __construct(
private readonly FacultyCacheService $cacheService,
) {}
public function run(Faculty $faculty, User $worker, array $data): void
{
$faculty->workers()->updateExistingPivot($worker->id, [
'position' => $data['position'],
'service_email' => $data['service_email'] ?? null,
'service_phone' => $data['service_phone'] ?? null,
'cabinet' => $data['cabinet'] ?? null,
]);
$this->cacheService->clearAllCacheByModel();
}
}
@@ -1,234 +0,0 @@
<?php
namespace App\Containers\Dashboard\Actions;
use App\Containers\Dashboard\Data\EmailAttachmentData;
use App\Containers\Dashboard\Exceptions\EmailFetchException;
use App\Containers\Dashboard\Tasks\ConnectToImapTask;
use App\Containers\Dashboard\Tasks\DownloadAttachmentsTask;
use App\Containers\Dashboard\Tasks\FetchUnreadEmailsTask;
use App\Containers\Dashboard\Tasks\FilterBySenderTask;
use App\Containers\Dashboard\Tasks\MarkEmailAsReadTask;
use App\Containers\Dashboard\Actions\ProcessMixedFilesAction;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Webklex\PHPIMAP\Client;
use Webklex\PHPIMAP\Folder;
/**
* Оркестрация процесса получения новостей из Email
*/
class FetchEmailNewsAction
{
public function __construct(
private readonly ConnectToImapTask $connectToImapTask,
private readonly FetchUnreadEmailsTask $fetchUnreadEmailsTask,
private readonly FilterBySenderTask $filterBySenderTask,
private readonly DownloadAttachmentsTask $downloadAttachmentsTask,
private readonly MarkEmailAsReadTask $markEmailAsReadTask,
private readonly ProcessMixedFilesAction $processMixedFilesAction,
) {}
/**
* Выполнить получение и обработку новостей из Email
*
* @return array Результат обработки
* @throws EmailFetchException
*/
public function run(): array
{
// Проверяем, включена ли функция
if (!config('email-news.enabled', true)) {
Log::warning('[FetchEmailNewsAction] Функция отключена в конфиге');
throw EmailFetchException::featureDisabled();
}
Log::info('[FetchEmailNewsAction] Начало получения новостей из Email');
$result = [
'processed_emails' => 0,
'skipped_emails' => 0,
'created_posts' => 0,
'errors' => [],
'posts' => [],
];
try {
// Подключаемся к IMAP
$client = $this->connectToImapTask->run();
// Получаем папку
$folder = $this->connectToImapTask->getFolder(
$client,
config('email-news.folder', 'INBOX')
);
// Получаем непрочитанные письма
$emails = $this->fetchUnreadEmailsTask->run($folder);
if (empty($emails)) {
Log::info('[FetchEmailNewsAction] Нет непрочитанных писем');
return $result;
}
// Фильтруем по отправителю
$filteredEmails = $this->filterBySenderTask->run($emails);
$result['skipped_emails'] = count($emails) - count($filteredEmails);
// Обрабатываем каждое письмо
foreach ($filteredEmails as $email) {
$emailResult = $this->processEmail($email, $folder);
if ($emailResult['success']) {
$result['created_posts']++;
$result['posts'][] = $emailResult['post'];
} else {
$result['errors'][] = [
'email_subject' => $email['subject'],
'error' => $emailResult['error'],
];
}
$result['processed_emails']++;
}
Log::info('[FetchEmailNewsAction] Завершено', [
'processed' => $result['processed_emails'],
'created_posts' => $result['created_posts'],
'skipped' => $result['skipped_emails'],
'errors_count' => count($result['errors']),
]);
return $result;
} catch (\Exception $e) {
Log::error('[FetchEmailNewsAction] Критическая ошибка', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw $e;
}
}
/**
* Обработать одно письмо
*
* @param array $email Данные письма
* @param Folder $folder IMAP папка
* @return array Результат обработки
*/
private function processEmail(array $email, Folder $folder): array
{
Log::info('[FetchEmailNewsAction:processEmail] Обработка письма', [
'subject' => $email['subject'],
'from' => $email['from_email'],
]);
try {
// Скачиваем вложения
$attachments = $this->downloadAttachmentsTask->run($email['message']);
// Проверяем, есть ли DOC/DOCX файл
$hasDocument = collect($attachments)->contains(fn($att) => $att->isDocument());
if (!$hasDocument) {
Log::warning('[FetchEmailNewsAction:processEmail] Нет DOC/DOCX файла во вложениях', [
'subject' => $email['subject'],
]);
return [
'success' => false,
'error' => 'Нет DOC/DOCX файла для извлечения текста',
];
}
// Конвертируем вложения в UploadedFile
$uploadedFiles = $this->convertToUploadedFiles($attachments);
// Обрабатываем через существующий ProcessMixedFilesAction
$postResult = $this->processMixedFilesAction->run($uploadedFiles);
// Помечаем письмо как прочитанное
$this->markEmail($email['message'], $folder);
Log::info('[FetchEmailNewsAction:processEmail] Письмо успешно обработано', [
'subject' => $email['subject'],
'post_id' => $postResult['post']->id,
]);
return [
'success' => true,
'post' => $postResult['post'],
'attachments_count' => count($attachments),
];
} catch (\Exception $e) {
Log::error('[FetchEmailNewsAction:processEmail] Ошибка обработки письма', [
'subject' => $email['subject'],
'error' => $e->getMessage(),
]);
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
/**
* Конвертировать EmailAttachmentData в UploadedFile
*
* @param array<EmailAttachmentData> $attachments
* @return \Illuminate\Support\Collection<UploadedFile>
*/
private function convertToUploadedFiles(array $attachments): \Illuminate\Support\Collection
{
$uploadedFiles = [];
foreach ($attachments as $attachment) {
$fullPath = storage_path('app/' . $attachment->path);
if (!file_exists($fullPath)) {
Log::warning('[FetchEmailNewsAction:convertToUploadedFiles] Файл не найден', [
'path' => $attachment->path,
]);
continue;
}
// Создаём UploadedFile из сохранённого файла
$uploadedFile = new UploadedFile(
$fullPath,
$attachment->filename,
$attachment->mimeType,
null,
true // test = false (файл валиден)
);
$uploadedFiles[] = $uploadedFile;
}
Log::info('[FetchEmailNewsAction:convertToUploadedFiles] Конвертировано файлов', [
'count' => count($uploadedFiles),
]);
return collect($uploadedFiles);
}
/**
* Пометить письмо как прочитанное (и возможно переместить)
*
* @param object $message IMAP сообщение
* @param Folder $folder Текущая папка
*/
private function markEmail(object $message, Folder $folder): void
{
$moveToFolder = config('email-news.move_to_folder');
if ($moveToFolder) {
$this->markEmailAsReadTask->markAndMove($message, $moveToFolder);
} elseif (config('email-news.mark_as_read', true)) {
$this->markEmailAsReadTask->run($message);
}
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\JournalIssues;
use App\Containers\Science\Models\JournalIssue;
class CreateJournalIssueAction
{
public function run(array $data): JournalIssue
{
return JournalIssue::create($data);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\JournalIssues;
use App\Containers\Science\Models\JournalIssue;
class DeleteJournalIssueAction
{
public function run(JournalIssue $issue): bool
{
return $issue->delete();
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Containers\Dashboard\Actions\JournalIssues;
use App\Containers\Science\Models\JournalIssue;
class ListJournalIssuesAction
{
public function run(int $journalId, array $filters = []): array
{
$query = JournalIssue::where('academic_journal_id', $journalId);
// Фильтр по году
if (!empty($filters['year_publication'])) {
$query->where('year_publication', $filters['year_publication']);
}
// Фильтр по статусу
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query->where('is_active', (bool) $filters['is_active']);
}
// Поиск по названию
if (!empty($filters['search'])) {
$query->where('title', 'like', '%' . $filters['search'] . '%');
}
$issues = $query->orderBy('sort')->orderBy('year_publication', 'desc')->paginate(20)->withQueryString();
// Получаем уникальные годы для фильтра (оптимизировано через groupBy)
$years = JournalIssue::where('academic_journal_id', $journalId)
->groupBy('year_publication')
->orderBy('year_publication', 'desc')
->pluck('year_publication');
return [
'issues' => $issues,
'filters' => $filters,
'years' => $years,
];
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Containers\Dashboard\Actions\JournalIssues;
use App\Containers\Science\Models\JournalIssue;
class UpdateJournalIssueAction
{
public function run(JournalIssue $issue, array $data): JournalIssue
{
$issue->update($data);
return $issue->fresh();
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Containers\Dashboard\Actions;
use App\Containers\Dashboard\Tasks\Posts\GetAiPreparedPostsTask;
use App\Containers\Dashboard\Tasks\Stats\GetDashboardStatsTask;
use App\Containers\Dashboard\Tasks\Stats\GetRecentActivityTask;
class LoadDashboardDataAction
{
public function __construct(
private readonly GetAiPreparedPostsTask $getAiPreparedPostsTask,
private readonly GetDashboardStatsTask $getDashboardStatsTask,
private readonly GetRecentActivityTask $getRecentActivityTask,
) {}
public function run(): array
{
return [
'aiPreparedPosts' => $this->getAiPreparedPostsTask->run(),
'stats' => $this->getDashboardStatsTask->run(),
'recentActivity' => $this->getRecentActivityTask->run(),
];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Containers\Dashboard\Actions\MainSections;
use App\Containers\AppStructure\Models\MainSection;
class CreateMainSectionAction
{
public function run(array $data): MainSection
{
return MainSection::create([
'title' => $data['title'],
'slug' => $data['slug'],
]);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\MainSections;
use App\Containers\AppStructure\Models\MainSection;
class DeleteMainSectionAction
{
public function run(MainSection $mainSection): bool
{
return $mainSection->delete();
}
}

Some files were not shown because too many files have changed in this diff Show More