Compare commits
No commits in common. "main" and "001-run-checks" have entirely different histories.
main
...
001-run-ch
@ -1,184 +0,0 @@
|
||||
---
|
||||
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Goal
|
||||
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||
|
||||
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Initialize Analysis Context
|
||||
|
||||
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
|
||||
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Overview/Context
|
||||
- Functional Requirements
|
||||
- Non-Functional Requirements
|
||||
- User Stories
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices
|
||||
- Data Model references
|
||||
- Phases
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs
|
||||
- Descriptions
|
||||
- Phase grouping
|
||||
- Parallel markers [P]
|
||||
- Referenced file paths
|
||||
|
||||
**From constitution:**
|
||||
|
||||
- Load `.specify/memory/constitution.md` for principle validation
|
||||
|
||||
### 3. Build Semantic Models
|
||||
|
||||
Create internal representations (do not include raw artifacts in output):
|
||||
|
||||
- **Requirements inventory**: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., "User can upload file" → `user-can-upload-file`)
|
||||
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
||||
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
|
||||
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 4. Detection Passes (Token-Efficient Analysis)
|
||||
|
||||
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
|
||||
|
||||
#### A. Duplication Detection
|
||||
|
||||
- Identify near-duplicate requirements
|
||||
- Mark lower-quality phrasing for consolidation
|
||||
|
||||
#### B. Ambiguity Detection
|
||||
|
||||
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
|
||||
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
|
||||
|
||||
#### C. Underspecification
|
||||
|
||||
- Requirements with verbs but missing object or measurable outcome
|
||||
- User stories missing acceptance criteria alignment
|
||||
- Tasks referencing files or components not defined in spec/plan
|
||||
|
||||
#### D. Constitution Alignment
|
||||
|
||||
- Any requirement or plan element conflicting with a MUST principle
|
||||
- Missing mandated sections or quality gates from constitution
|
||||
|
||||
#### E. Coverage Gaps
|
||||
|
||||
- Requirements with zero associated tasks
|
||||
- Tasks with no mapped requirement/story
|
||||
- Non-functional requirements not reflected in tasks (e.g., performance, security)
|
||||
|
||||
#### F. Inconsistency
|
||||
|
||||
- Terminology drift (same concept named differently across files)
|
||||
- Data entities referenced in plan but absent in spec (or vice versa)
|
||||
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
|
||||
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
|
||||
|
||||
### 5. Severity Assignment
|
||||
|
||||
Use this heuristic to prioritize findings:
|
||||
|
||||
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
|
||||
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
|
||||
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
|
||||
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
|
||||
|
||||
### 6. Produce Compact Analysis Report
|
||||
|
||||
Output a Markdown report (no file writes) with the following structure:
|
||||
|
||||
## Specification Analysis Report
|
||||
|
||||
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|
||||
|----|----------|----------|-------------|---------|----------------|
|
||||
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
|
||||
|
||||
(Add one row per finding; generate stable IDs prefixed by category initial.)
|
||||
|
||||
**Coverage Summary Table:**
|
||||
|
||||
| Requirement Key | Has Task? | Task IDs | Notes |
|
||||
|-----------------|-----------|----------|-------|
|
||||
|
||||
**Constitution Alignment Issues:** (if any)
|
||||
|
||||
**Unmapped Tasks:** (if any)
|
||||
|
||||
**Metrics:**
|
||||
|
||||
- Total Requirements
|
||||
- Total Tasks
|
||||
- Coverage % (requirements with >=1 task)
|
||||
- Ambiguity Count
|
||||
- Duplication Count
|
||||
- Critical Issues Count
|
||||
|
||||
### 7. Provide Next Actions
|
||||
|
||||
At end of report, output a concise Next Actions block:
|
||||
|
||||
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
|
||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||
|
||||
### 8. Offer Remediation
|
||||
|
||||
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
|
||||
|
||||
## Operating Principles
|
||||
|
||||
### Context Efficiency
|
||||
|
||||
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
|
||||
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
|
||||
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
|
||||
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
|
||||
|
||||
### Analysis Guidelines
|
||||
|
||||
- **NEVER modify files** (this is read-only analysis)
|
||||
- **NEVER hallucinate missing sections** (if absent, report them accurately)
|
||||
- **Prioritize constitution violations** (these are always CRITICAL)
|
||||
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
|
||||
- **Report zero issues gracefully** (emit success report with coverage statistics)
|
||||
|
||||
## Context
|
||||
|
||||
$ARGUMENTS
|
||||
@ -1,294 +0,0 @@
|
||||
---
|
||||
description: Generate a custom checklist for the current feature based on user requirements.
|
||||
---
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
|
||||
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
|
||||
|
||||
**NOT for verification/testing**:
|
||||
|
||||
- ❌ NOT "Verify the button clicks correctly"
|
||||
- ❌ NOT "Test error handling works"
|
||||
- ❌ NOT "Confirm the API returns 200"
|
||||
- ❌ NOT checking if code/implementation matches the spec
|
||||
|
||||
**FOR requirements quality validation**:
|
||||
|
||||
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
|
||||
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
|
||||
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
|
||||
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
|
||||
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
|
||||
|
||||
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
- Prefer precision over breadth
|
||||
|
||||
Generation algorithm:
|
||||
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
|
||||
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
|
||||
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
|
||||
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
|
||||
5. Formulate questions chosen from these archetypes:
|
||||
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
|
||||
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
|
||||
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
|
||||
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
|
||||
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
|
||||
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
|
||||
|
||||
Question formatting rules:
|
||||
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
|
||||
- Limit to A–E options maximum; omit table if a free-form answer is clearer
|
||||
- Never ask the user to restate what they already said
|
||||
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
|
||||
|
||||
Defaults when interaction impossible:
|
||||
- Depth: Standard
|
||||
- Audience: Reviewer (PR) if code-related; Author otherwise
|
||||
- Focus: Top 2 relevance clusters
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
4. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
|
||||
**Context Loading Strategy**:
|
||||
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
|
||||
- Prefer summarizing long sections into concise scenario/requirement bullets
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
5. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
- Format: `[domain].md`
|
||||
- If file exists, append to existing file
|
||||
- Number items sequentially starting from CHK001
|
||||
- Each `/speckit.checklist` run creates a NEW file (never overwrites existing checklists)
|
||||
|
||||
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||
- **Completeness**: Are all necessary requirements present?
|
||||
- **Clarity**: Are requirements unambiguous and specific?
|
||||
- **Consistency**: Do requirements align with each other?
|
||||
- **Measurability**: Can requirements be objectively verified?
|
||||
- **Coverage**: Are all scenarios/edge cases addressed?
|
||||
|
||||
**Category Structure** - Group items by requirement quality dimensions:
|
||||
- **Requirement Completeness** (Are all necessary requirements documented?)
|
||||
- **Requirement Clarity** (Are requirements specific and unambiguous?)
|
||||
- **Requirement Consistency** (Do requirements align without conflicts?)
|
||||
- **Acceptance Criteria Quality** (Are success criteria measurable?)
|
||||
- **Scenario Coverage** (Are all flows/cases addressed?)
|
||||
- **Edge Case Coverage** (Are boundary conditions defined?)
|
||||
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
|
||||
- **Dependencies & Assumptions** (Are they documented and validated?)
|
||||
- **Ambiguities & Conflicts** (What needs clarification?)
|
||||
|
||||
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
|
||||
|
||||
❌ **WRONG** (Testing implementation):
|
||||
- "Verify landing page displays 3 episode cards"
|
||||
- "Test hover states work on desktop"
|
||||
- "Confirm logo click navigates home"
|
||||
|
||||
✅ **CORRECT** (Testing requirements quality):
|
||||
- "Are the exact number and layout of featured episodes specified?" [Completeness]
|
||||
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
|
||||
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
|
||||
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
|
||||
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
|
||||
- "Are loading states defined for asynchronous episode data?" [Completeness]
|
||||
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
|
||||
|
||||
**ITEM STRUCTURE**:
|
||||
Each item should follow this pattern:
|
||||
- Question format asking about requirement quality
|
||||
- Focus on what's WRITTEN (or not written) in the spec/plan
|
||||
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
|
||||
- Reference spec section `[Spec §X.Y]` when checking existing requirements
|
||||
- Use `[Gap]` marker when checking for missing requirements
|
||||
|
||||
**EXAMPLES BY QUALITY DIMENSION**:
|
||||
|
||||
Completeness:
|
||||
- "Are error handling requirements defined for all API failure modes? [Gap]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
|
||||
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
|
||||
|
||||
Clarity:
|
||||
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
|
||||
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
|
||||
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
|
||||
|
||||
Consistency:
|
||||
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
|
||||
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
|
||||
|
||||
Coverage:
|
||||
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
|
||||
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
|
||||
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
|
||||
|
||||
Measurability:
|
||||
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
|
||||
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
|
||||
|
||||
**Scenario Classification & Coverage** (Requirements Quality Focus):
|
||||
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
|
||||
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
|
||||
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
|
||||
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
|
||||
|
||||
**Traceability Requirements**:
|
||||
- MINIMUM: ≥80% of items MUST include at least one traceability reference
|
||||
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
|
||||
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
|
||||
|
||||
**Surface & Resolve Issues** (Requirements Quality Problems):
|
||||
Ask questions about the requirements themselves:
|
||||
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
|
||||
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
|
||||
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
|
||||
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
|
||||
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
|
||||
|
||||
**Content Consolidation**:
|
||||
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
|
||||
- Merge near-duplicates checking the same requirement aspect
|
||||
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
|
||||
|
||||
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
|
||||
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
|
||||
- ❌ References to code execution, user actions, system behavior
|
||||
- ❌ "Displays correctly", "works properly", "functions as expected"
|
||||
- ❌ "Click", "navigate", "render", "load", "execute"
|
||||
- ❌ Test cases, test plans, QA procedures
|
||||
- ❌ Implementation details (frameworks, APIs, algorithms)
|
||||
|
||||
**✅ REQUIRED PATTERNS** - These test requirements quality:
|
||||
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
|
||||
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
|
||||
- ✅ "Are requirements consistent between [section A] and [section B]?"
|
||||
- ✅ "Can [requirement] be objectively measured/verified?"
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
6. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
|
||||
7. **Report**: Output full path to created checklist, item count, and remind user that each run creates a new file. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
- Any explicit user-specified must-have items incorporated
|
||||
|
||||
**Important**: Each `/speckit.checklist` command invocation creates a checklist file using short, descriptive names unless file already exists. This allows:
|
||||
|
||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||
- Simple, memorable filenames that indicate checklist purpose
|
||||
- Easy identification and navigation in the `checklists/` folder
|
||||
|
||||
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
|
||||
|
||||
## Example Checklist Types & Sample Items
|
||||
|
||||
**UX Requirements Quality:** `ux.md`
|
||||
|
||||
Sample items (testing the requirements, NOT the implementation):
|
||||
|
||||
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
|
||||
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
|
||||
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
|
||||
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
|
||||
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
|
||||
|
||||
**API Requirements Quality:** `api.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are error response formats specified for all failure scenarios? [Completeness]"
|
||||
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
|
||||
- "Are authentication requirements consistent across all endpoints? [Consistency]"
|
||||
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
|
||||
- "Is versioning strategy documented in requirements? [Gap]"
|
||||
|
||||
**Performance Requirements Quality:** `performance.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are performance requirements quantified with specific metrics? [Clarity]"
|
||||
- "Are performance targets defined for all critical user journeys? [Coverage]"
|
||||
- "Are performance requirements under different load conditions specified? [Completeness]"
|
||||
- "Can performance requirements be objectively measured? [Measurability]"
|
||||
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
|
||||
|
||||
**Security Requirements Quality:** `security.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are authentication requirements specified for all protected resources? [Coverage]"
|
||||
- "Are data protection requirements defined for sensitive information? [Completeness]"
|
||||
- "Is the threat model documented and requirements aligned to it? [Traceability]"
|
||||
- "Are security requirements consistent with compliance obligations? [Consistency]"
|
||||
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
|
||||
|
||||
## Anti-Examples: What NOT To Do
|
||||
|
||||
**❌ WRONG - These test implementation, not requirements:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
|
||||
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
|
||||
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
|
||||
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
|
||||
```
|
||||
|
||||
**✅ CORRECT - These test requirements quality:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
|
||||
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
|
||||
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
|
||||
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
|
||||
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
|
||||
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
- Wrong: Tests if the system works correctly
|
||||
- Correct: Tests if the requirements are written correctly
|
||||
- Wrong: Verification of behavior
|
||||
- Correct: Validation of requirement quality
|
||||
- Wrong: "Does it do X?"
|
||||
- Correct: "Is X clearly specified?"
|
||||
@ -1,181 +0,0 @@
|
||||
---
|
||||
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||
|
||||
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||
|
||||
Execution steps:
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
|
||||
- `FEATURE_DIR`
|
||||
- `FEATURE_SPEC`
|
||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
- Explicit out-of-scope declarations
|
||||
- User roles / personas differentiation
|
||||
|
||||
Domain & Data Model:
|
||||
- Entities, attributes, relationships
|
||||
- Identity & uniqueness rules
|
||||
- Lifecycle/state transitions
|
||||
- Data volume / scale assumptions
|
||||
|
||||
Interaction & UX Flow:
|
||||
- Critical user journeys / sequences
|
||||
- Error/empty/loading states
|
||||
- Accessibility or localization notes
|
||||
|
||||
Non-Functional Quality Attributes:
|
||||
- Performance (latency, throughput targets)
|
||||
- Scalability (horizontal/vertical, limits)
|
||||
- Reliability & availability (uptime, recovery expectations)
|
||||
- Observability (logging, metrics, tracing signals)
|
||||
- Security & privacy (authN/Z, data protection, threat assumptions)
|
||||
- Compliance / regulatory constraints (if any)
|
||||
|
||||
Integration & External Dependencies:
|
||||
- External services/APIs and failure modes
|
||||
- Data import/export formats
|
||||
- Protocol/versioning assumptions
|
||||
|
||||
Edge Cases & Failure Handling:
|
||||
- Negative scenarios
|
||||
- Rate limiting / throttling
|
||||
- Conflict resolution (e.g., concurrent edits)
|
||||
|
||||
Constraints & Tradeoffs:
|
||||
- Technical constraints (language, storage, hosting)
|
||||
- Explicit tradeoffs or rejected alternatives
|
||||
|
||||
Terminology & Consistency:
|
||||
- Canonical glossary terms
|
||||
- Avoided synonyms / deprecated terms
|
||||
|
||||
Completion Signals:
|
||||
- Acceptance criteria testability
|
||||
- Measurable Definition of Done style indicators
|
||||
|
||||
Misc / Placeholders:
|
||||
- TODO markers / unresolved decisions
|
||||
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
|
||||
|
||||
For each category with Partial or Missing status, add a candidate question opportunity unless:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- Information is better deferred to planning phase (note internally)
|
||||
|
||||
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 10 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words").
|
||||
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
|
||||
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
|
||||
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
|
||||
|
||||
4. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
- Best practices for the project type
|
||||
- Common patterns in similar implementations
|
||||
- Risk reduction (security, performance, maintainability)
|
||||
- Alignment with any explicit project goals or constraints visible in the spec
|
||||
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
|
||||
- Format as: `**Recommended:** Option [X] - <reasoning>`
|
||||
- Then render all options as a Markdown table:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| A | <Option A description> |
|
||||
| B | <Option B description> |
|
||||
| C | <Option C description> (add D/E as needed up to 5) |
|
||||
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
|
||||
|
||||
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
|
||||
- For short‑answer style (no meaningful discrete options):
|
||||
- Provide your **suggested answer** based on best practices and context.
|
||||
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
|
||||
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
|
||||
- After the user answers:
|
||||
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
|
||||
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
|
||||
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
||||
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
||||
- Stop asking further questions when:
|
||||
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
|
||||
- User signals completion ("done", "good", "no more"), OR
|
||||
- You reach 5 asked questions.
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
5. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
|
||||
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
|
||||
- Then immediately apply the clarification to the most appropriate section(s):
|
||||
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
||||
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
|
||||
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||
- Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target).
|
||||
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
|
||||
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
|
||||
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
6. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
7. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
8. Report completion (after questioning loop ends or early termination):
|
||||
- Number of questions asked & answered.
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
|
||||
- Suggested next command.
|
||||
|
||||
Behavior rules:
|
||||
|
||||
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
|
||||
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||
- Respect user early termination signals ("stop", "done", "proceed").
|
||||
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
|
||||
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
|
||||
|
||||
Context for prioritization: $ARGUMENTS
|
||||
@ -1,82 +0,0 @@
|
||||
---
|
||||
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
|
||||
handoffs:
|
||||
- label: Build Specification
|
||||
agent: speckit.specify
|
||||
prompt: Implement the feature specification based on the updated constitution. I want to build...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
|
||||
|
||||
Follow this execution flow:
|
||||
|
||||
1. Load the existing constitution template at `.specify/memory/constitution.md`.
|
||||
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
|
||||
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
|
||||
|
||||
2. Collect/derive values for placeholders:
|
||||
- If user input (conversation) supplies a value, use it.
|
||||
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
|
||||
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
|
||||
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
|
||||
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
|
||||
- MINOR: New principle/section added or materially expanded guidance.
|
||||
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
|
||||
- If version bump type ambiguous, propose reasoning before finalizing.
|
||||
|
||||
3. Draft the updated constitution content:
|
||||
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
|
||||
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
|
||||
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
|
||||
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
|
||||
|
||||
4. Consistency propagation checklist (convert prior checklist into active validations):
|
||||
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
|
||||
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
|
||||
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
|
||||
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
|
||||
|
||||
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||
- Version change: old → new
|
||||
- List of modified principles (old title → new title if renamed)
|
||||
- Added sections
|
||||
- Removed sections
|
||||
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
|
||||
- Follow-up TODOs if any placeholders intentionally deferred.
|
||||
|
||||
6. Validation before final output:
|
||||
- No remaining unexplained bracket tokens.
|
||||
- Version line matches report.
|
||||
- Dates ISO format YYYY-MM-DD.
|
||||
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
|
||||
|
||||
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
|
||||
|
||||
8. Output a final summary to the user with:
|
||||
- New version and bump rationale.
|
||||
- Any files flagged for manual follow-up.
|
||||
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
|
||||
|
||||
Formatting & Style Requirements:
|
||||
|
||||
- Use Markdown headings exactly as in the template (do not demote/promote levels).
|
||||
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
|
||||
- Keep a single blank line between sections.
|
||||
- Avoid trailing whitespace.
|
||||
|
||||
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
|
||||
|
||||
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||
|
||||
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
|
||||
@ -1,135 +0,0 @@
|
||||
---
|
||||
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
- For each checklist, count:
|
||||
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||
- Completed items: Lines matching `- [X]` or `- [x]`
|
||||
- Incomplete items: Lines matching `- [ ]`
|
||||
- Create a status table:
|
||||
|
||||
```text
|
||||
| Checklist | Total | Completed | Incomplete | Status |
|
||||
|-----------|-------|-----------|------------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
```
|
||||
|
||||
- Calculate overall status:
|
||||
- **PASS**: All checklists have 0 incomplete items
|
||||
- **FAIL**: One or more checklists have incomplete items
|
||||
|
||||
- **If any checklist is incomplete**:
|
||||
- Display the table with incomplete item counts
|
||||
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- Wait for user response before continuing
|
||||
- If user says "no" or "wait" or "stop", halt execution
|
||||
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||
|
||||
- **If all checklists are complete**:
|
||||
- Display the table showing all checklists passed
|
||||
- Automatically proceed to step 3
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
4. **Project Setup Verification**:
|
||||
- **REQUIRED**: Create/verify ignore files based on actual project setup:
|
||||
|
||||
**Detection & Creation Logic**:
|
||||
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
|
||||
|
||||
```sh
|
||||
git rev-parse --git-dir 2>/dev/null
|
||||
```
|
||||
|
||||
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
|
||||
- Check if .eslintrc* exists → create/verify .eslintignore
|
||||
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
|
||||
- Check if .prettierrc* exists → create/verify .prettierignore
|
||||
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
|
||||
- Check if terraform files (*.tf) exist → create/verify .terraformignore
|
||||
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
|
||||
|
||||
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
|
||||
**If ignore file missing**: Create with full pattern set for detected technology
|
||||
|
||||
**Common Patterns by Technology** (from plan.md tech stack):
|
||||
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
|
||||
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
|
||||
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
|
||||
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
|
||||
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
|
||||
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
|
||||
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
|
||||
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
|
||||
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
|
||||
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
|
||||
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `Makefile`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
|
||||
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
|
||||
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||
|
||||
**Tool-Specific Patterns**:
|
||||
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
|
||||
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
|
||||
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
||||
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
|
||||
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
|
||||
|
||||
5. Parse tasks.md structure and extract:
|
||||
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
||||
- **Task dependencies**: Sequential vs parallel execution rules
|
||||
- **Task details**: ID, description, file paths, parallel markers [P]
|
||||
- **Execution flow**: Order and dependency requirements
|
||||
|
||||
6. Execute implementation following the task plan:
|
||||
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
||||
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||
|
||||
7. Implementation execution rules:
|
||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||
- **Core development**: Implement models, services, CLI commands, endpoints
|
||||
- **Integration work**: Database connections, middleware, logging, external services
|
||||
- **Polish and validation**: Unit tests, performance optimization, documentation
|
||||
|
||||
8. Progress tracking and error handling:
|
||||
- Report progress after each completed task
|
||||
- Halt execution if any non-parallel task fails
|
||||
- For parallel tasks [P], continue with successful tasks, report failed ones
|
||||
- Provide clear error messages with context for debugging
|
||||
- Suggest next steps if implementation cannot proceed
|
||||
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
||||
|
||||
9. Completion validation:
|
||||
- Verify all required tasks are completed
|
||||
- Check that implemented features match the original specification
|
||||
- Validate that tests pass and coverage meets requirements
|
||||
- Confirm the implementation follows the technical plan
|
||||
- Report final status with summary of completed work
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.
|
||||
@ -1,89 +0,0 @@
|
||||
---
|
||||
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
|
||||
handoffs:
|
||||
- label: Create Tasks
|
||||
agent: speckit.tasks
|
||||
prompt: Break the plan into tasks
|
||||
send: true
|
||||
- label: Create Checklist
|
||||
agent: speckit.checklist
|
||||
prompt: Create a checklist for the following domain...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
|
||||
|
||||
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||
- Fill Constitution Check section from constitution
|
||||
- Evaluate gates (ERROR if violations unjustified)
|
||||
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
|
||||
- Phase 1: Generate data-model.md, contracts/, quickstart.md
|
||||
- Phase 1: Update agent context by running the agent script
|
||||
- Re-evaluate Constitution Check post-design
|
||||
|
||||
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0: Outline & Research
|
||||
|
||||
1. **Extract unknowns from Technical Context** above:
|
||||
- For each NEEDS CLARIFICATION → research task
|
||||
- For each dependency → best practices task
|
||||
- For each integration → patterns task
|
||||
|
||||
2. **Generate and dispatch research agents**:
|
||||
|
||||
```text
|
||||
For each unknown in Technical Context:
|
||||
Task: "Research {unknown} for {feature context}"
|
||||
For each technology choice:
|
||||
Task: "Find best practices for {tech} in {domain}"
|
||||
```
|
||||
|
||||
3. **Consolidate findings** in `research.md` using format:
|
||||
- Decision: [what was chosen]
|
||||
- Rationale: [why chosen]
|
||||
- Alternatives considered: [what else evaluated]
|
||||
|
||||
**Output**: research.md with all NEEDS CLARIFICATION resolved
|
||||
|
||||
### Phase 1: Design & Contracts
|
||||
|
||||
**Prerequisites:** `research.md` complete
|
||||
|
||||
1. **Extract entities from feature spec** → `data-model.md`:
|
||||
- Entity name, fields, relationships
|
||||
- Validation rules from requirements
|
||||
- State transitions if applicable
|
||||
|
||||
2. **Generate API contracts** from functional requirements:
|
||||
- For each user action → endpoint
|
||||
- Use standard REST/GraphQL patterns
|
||||
- Output OpenAPI/GraphQL schema to `/contracts/`
|
||||
|
||||
3. **Agent context update**:
|
||||
- Run `.specify/scripts/bash/update-agent-context.sh codex`
|
||||
- These scripts detect which AI agent is in use
|
||||
- Update the appropriate agent-specific context file
|
||||
- Add only new technology from current plan
|
||||
- Preserve manual additions between markers
|
||||
|
||||
**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file
|
||||
|
||||
## Key rules
|
||||
|
||||
- Use absolute paths
|
||||
- ERROR on gate failures or unresolved clarifications
|
||||
@ -1,258 +0,0 @@
|
||||
---
|
||||
description: Create or update the feature specification from a natural language feature description.
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
- label: Clarify Spec Requirements
|
||||
agent: speckit.clarify
|
||||
prompt: Clarify specification requirements
|
||||
send: true
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||
|
||||
Given that feature description, do this:
|
||||
|
||||
1. **Generate a concise short name** (2-4 words) for the branch:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Create a 2-4 word short name that captures the essence of the feature
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
- Keep it concise but descriptive enough to understand the feature at a glance
|
||||
- Examples:
|
||||
- "I want to add user authentication" → "user-auth"
|
||||
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
|
||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||
|
||||
2. **Check for existing branches before creating new one**:
|
||||
|
||||
a. First, fetch all remote branches to ensure we have the latest information:
|
||||
|
||||
```bash
|
||||
git fetch --all --prune
|
||||
```
|
||||
|
||||
b. Find the highest feature number across all sources for the short-name:
|
||||
- Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/[0-9]+-<short-name>$'`
|
||||
- Local branches: `git branch | grep -E '^[* ]*[0-9]+-<short-name>$'`
|
||||
- Specs directories: Check for directories matching `specs/[0-9]+-<short-name>`
|
||||
|
||||
c. Determine the next available number:
|
||||
- Extract all numbers from all three sources
|
||||
- Find the highest number N
|
||||
- Use N+1 for the new branch number
|
||||
|
||||
d. Run the script `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS"` with the calculated number and short-name:
|
||||
- Pass `--number N+1` and `--short-name "your-short-name"` along with the feature description
|
||||
- Bash example: `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS" --json --number 5 --short-name "user-auth" "Add user authentication"`
|
||||
- PowerShell example: `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS" -Json -Number 5 -ShortName "user-auth" "Add user authentication"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Check all three sources (remote branches, local branches, specs directories) to find the highest number
|
||||
- Only match branches/directories with the exact short-name pattern
|
||||
- If no existing branches/directories found with this short-name, start with number 1
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
|
||||
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot")
|
||||
|
||||
3. Load `.specify/templates/spec-template.md` to understand required sections.
|
||||
|
||||
4. Follow this execution flow:
|
||||
|
||||
1. Parse user description from Input
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
Identify: actors, actions, data, constraints
|
||||
3. For unclear aspects:
|
||||
- Make informed guesses based on context and industry standards
|
||||
- Only mark with [NEEDS CLARIFICATION: specific question] if:
|
||||
- The choice significantly impacts feature scope or user experience
|
||||
- Multiple reasonable interpretations exist with different implications
|
||||
- No reasonable default exists
|
||||
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
|
||||
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
|
||||
4. Fill User Scenarios & Testing section
|
||||
If no clear user flow: ERROR "Cannot determine user scenarios"
|
||||
5. Generate Functional Requirements
|
||||
Each requirement must be testable
|
||||
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
|
||||
6. Define Success Criteria
|
||||
Create measurable, technology-agnostic outcomes
|
||||
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
|
||||
Each criterion must be verifiable without implementation details
|
||||
7. Identify Key Entities (if data involved)
|
||||
8. Return: SUCCESS (spec ready for planning)
|
||||
|
||||
5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
|
||||
6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
|
||||
a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||
|
||||
```markdown
|
||||
# Specification Quality Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md]
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [ ] No implementation details (languages, frameworks, APIs)
|
||||
- [ ] Focused on user value and business needs
|
||||
- [ ] Written for non-technical stakeholders
|
||||
- [ ] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [ ] No [NEEDS CLARIFICATION] markers remain
|
||||
- [ ] Requirements are testable and unambiguous
|
||||
- [ ] Success criteria are measurable
|
||||
- [ ] Success criteria are technology-agnostic (no implementation details)
|
||||
- [ ] All acceptance scenarios are defined
|
||||
- [ ] Edge cases are identified
|
||||
- [ ] Scope is clearly bounded
|
||||
- [ ] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [ ] All functional requirements have clear acceptance criteria
|
||||
- [ ] User scenarios cover primary flows
|
||||
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [ ] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
```
|
||||
|
||||
b. **Run Validation Check**: Review the spec against each checklist item:
|
||||
- For each item, determine if it passes or fails
|
||||
- Document specific issues found (quote relevant spec sections)
|
||||
|
||||
c. **Handle Validation Results**:
|
||||
|
||||
- **If all items pass**: Mark checklist complete and proceed to step 6
|
||||
|
||||
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||
1. List the failing items and specific issues
|
||||
2. Update the spec to address each issue
|
||||
3. Re-run validation until all items pass (max 3 iterations)
|
||||
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
|
||||
|
||||
- **If [NEEDS CLARIFICATION] markers remain**:
|
||||
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
|
||||
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
|
||||
3. For each clarification needed (max 3), present options to user in this format:
|
||||
|
||||
```markdown
|
||||
## Question [N]: [Topic]
|
||||
|
||||
**Context**: [Quote relevant spec section]
|
||||
|
||||
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
|
||||
|
||||
**Suggested Answers**:
|
||||
|
||||
| Option | Answer | Implications |
|
||||
|--------|--------|--------------|
|
||||
| A | [First suggested answer] | [What this means for the feature] |
|
||||
| B | [Second suggested answer] | [What this means for the feature] |
|
||||
| C | [Third suggested answer] | [What this means for the feature] |
|
||||
| Custom | Provide your own answer | [Explain how to provide custom input] |
|
||||
|
||||
**Your choice**: _[Wait for user response]_
|
||||
```
|
||||
|
||||
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
|
||||
- Use consistent spacing with pipes aligned
|
||||
- Each cell should have spaces around content: `| Content |` not `|Content|`
|
||||
- Header separator must have at least 3 dashes: `|--------|`
|
||||
- Test that the table renders correctly in markdown preview
|
||||
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
|
||||
6. Present all questions together before waiting for responses
|
||||
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
|
||||
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
|
||||
9. Re-run validation after all clarifications are resolved
|
||||
|
||||
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
|
||||
|
||||
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
|
||||
|
||||
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
## Quick Guidelines
|
||||
|
||||
- Focus on **WHAT** users need and **WHY**.
|
||||
- Avoid HOW to implement (no tech stack, APIs, code structure).
|
||||
- Written for business stakeholders, not developers.
|
||||
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
|
||||
|
||||
### Section Requirements
|
||||
|
||||
- **Mandatory sections**: Must be completed for every feature
|
||||
- **Optional sections**: Include only when relevant to the feature
|
||||
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
|
||||
|
||||
### For AI Generation
|
||||
|
||||
When creating this spec from a user prompt:
|
||||
|
||||
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
|
||||
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
|
||||
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
|
||||
- Significantly impact feature scope or user experience
|
||||
- Have multiple reasonable interpretations with different implications
|
||||
- Lack any reasonable default
|
||||
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
|
||||
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
|
||||
6. **Common areas needing clarification** (only if no reasonable default exists):
|
||||
- Feature scope and boundaries (include/exclude specific use cases)
|
||||
- User types and permissions (if multiple conflicting interpretations possible)
|
||||
- Security/compliance requirements (when legally/financially significant)
|
||||
|
||||
**Examples of reasonable defaults** (don't ask about these):
|
||||
|
||||
- Data retention: Industry-standard practices for the domain
|
||||
- Performance targets: Standard web/mobile app expectations unless specified
|
||||
- Error handling: User-friendly messages with appropriate fallbacks
|
||||
- Authentication method: Standard session-based or OAuth2 for web apps
|
||||
- Integration patterns: RESTful APIs unless specified otherwise
|
||||
|
||||
### Success Criteria Guidelines
|
||||
|
||||
Success criteria must be:
|
||||
|
||||
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
|
||||
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
|
||||
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
|
||||
4. **Verifiable**: Can be tested/validated without knowing implementation details
|
||||
|
||||
**Good examples**:
|
||||
|
||||
- "Users can complete checkout in under 3 minutes"
|
||||
- "System supports 10,000 concurrent users"
|
||||
- "95% of searches return results in under 1 second"
|
||||
- "Task completion rate improves by 40%"
|
||||
|
||||
**Bad examples** (implementation-focused):
|
||||
|
||||
- "API response time is under 200ms" (too technical, use "Users see results instantly")
|
||||
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
|
||||
- "React components render efficiently" (framework-specific)
|
||||
- "Redis cache hit rate above 80%" (technology-specific)
|
||||
@ -1,137 +0,0 @@
|
||||
---
|
||||
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
|
||||
handoffs:
|
||||
- label: Analyze For Consistency
|
||||
agent: speckit.analyze
|
||||
prompt: Run a project analysis for consistency
|
||||
send: true
|
||||
- label: Implement Project
|
||||
agent: speckit.implement
|
||||
prompt: Start the implementation in phases
|
||||
send: true
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
- **Optional**: data-model.md (entities), contracts/ (API endpoints), research.md (decisions), quickstart.md (test scenarios)
|
||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||
|
||||
3. **Execute task generation workflow**:
|
||||
- Load plan.md and extract tech stack, libraries, project structure
|
||||
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
|
||||
- If data-model.md exists: Extract entities and map to user stories
|
||||
- If contracts/ exists: Map endpoints to user stories
|
||||
- If research.md exists: Extract decisions for setup tasks
|
||||
- Generate tasks organized by user story (see Task Generation Rules below)
|
||||
- Generate dependency graph showing user story completion order
|
||||
- Create parallel execution examples per user story
|
||||
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||
|
||||
4. **Generate tasks.md**: Use `.specify/templates/tasks-template.md` as structure, fill with:
|
||||
- Correct feature name from plan.md
|
||||
- Phase 1: Setup tasks (project initialization)
|
||||
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||
- Phase 3+: One phase per user story (in priority order from spec.md)
|
||||
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
|
||||
- Final Phase: Polish & cross-cutting concerns
|
||||
- All tasks must follow the strict checklist format (see Task Generation Rules below)
|
||||
- Clear file paths for each task
|
||||
- Dependencies section showing story completion order
|
||||
- Parallel execution examples per story
|
||||
- Implementation strategy section (MVP first, incremental delivery)
|
||||
|
||||
5. **Report**: Output path to generated tasks.md and summary:
|
||||
- Total task count
|
||||
- Task count per user story
|
||||
- Parallel opportunities identified
|
||||
- Independent test criteria for each story
|
||||
- Suggested MVP scope (typically just User Story 1)
|
||||
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||
|
||||
Context for task generation: $ARGUMENTS
|
||||
|
||||
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
|
||||
|
||||
## Task Generation Rules
|
||||
|
||||
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
|
||||
|
||||
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
|
||||
|
||||
### Checklist Format (REQUIRED)
|
||||
|
||||
Every task MUST strictly follow this format:
|
||||
|
||||
```text
|
||||
- [ ] [TaskID] [P?] [Story?] Description with file path
|
||||
```
|
||||
|
||||
**Format Components**:
|
||||
|
||||
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
|
||||
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
|
||||
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
|
||||
4. **[Story] label**: REQUIRED for user story phase tasks only
|
||||
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
|
||||
- Setup phase: NO story label
|
||||
- Foundational phase: NO story label
|
||||
- User Story phases: MUST have story label
|
||||
- Polish phase: NO story label
|
||||
5. **Description**: Clear action with exact file path
|
||||
|
||||
**Examples**:
|
||||
|
||||
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
|
||||
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
|
||||
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
|
||||
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
|
||||
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
|
||||
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
|
||||
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
|
||||
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
|
||||
|
||||
### Task Organization
|
||||
|
||||
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
|
||||
- Each user story (P1, P2, P3...) gets its own phase
|
||||
- Map all related components to their story:
|
||||
- Models needed for that story
|
||||
- Services needed for that story
|
||||
- Endpoints/UI needed for that story
|
||||
- If tests requested: Tests specific to that story
|
||||
- Mark story dependencies (most stories should be independent)
|
||||
|
||||
2. **From Contracts**:
|
||||
- Map each contract/endpoint → to the user story it serves
|
||||
- If tests requested: Each contract → contract test task [P] before implementation in that story's phase
|
||||
|
||||
3. **From Data Model**:
|
||||
- Map each entity to the user story(ies) that need it
|
||||
- If entity serves multiple stories: Put in earliest story or Setup phase
|
||||
- Relationships → service layer tasks in appropriate story phase
|
||||
|
||||
4. **From Setup/Infrastructure**:
|
||||
- Shared infrastructure → Setup phase (Phase 1)
|
||||
- Foundational/blocking tasks → Foundational phase (Phase 2)
|
||||
- Story-specific setup → within that story's phase
|
||||
|
||||
### Phase Structure
|
||||
|
||||
- **Phase 1**: Setup (project initialization)
|
||||
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
|
||||
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
|
||||
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
|
||||
- Each phase should be a complete, independently testable increment
|
||||
- **Final Phase**: Polish & Cross-Cutting Concerns
|
||||
@ -1,30 +0,0 @@
|
||||
---
|
||||
description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts.
|
||||
tools: ['github/github-mcp-server/issue_write']
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
1. From the executed script, extract the path to **tasks**.
|
||||
1. Get the Git remote by running:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
|
||||
|
||||
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote.
|
||||
|
||||
> [!CAUTION]
|
||||
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
|
||||
@ -1,32 +0,0 @@
|
||||
name: Build & Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: cloudarix
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.cloudarix.de \
|
||||
-u "${{ secrets.REGISTRY_USER }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build \
|
||||
-t git.cloudarix.de/ahmido/lms:${{ github.sha }} \
|
||||
-t git.cloudarix.de/ahmido/lms:latest \
|
||||
.
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push git.cloudarix.de/ahmido/lms:${{ github.sha }}
|
||||
docker push git.cloudarix.de/ahmido/lms:latest
|
||||
162
Dockerfile.old
162
Dockerfile.old
@ -1,162 +0,0 @@
|
||||
# Multi-stage Dockerfile for Laravel Mentor LMS
|
||||
FROM node:22-alpine AS node-builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install Node dependencies (including dev dependencies for build)
|
||||
RUN npm ci
|
||||
|
||||
# Copy all source files for build (Vite may need access to various files)
|
||||
COPY . .
|
||||
|
||||
# Build assets
|
||||
RUN npm run build
|
||||
|
||||
# PHP Stage
|
||||
FROM php:8.3-fpm-alpine AS php-base
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
curl \
|
||||
freetype-dev \
|
||||
g++ \
|
||||
gcc \
|
||||
git \
|
||||
icu-dev \
|
||||
jpeg-dev \
|
||||
libpng-dev \
|
||||
libzip-dev \
|
||||
make \
|
||||
mysql-client \
|
||||
nginx \
|
||||
oniguruma-dev \
|
||||
redis \
|
||||
supervisor \
|
||||
unzip \
|
||||
zip \
|
||||
autoconf \
|
||||
pkgconf \
|
||||
linux-headers \
|
||||
openssl \
|
||||
ca-certificates
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||
&& docker-php-ext-install -j$(nproc) \
|
||||
bcmath \
|
||||
exif \
|
||||
gd \
|
||||
intl \
|
||||
mbstring \
|
||||
opcache \
|
||||
pcntl \
|
||||
pdo \
|
||||
pdo_mysql \
|
||||
zip
|
||||
|
||||
# Install Redis extension with proper configuration
|
||||
RUN pecl config-set php_ini /usr/local/etc/php/php.ini \
|
||||
&& pecl install redis \
|
||||
&& docker-php-ext-enable redis
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# Copy PHP configuration
|
||||
COPY docker/php/php.ini /usr/local/etc/php/conf.d/99-custom.ini
|
||||
COPY docker/php/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf
|
||||
|
||||
# Copy Nginx configuration
|
||||
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/nginx/default.conf /etc/nginx/http.d/default.conf
|
||||
|
||||
# Copy Supervisor configuration
|
||||
COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
# Copy application code
|
||||
COPY --chown=www-data:www-data . .
|
||||
|
||||
# Remove conflicting file causing PSR-4 autoloading errors
|
||||
RUN rm -f Modules/Exam/app/Services/Exam-ResourceService.php
|
||||
|
||||
# Copy built assets from node stage
|
||||
COPY --from=node-builder --chown=www-data:www-data /app/public/build ./public/build
|
||||
|
||||
# Copy environment file from docker.env to .env if .env doesn't exist
|
||||
# RUN if [ ! -f .env ]; then cp docker.env .env; fi
|
||||
|
||||
# Generate APP_KEY if not present (required for some Composer operations)
|
||||
RUN if ! grep -q "APP_KEY=base64:" .env; then \
|
||||
php artisan key:generate --no-interaction || \
|
||||
echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env; \
|
||||
fi
|
||||
|
||||
# Create necessary directories before Composer install
|
||||
RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views \
|
||||
&& mkdir -p bootstrap/cache
|
||||
|
||||
# Debug: Check Composer and PHP setup
|
||||
RUN composer --version && php --version
|
||||
|
||||
# Configure Composer
|
||||
ENV COMPOSER_ALLOW_SUPERUSER=1
|
||||
ENV COMPOSER_NO_INTERACTION=1
|
||||
ENV COMPOSER_MEMORY_LIMIT=2G
|
||||
|
||||
# Update composer.lock to match PHP version and install dependencies
|
||||
# Update symfony packages to resolve security advisories
|
||||
RUN composer update symfony/http-foundation symfony/http-kernel --with-all-dependencies --no-dev --optimize-autoloader --no-interaction --verbose \
|
||||
&& composer install --no-dev --optimize-autoloader --no-interaction --verbose
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R www-data:www-data /var/www/html \
|
||||
&& chmod -R 755 /var/www/html/storage \
|
||||
&& chmod -R 755 /var/www/html/bootstrap/cache
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /var/www/html/storage/logs \
|
||||
&& mkdir -p /var/www/html/storage/framework/cache \
|
||||
&& mkdir -p /var/www/html/storage/framework/sessions \
|
||||
&& mkdir -p /var/www/html/storage/framework/views \
|
||||
&& mkdir -p /var/www/html/storage/app/public \
|
||||
&& mkdir -p /var/log/supervisor \
|
||||
&& mkdir -p /var/log/nginx \
|
||||
&& mkdir -p /var/run \
|
||||
&& chown -R www-data:www-data /var/www/html/storage
|
||||
|
||||
# Fix Nginx temp directories permissions for file uploads
|
||||
RUN chown -R www-data:www-data /var/lib/nginx/ \
|
||||
&& chmod -R 755 /var/lib/nginx/tmp
|
||||
|
||||
# Create storage symlink and test files
|
||||
RUN php artisan storage:link || echo "Storage link creation failed, but continuing..." \
|
||||
&& echo '<?php echo "Laravel Test: " . date("Y-m-d H:i:s") . "<br>PHP Version: " . phpversion(); ?>' > /var/www/html/public/test.php \
|
||||
&& echo 'healthy' > /var/www/html/public/health \
|
||||
&& echo '<?php echo "Installer should redirect to: /install/step-1<br>"; echo "Routes available: "; echo "<pre>"; print_r(glob("/var/www/html/Modules/*/routes/*.php")); echo "</pre>"; ?>' > /var/www/html/public/debug.php \
|
||||
&& chmod 644 /var/www/html/public/test.php /var/www/html/public/health /var/www/html/public/debug.php
|
||||
|
||||
# Ensure the app is NOT marked as installed (for first-time setup)
|
||||
# RUN rm -f /var/www/html/public/installed
|
||||
|
||||
# Clear any cached routes/config that might interfere with installation
|
||||
RUN php artisan config:clear || true \
|
||||
&& php artisan route:clear || true \
|
||||
&& php artisan view:clear || true
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 80
|
||||
|
||||
# Health check (more lenient)
|
||||
HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=5 \
|
||||
CMD curl -f http://localhost:80/health || curl -f http://localhost:80/test.php || exit 1
|
||||
|
||||
# Start supervisor
|
||||
CMD ["sh", "-c", "mkdir -p storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache storage/app && touch storage/installed storage/app/installed public/installed && chown -R www-data:www-data storage bootstrap/cache public/installed && chmod -R 775 storage bootstrap/cache && if [ -f .env ]; then chmod 666 .env || true; fi && php artisan migrate --force && php artisan optimize:clear && /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf"]
|
||||
@ -20,17 +20,17 @@ class CertificateController extends Controller
|
||||
if (!$activeTemplate) {
|
||||
$activeTemplate = [
|
||||
'id' => 0,
|
||||
'name' => 'Standardvorlage',
|
||||
'name' => 'Default Template',
|
||||
'logo_path' => null,
|
||||
'template_data' => [
|
||||
'primaryColor' => '#3730a3',
|
||||
'secondaryColor' => '#4b5563',
|
||||
'backgroundColor' => '#dbeafe',
|
||||
'borderColor' => '#f59e0b',
|
||||
'titleText' => 'Zertifikat über den Abschluss',
|
||||
'descriptionText' => 'Dieses Zertifikat wird feierlich überreicht an',
|
||||
'completionText' => 'für den erfolgreichen Abschluss des Kurses',
|
||||
'footerText' => 'Offizielles Zertifikat',
|
||||
'titleText' => 'Certificate of Completion',
|
||||
'descriptionText' => 'This certificate is proudly presented to',
|
||||
'completionText' => 'for successfully completing the course',
|
||||
'footerText' => 'Authorized Certificate',
|
||||
'fontFamily' => 'serif',
|
||||
],
|
||||
'is_active' => false,
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Certificate\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CertificationSettingsController extends Controller
|
||||
{
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'show_course_certificate' => ['sometimes', 'boolean'],
|
||||
'show_course_marksheet' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$system = Setting::where('type', 'system')->firstOrFail();
|
||||
$fields = $system->fields ?? [];
|
||||
|
||||
foreach ($validated as $key => $value) {
|
||||
$fields[$key] = (bool) $value;
|
||||
}
|
||||
|
||||
$system->update(['fields' => $fields]);
|
||||
|
||||
return back()->with('success', 'Certification settings updated successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,14 +25,9 @@ class CertificateService extends MediaService
|
||||
{
|
||||
$template = CertificateTemplate::findOrFail($id);
|
||||
|
||||
$updateData = $data;
|
||||
unset($updateData['logo']);
|
||||
|
||||
$template->update($updateData);
|
||||
|
||||
if (array_key_exists('logo', $data) && $data['logo']) {
|
||||
$template->update([
|
||||
'logo_path' => $this->addNewDeletePrev($template, $data['logo'], 'certificates_logo'),
|
||||
'logo_path' => $this->addNewDeletePrev($template, $data['logo'], 'certificates_logo')
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -23,10 +23,10 @@ class CertificateTemplateSeeder extends Seeder
|
||||
'secondaryColor' => '#475569',
|
||||
'backgroundColor' => '#eff6ff',
|
||||
'borderColor' => '#2563eb',
|
||||
'titleText' => 'Leistungszertifikat',
|
||||
'descriptionText' => 'Dieses Zertifikat wird feierlich überreicht an',
|
||||
'completionText' => 'für den erfolgreichen Abschluss des Kurses',
|
||||
'footerText' => 'Offiziell beglaubigt',
|
||||
'titleText' => 'Certificate of Achievement',
|
||||
'descriptionText' => 'This certificate is proudly presented to',
|
||||
'completionText' => 'for successfully completing the course',
|
||||
'footerText' => 'Authorized and Certified',
|
||||
'fontFamily' => 'sans-serif',
|
||||
],
|
||||
'is_active' => true,
|
||||
@ -44,10 +44,10 @@ class CertificateTemplateSeeder extends Seeder
|
||||
'secondaryColor' => '#1f2937',
|
||||
'backgroundColor' => '#d1fae5',
|
||||
'borderColor' => '#10b981',
|
||||
'titleText' => 'Exzellenzzertifikat',
|
||||
'descriptionText' => 'Hiermit wird bescheinigt, dass',
|
||||
'completionText' => 'hat herausragende Leistungen in gezeigt',
|
||||
'footerText' => 'Herzlichen Glückwunsch zu Ihrer Leistung',
|
||||
'titleText' => 'Certificate of Excellence',
|
||||
'descriptionText' => 'This is to certify that',
|
||||
'completionText' => 'has demonstrated outstanding achievement in',
|
||||
'footerText' => 'Congratulations on your accomplishment',
|
||||
'fontFamily' => 'serif',
|
||||
],
|
||||
'is_active' => false,
|
||||
@ -65,10 +65,10 @@ class CertificateTemplateSeeder extends Seeder
|
||||
'secondaryColor' => '#374151',
|
||||
'backgroundColor' => '#fae8ff',
|
||||
'borderColor' => '#c026d3',
|
||||
'titleText' => 'Zertifikat über den Abschluss',
|
||||
'descriptionText' => 'Dieses renommierte Zertifikat wird verliehen an',
|
||||
'completionText' => 'für außergewöhnliches Engagement und erfolgreichen Abschluss von',
|
||||
'footerText' => 'Exzellenz im Lernen',
|
||||
'titleText' => 'Certificate of Completion',
|
||||
'descriptionText' => 'This prestigious certificate is awarded to',
|
||||
'completionText' => 'for exceptional dedication and successful completion of',
|
||||
'footerText' => 'Excellence in Learning',
|
||||
'fontFamily' => 'cursive',
|
||||
],
|
||||
'is_active' => false,
|
||||
@ -86,10 +86,10 @@ class CertificateTemplateSeeder extends Seeder
|
||||
'secondaryColor' => '#1f2937',
|
||||
'backgroundColor' => '#fef2f2',
|
||||
'borderColor' => '#ef4444',
|
||||
'titleText' => 'Zertifikat für herausragende Prüfungsleistung',
|
||||
'descriptionText' => 'Dieses Zertifikat wird feierlich überreicht an',
|
||||
'completionText' => 'für herausragende Leistungen in der Prüfung',
|
||||
'footerText' => 'Offizielles Prüfungszertifikat',
|
||||
'titleText' => 'Certificate of Examination Excellence',
|
||||
'descriptionText' => 'This certificate is proudly presented to',
|
||||
'completionText' => 'for outstanding performance in the examination',
|
||||
'footerText' => 'Authorized Examination Certificate',
|
||||
'fontFamily' => 'sans-serif',
|
||||
],
|
||||
'is_active' => true,
|
||||
@ -107,10 +107,10 @@ class CertificateTemplateSeeder extends Seeder
|
||||
'secondaryColor' => '#374151',
|
||||
'backgroundColor' => '#fff7ed',
|
||||
'borderColor' => '#f97316',
|
||||
'titleText' => 'Zertifikat für Bewertungserfolg',
|
||||
'descriptionText' => 'Hiermit wird bescheinigt, dass',
|
||||
'completionText' => 'hat die Bewertung mit Auszeichnung bestanden',
|
||||
'footerText' => 'Verifiziertes Bewertungszertifikat',
|
||||
'titleText' => 'Certificate of Assessment Achievement',
|
||||
'descriptionText' => 'This is to certify that',
|
||||
'completionText' => 'has successfully passed the assessment with distinction',
|
||||
'footerText' => 'Verified Assessment Certificate',
|
||||
'fontFamily' => 'serif',
|
||||
],
|
||||
'is_active' => false,
|
||||
@ -128,10 +128,10 @@ class CertificateTemplateSeeder extends Seeder
|
||||
'secondaryColor' => '#1f2937',
|
||||
'backgroundColor' => '#f0fdfa',
|
||||
'borderColor' => '#14b8a6',
|
||||
'titleText' => 'Zertifikat für herausragende Testergebnisse',
|
||||
'descriptionText' => 'Dieses renommierte Zertifikat wird verliehen an',
|
||||
'completionText' => 'für außergewöhnliche Leistungen im Test',
|
||||
'footerText' => 'Zertifikat für herausragende Testergebnisse',
|
||||
'titleText' => 'Certificate of Test Excellence',
|
||||
'descriptionText' => 'This prestigious certificate is awarded to',
|
||||
'completionText' => 'for exceptional performance in the test',
|
||||
'footerText' => 'Certified Test Achievement',
|
||||
'fontFamily' => 'cursive',
|
||||
],
|
||||
'is_active' => false,
|
||||
|
||||
@ -2,15 +2,12 @@
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Certificate\Http\Controllers\CertificateController;
|
||||
use Modules\Certificate\Http\Controllers\CertificationSettingsController;
|
||||
use Modules\Certificate\Http\Controllers\CertificateTemplateController;
|
||||
use Modules\Certificate\Http\Controllers\MarksheetTemplateController;
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
// Admin routes for managing certificate templates
|
||||
Route::middleware(['role:admin'])->prefix('dashboard/certification')->group(function () {
|
||||
Route::post('settings', [CertificationSettingsController::class, 'update'])->name('certification.settings.update');
|
||||
|
||||
Route::resource('certificate', CertificateTemplateController::class)->except(['show', 'create', 'update'])->names('certificate.templates');
|
||||
Route::get('certificate/create-certificate', [CertificateTemplateController::class, 'create'])->name('certificate.templates.create');
|
||||
Route::post('certificate/{id}', [CertificateTemplateController::class, 'update'])->name('certificate.templates.update');
|
||||
|
||||
@ -24,7 +24,7 @@ class ExamRequest extends FormRequest
|
||||
'duration_minutes' => request('duration_minutes') ? (int) request('duration_minutes') : 0,
|
||||
'pass_mark' => request('pass_mark') ? (float) request('pass_mark') : null,
|
||||
'total_marks' => request('total_marks') ? (float) request('total_marks') : null,
|
||||
'max_attempts' => request()->has('max_attempts') ? (int) request('max_attempts') : 0,
|
||||
'max_attempts' => request('max_attempts') ? (int) request('max_attempts') : null,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -59,7 +59,7 @@ class ExamRequest extends FormRequest
|
||||
'duration_minutes' => 'required|integer|min:0|max:59',
|
||||
'pass_mark' => 'required|numeric|min:0|max:100',
|
||||
'total_marks' => 'required|numeric|min:1',
|
||||
'max_attempts' => 'required|integer|min:0',
|
||||
'max_attempts' => 'required|integer|min:1',
|
||||
|
||||
// Status & Level
|
||||
'status' => 'nullable|string|in:draft,published,archived',
|
||||
|
||||
@ -19,7 +19,7 @@ class UpdateExamRequest extends FormRequest
|
||||
'duration_minutes' => request('duration_minutes') ? (int) request('duration_minutes') : 0,
|
||||
'pass_mark' => request('pass_mark') ? (float) request('pass_mark') : null,
|
||||
'total_marks' => request('total_marks') ? (float) request('total_marks') : null,
|
||||
'max_attempts' => request()->has('max_attempts') ? (int) request('max_attempts') : 0,
|
||||
'max_attempts' => request('max_attempts') ? (int) request('max_attempts') : null,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -103,7 +103,7 @@ class UpdateExamRequest extends FormRequest
|
||||
'duration_minutes' => 'required|integer|min:0|max:59',
|
||||
'pass_mark' => 'required|numeric|min:0|max:100',
|
||||
'total_marks' => 'required|numeric|min:1',
|
||||
'max_attempts' => 'required|integer|min:0',
|
||||
'max_attempts' => 'required|integer|min:1',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -19,9 +19,7 @@ class ExamAttemptService
|
||||
->where('exam_id', $exam->id)
|
||||
->count();
|
||||
|
||||
$hasAttemptLimit = $exam->max_attempts > 0;
|
||||
|
||||
if ($hasAttemptLimit && $previousAttempts >= $exam->max_attempts) {
|
||||
if ($previousAttempts >= $exam->max_attempts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -112,9 +112,7 @@ class ExamEnrollmentService extends MediaService
|
||||
'enrollment' => $enrollment,
|
||||
'is_active' => $enrollment->isActive(),
|
||||
'attempts_used' => $attempts->count(),
|
||||
'attempts_remaining' => $exam->max_attempts > 0
|
||||
? max(0, $exam->max_attempts - $attempts->count())
|
||||
: null,
|
||||
'attempts_remaining' => max(0, $exam->max_attempts - $attempts->count()),
|
||||
'completed_attempts' => $completedAttempts,
|
||||
'best_score' => $bestScore,
|
||||
'has_passed' => $hasPassed,
|
||||
|
||||
@ -54,13 +54,6 @@ class LanguageController extends Controller
|
||||
return back()->with('success', "Language deleted successfully");
|
||||
}
|
||||
|
||||
public function sync(string $local)
|
||||
{
|
||||
$this->languageService->syncLanguageFromFiles($local);
|
||||
|
||||
return back()->with('success', "Language synced from files successfully");
|
||||
}
|
||||
|
||||
public function edit_property(string $id)
|
||||
{
|
||||
$property = LanguageProperty::where('id', $id)->with(['language:id,code'])->firstOrFail();
|
||||
@ -70,13 +63,9 @@ class LanguageController extends Controller
|
||||
|
||||
public function update_property(Request $request, $id)
|
||||
{
|
||||
$property = LanguageProperty::with('language:id,code')->findOrFail($id);
|
||||
$property = LanguageProperty::findOrFail($id);
|
||||
$property->update(['properties' => $request->all()]);
|
||||
|
||||
if ($property->language) {
|
||||
$this->languageService->forgetLanguageCache($property->language->code);
|
||||
}
|
||||
|
||||
return back()->with('success', $property->name . ' translation successfully updated');
|
||||
}
|
||||
|
||||
@ -89,10 +78,8 @@ class LanguageController extends Controller
|
||||
|
||||
public function change_lang(Request $request)
|
||||
{
|
||||
$locale = $request->locale;
|
||||
$this->languageService->forgetLanguageCache($locale);
|
||||
|
||||
$cookie = Cookie::forever('locale', $locale);
|
||||
Cache::forget($this->languageService->cacheKey);
|
||||
$cookie = Cookie::forever('locale', $request->locale);
|
||||
|
||||
return back()->withCookie($cookie);
|
||||
}
|
||||
|
||||
@ -3,11 +3,11 @@
|
||||
namespace Modules\Language\Services;
|
||||
|
||||
use App\Services\MediaService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Modules\Language\Models\Language;
|
||||
use Modules\Language\Models\LanguageProperty;
|
||||
use Exception;
|
||||
@ -15,7 +15,6 @@ use Exception;
|
||||
class LanguageService extends MediaService
|
||||
{
|
||||
public string $cacheKey;
|
||||
protected array $groupNames = ['auth', 'button', 'common', 'dashboard', 'frontend', 'input', 'settings', 'table'];
|
||||
|
||||
public function __construct(Request $request)
|
||||
{
|
||||
@ -28,181 +27,54 @@ class LanguageService extends MediaService
|
||||
$langDir = $langPath . "/" . $data['code'];
|
||||
$appLangPath = storage_path('app/lang/default');
|
||||
|
||||
if (is_dir($langDir)) {
|
||||
throw new Exception("Language already exist");
|
||||
}
|
||||
|
||||
$language = Language::create($data);
|
||||
$this->syncLanguagePropertiesFromFiles($language);
|
||||
$groups = [
|
||||
'auth' => require storage_path('app/lang/groups/auth.php'),
|
||||
'button' => require storage_path('app/lang/groups/button.php'),
|
||||
'common' => require storage_path('app/lang/groups/common.php'),
|
||||
'dashboard' => require storage_path('app/lang/groups/dashboard.php'),
|
||||
'frontend' => require storage_path('app/lang/groups/frontend.php'),
|
||||
'input' => require storage_path('app/lang/groups/input.php'),
|
||||
'settings' => require storage_path('app/lang/groups/settings.php'),
|
||||
];
|
||||
|
||||
$alreadyExists = is_dir($langDir);
|
||||
$languages = Language::create($data);
|
||||
|
||||
foreach ($groups as $key => $group) {
|
||||
foreach ($group as $value) {
|
||||
LanguageProperty::create([
|
||||
...$value,
|
||||
'group' => $key,
|
||||
'language_id' => $languages->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $alreadyExists) {
|
||||
File::makeDirectory($langDir, 0777, true, true);
|
||||
File::copyDirectory($appLangPath, $langDir);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLanguage($id, $data)
|
||||
{
|
||||
Language::find($id)->update($data);
|
||||
}
|
||||
|
||||
public function syncLanguageFromFiles(string $locale): void
|
||||
{
|
||||
$language = Language::where('code', $locale)->first();
|
||||
|
||||
if (! $language) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->syncLanguagePropertiesFromFiles($language);
|
||||
$this->forgetLanguageCache($locale);
|
||||
}
|
||||
|
||||
protected function loadLanguageGroup(string $locale, string $groupName): array
|
||||
{
|
||||
$defaultGroupPath = storage_path("app/lang/groups/{$groupName}.php");
|
||||
$defaultGroups = file_exists($defaultGroupPath) ? require $defaultGroupPath : [];
|
||||
|
||||
$repoGroupPath = base_path("lang/{$locale}/groups/{$groupName}.php");
|
||||
if (file_exists($repoGroupPath)) {
|
||||
return require $repoGroupPath;
|
||||
}
|
||||
|
||||
$flatFilePath = base_path("lang/{$locale}/{$groupName}.php");
|
||||
if (file_exists($flatFilePath)) {
|
||||
$flatTranslations = require $flatFilePath;
|
||||
return $this->mergeFlatTranslations($defaultGroups, $flatTranslations, $groupName);
|
||||
}
|
||||
|
||||
return $defaultGroups;
|
||||
}
|
||||
|
||||
protected function mergeFlatTranslations(array $defaultGroups, array $flatTranslations, string $groupName): array
|
||||
{
|
||||
if (empty($defaultGroups)) {
|
||||
return [[
|
||||
'name' => ucfirst(str_replace('_', ' ', $groupName)),
|
||||
'slug' => $groupName,
|
||||
'properties' => $flatTranslations,
|
||||
]];
|
||||
}
|
||||
|
||||
$groups = [];
|
||||
|
||||
foreach ($defaultGroups as $group) {
|
||||
$properties = [];
|
||||
foreach ($group['properties'] as $key => $value) {
|
||||
if (array_key_exists($key, $flatTranslations)) {
|
||||
$properties[$key] = $flatTranslations[$key];
|
||||
unset($flatTranslations[$key]);
|
||||
} else {
|
||||
$properties[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$groups[] = [
|
||||
...$group,
|
||||
'properties' => $properties,
|
||||
];
|
||||
}
|
||||
|
||||
if (! empty($flatTranslations)) {
|
||||
$groups[] = [
|
||||
'name' => ucfirst(str_replace('_', ' ', $groupName)) . ' Extra',
|
||||
'slug' => "{$groupName}_extra",
|
||||
'properties' => $flatTranslations,
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
protected function syncLanguagePropertiesFromFiles(Language $language): void
|
||||
{
|
||||
foreach ($this->groupNames as $groupName) {
|
||||
$groups = $this->loadLanguageGroup($language->code, $groupName);
|
||||
|
||||
foreach ($groups as $group) {
|
||||
LanguageProperty::updateOrCreate(
|
||||
[
|
||||
'language_id' => $language->id,
|
||||
'group' => $groupName,
|
||||
'slug' => $group['slug'],
|
||||
],
|
||||
[
|
||||
'name' => $group['name'],
|
||||
'properties' => $group['properties'],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLanguage($id)
|
||||
{
|
||||
Language::where('is_default', true)->update(['is_default' => false]);
|
||||
Language::where('id', $id)->update(['is_default' => true]);
|
||||
}
|
||||
|
||||
protected function getCacheKey(string $locale): string
|
||||
{
|
||||
return "{$this->cacheKey}_{$locale}";
|
||||
}
|
||||
|
||||
public function forgetLanguageCache(string $locale): void
|
||||
{
|
||||
Cache::forget($this->getCacheKey($locale));
|
||||
}
|
||||
|
||||
protected function getDefaultLocale(): string
|
||||
{
|
||||
$default = Language::where('is_default', true)->value('code');
|
||||
|
||||
if ($default) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return config('app.locale', 'en');
|
||||
}
|
||||
|
||||
protected function buildTranslationsFromFiles(string $locale): array
|
||||
{
|
||||
$langPath = lang_path($locale);
|
||||
|
||||
if (! is_dir($langPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$translations = [];
|
||||
|
||||
foreach (File::allFiles($langPath) as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$group = $file->getFilenameWithoutExtension();
|
||||
$values = require $file->getPathname();
|
||||
|
||||
foreach (Arr::dot($values) as $key => $value) {
|
||||
$translations[$group . '.' . $key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $translations;
|
||||
}
|
||||
|
||||
function setLanguageProperties(string $locale): void
|
||||
{
|
||||
$cacheKey = $this->getCacheKey($locale);
|
||||
|
||||
$cached = Cache::rememberForever($cacheKey, function () use ($locale) {
|
||||
$cached = Cache::rememberForever($this->cacheKey, function () use ($locale) {
|
||||
$language = Language::where('code', $locale)
|
||||
->with('properties')
|
||||
->first();
|
||||
|
||||
if (! $language) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groups = [];
|
||||
foreach ($language->properties as $property) {
|
||||
$groups[$property->group] = array_merge($groups[$property->group] ?? [], $property->properties);
|
||||
@ -218,10 +90,6 @@ class LanguageService extends MediaService
|
||||
return $translations;
|
||||
});
|
||||
|
||||
if (empty($cached)) {
|
||||
$cached = $this->buildTranslationsFromFiles($locale);
|
||||
}
|
||||
|
||||
Lang::addLines($cached, $locale);
|
||||
}
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ Route::post('change-direction', [LanguageController::class, 'change_direction'])
|
||||
Route::middleware(['installed', 'appConfig', 'auth', 'role:admin'])->prefix('dashboard/settings')->group(function () {
|
||||
Route::resource('language', LanguageController::class)->except(['create']);
|
||||
Route::post('language/default/{id}', [LanguageController::class, 'default'])->name('language.default');
|
||||
Route::post('language/{local}/sync', [LanguageController::class, 'sync'])->name('language.sync');
|
||||
|
||||
Route::get('/language/property/{property}', [LanguageController::class, 'edit_property'])->name('language.property.edit');
|
||||
Route::put('/language/property/{property}', [LanguageController::class, 'update_property'])->name('language.property.update');
|
||||
|
||||
@ -34,7 +34,7 @@ class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
$this->accountService->changeEmail($request->validated(), Auth::user()->id);
|
||||
|
||||
return back()->with('success', 'Wir haben einen Bestätigungslink an deine neue E-Mail-Adresse gesendet.');
|
||||
return back()->with('success', 'We have sent a email verification link to your new email account.');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -45,7 +45,7 @@ class EmailVerificationNotificationController extends Controller
|
||||
$user = Auth::user();
|
||||
$saved = $this->accountService->saveChangedEmail($request->token, $user->id);
|
||||
$flash = $saved ? 'success' : 'error';
|
||||
$message = $saved ? 'Die E‑Mail wurde erfolgreich geändert.' : 'Der Bestätigungs-Token stimmt nicht oder ist abgelaufen.';
|
||||
$message = $saved ? "New email successfully changed." : "Verification token didn't match or expire.";
|
||||
|
||||
if ($user->role == 'student') {
|
||||
return redirect()->route('student.index', ['tab' => 'settings'])
|
||||
|
||||
@ -48,7 +48,7 @@ class PasswordResetLinkController extends Controller
|
||||
$user->notify(new ResetPasswordNotification($token));
|
||||
}
|
||||
|
||||
return back()->with('status', __('Ein Link zum Zurücksetzen wird gesendet, wenn das Konto existiert.'));
|
||||
return back()->with('status', __('A reset link will be sent if the account exists.'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -4,52 +4,39 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\UserType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the user's email address as verified via signed link.
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
$user = User::find($request->route('id'));
|
||||
$adminDashboard = route('dashboard', absolute: false) . '?verified=1';
|
||||
$studentDashboard = route('student.index', ['tab' => 'courses'], absolute: false) . '?verified=1';
|
||||
|
||||
if (!$user) {
|
||||
abort(404);
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
if ($request->user()->role === UserType::STUDENT->value) {
|
||||
return redirect()->intended($studentDashboard);
|
||||
} else {
|
||||
return redirect()->intended($adminDashboard);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hash_equals(sha1($user->getEmailForVerification()), (string) $request->route('hash'))) {
|
||||
abort(403);
|
||||
}
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */
|
||||
$user = $request->user();
|
||||
|
||||
if (!$user->hasVerifiedEmail()) {
|
||||
$user->markEmailAsVerified();
|
||||
event(new Verified($user));
|
||||
}
|
||||
|
||||
if ($request->boolean('invite')) {
|
||||
$token = Password::createToken($user);
|
||||
|
||||
return redirect()->route('password.reset', [
|
||||
'token' => $token,
|
||||
'email' => $user->email,
|
||||
]);
|
||||
if ($request->user()->role === UserType::STUDENT->value) {
|
||||
return redirect()->intended($studentDashboard);
|
||||
} else {
|
||||
return redirect()->intended($adminDashboard);
|
||||
}
|
||||
|
||||
if (Auth::check()) {
|
||||
$redirect = Auth::user()->role === UserType::STUDENT->value
|
||||
? route('student.index', ['tab' => 'courses'], absolute: false)
|
||||
: route('dashboard', absolute: false);
|
||||
|
||||
return redirect()->intended($redirect . '?verified=1');
|
||||
}
|
||||
|
||||
return redirect()->route('login')->with('status', __('auth.email_verified_successfully'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,16 +26,9 @@ class CourseCartController extends Controller
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$system = app('system_settings');
|
||||
if (($system?->fields['show_course_cart'] ?? true) === false) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$coupon = null;
|
||||
$couponCode = $request->input('coupon');
|
||||
|
||||
if (!empty($couponCode)) {
|
||||
$coupon = $this->couponService->getCoupon($couponCode);
|
||||
if ($request->has('coupon')) {
|
||||
$coupon = $this->couponService->getCoupon($request->coupon);
|
||||
|
||||
if (!$coupon) {
|
||||
return back()->with('error', 'This coupon is not valid.');
|
||||
@ -61,11 +54,6 @@ class CourseCartController extends Controller
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$system = app('system_settings');
|
||||
if (($system?->fields['show_course_cart'] ?? true) === false) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->cartService->addToCart(Auth::user()->id, $request->course_id);
|
||||
|
||||
return redirect(route('course-cart.index'))->with('success', 'Course added to cart successfully.');
|
||||
@ -76,11 +64,6 @@ class CourseCartController extends Controller
|
||||
*/
|
||||
public function update(Request $request, CourseCart $courseCart)
|
||||
{
|
||||
$system = app('system_settings');
|
||||
if (($system?->fields['show_course_cart'] ?? true) === false) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->cartService->clearCart(Auth::user()->id);
|
||||
|
||||
return back()->with('success', 'Cart updated successfully.');
|
||||
@ -91,11 +74,6 @@ class CourseCartController extends Controller
|
||||
*/
|
||||
public function destroy($course_cart)
|
||||
{
|
||||
$system = app('system_settings');
|
||||
if (($system?->fields['show_course_cart'] ?? true) === false) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->cartService->removeFromCart(Auth::user()->id, $course_cart);
|
||||
|
||||
return back()->with('success', 'Course removed from cart successfully.');
|
||||
|
||||
@ -20,7 +20,7 @@ class CourseForumController extends Controller
|
||||
{
|
||||
$this->forumService->createForum($request->validated());
|
||||
|
||||
return back()->with('success', 'Forum erfolgreich erstellt.');
|
||||
return back()->with('success', 'Forum created successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -30,7 +30,7 @@ class CourseForumController extends Controller
|
||||
{
|
||||
$this->forumService->updateForum($id, $request->validated());
|
||||
|
||||
return back()->with('success', 'Forum erfolgreich aktualisiert.');
|
||||
return back()->with('success', 'Forum updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -40,6 +40,6 @@ class CourseForumController extends Controller
|
||||
{
|
||||
$this->forumService->deleteForum($id);
|
||||
|
||||
return back()->with('success', 'Forum erfolgreich gelöscht.');
|
||||
return back()->with('success', 'Forum deleted successfully');
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,74 +10,11 @@ use App\Services\Course\CourseReviewService;
|
||||
use App\Services\Course\CourseService;
|
||||
use App\Services\Course\CourseSectionService;
|
||||
use App\Services\LiveClass\ZoomLiveService;
|
||||
use App\Models\Course\Course;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PlayerController extends Controller
|
||||
{
|
||||
private function findCourseContentByIdAndType(Course $course, string $contentId, string $contentType): ?array
|
||||
{
|
||||
if (!in_array($contentType, ['lesson', 'quiz'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($course->sections as $section) {
|
||||
if ($contentType === 'lesson') {
|
||||
foreach ($section->section_lessons as $lesson) {
|
||||
if ((string) $lesson->id === (string) $contentId) {
|
||||
return ['type' => 'lesson', 'id' => $lesson->id];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($section->section_quizzes as $quiz) {
|
||||
if ((string) $quiz->id === (string) $contentId) {
|
||||
return ['type' => 'quiz', 'id' => $quiz->id];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function findCourseContentById(Course $course, string $contentId): ?array
|
||||
{
|
||||
foreach ($course->sections as $section) {
|
||||
foreach ($section->section_lessons as $lesson) {
|
||||
if ((string) $lesson->id === (string) $contentId) {
|
||||
return ['type' => 'lesson', 'id' => $lesson->id];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($section->section_quizzes as $quiz) {
|
||||
if ((string) $quiz->id === (string) $contentId) {
|
||||
return ['type' => 'quiz', 'id' => $quiz->id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function findFirstCourseContent(Course $course): ?array
|
||||
{
|
||||
foreach ($course->sections as $section) {
|
||||
$lesson = $section->section_lessons->first();
|
||||
if ($lesson) {
|
||||
return ['type' => 'lesson', 'id' => $lesson->id];
|
||||
}
|
||||
|
||||
$quiz = $section->section_quizzes->first();
|
||||
if ($quiz) {
|
||||
return ['type' => 'quiz', 'id' => $quiz->id];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
protected CourseService $courseService,
|
||||
protected CoursePlayerService $coursePlay,
|
||||
@ -99,10 +36,6 @@ class PlayerController extends Controller
|
||||
$user = Auth::user();
|
||||
$watchHistory = $this->sectionService->initWatchHistory($request->course_id, 'lesson', $user->id);
|
||||
|
||||
if (! $watchHistory) {
|
||||
return back()->with('error', 'Dieser Kurs hat noch keine Lektionen oder Quizze.');
|
||||
}
|
||||
|
||||
return redirect()->route('course.player', [
|
||||
'type' => $watchHistory->current_watching_type,
|
||||
'watch_history' => $watchHistory->id,
|
||||
@ -115,64 +48,29 @@ class PlayerController extends Controller
|
||||
try {
|
||||
$user = Auth::user();
|
||||
|
||||
$watching_id = $lesson_id ?: $watch_history->current_watching_id;
|
||||
$watching_type = in_array($type, ['lesson', 'quiz'], true) ? $type : ($watch_history->current_watching_type ?? 'lesson');
|
||||
$section_id = $watch_history->current_section_id;
|
||||
$watching_id = $lesson_id ?? $watch_history->current_watching_id;
|
||||
$watching_type = $type; // Use the route parameter, not old watch history
|
||||
|
||||
$course = $this->courseService->getUserCourseById($watch_history->course_id, $user);
|
||||
|
||||
if (! $course) {
|
||||
return redirect()
|
||||
->route('category.courses', ['category' => 'all'])
|
||||
->with('error', 'Der Kurs konnte nicht gefunden werden.');
|
||||
}
|
||||
|
||||
// Fix wrong `type`/`lesson_id` combinations by resolving the content inside the course.
|
||||
$resolved = $this->findCourseContentByIdAndType($course, (string) $watching_id, $watching_type);
|
||||
if (! $resolved) {
|
||||
$resolved = $this->findCourseContentById($course, (string) $watching_id);
|
||||
}
|
||||
if (! $resolved) {
|
||||
$resolved = $this->findCourseContentByIdAndType($course, (string) $watch_history->current_watching_id, (string) $watch_history->current_watching_type);
|
||||
}
|
||||
if (! $resolved) {
|
||||
$resolved = $this->findCourseContentById($course, (string) $watch_history->current_watching_id);
|
||||
}
|
||||
if (! $resolved) {
|
||||
$resolved = $this->findFirstCourseContent($course);
|
||||
}
|
||||
|
||||
if (! $resolved) {
|
||||
return redirect()
|
||||
->route('course.details', ['slug' => $course->slug, 'id' => $course->id])
|
||||
->with('error', 'Dieser Kurs hat noch keine Lektionen oder Quizze.');
|
||||
}
|
||||
|
||||
$watching_id = (string) $resolved['id'];
|
||||
$watching_type = $resolved['type'];
|
||||
|
||||
// Canonicalize URL if it doesn’t match the resolved content.
|
||||
if ($type !== $watching_type || (string) $lesson_id !== (string) $watching_id) {
|
||||
return redirect()->route('course.player', [
|
||||
'type' => $watching_type,
|
||||
'watch_history' => $watch_history->id,
|
||||
'lesson_id' => $watching_id,
|
||||
]);
|
||||
}
|
||||
|
||||
$watching = $this->coursePlay->getWatchingLesson($watching_id, $watching_type, (string) $course->id);
|
||||
$watching = $this->coursePlay->getWatchingLesson($lesson_id, $type);
|
||||
$reviews = $this->reviewService->getReviews(['course_id' => $course->id, ...$request->all()], true);
|
||||
$userReview = $this->reviewService->userReview($course->id, $user->id);
|
||||
$totalReviews = $this->reviewService->totalReviews($course->id);
|
||||
$zoomConfig = $this->zoomLiveService->zoomConfig;
|
||||
|
||||
$section = null;
|
||||
$totalContent = 0;
|
||||
|
||||
foreach ($course->sections as $courseSection) {
|
||||
$totalContent += count($courseSection->section_lessons) + count($courseSection->section_quizzes);
|
||||
|
||||
if ($courseSection->id == $section_id) {
|
||||
$section = $courseSection;
|
||||
}
|
||||
}
|
||||
|
||||
$watchHistory = $this->coursePlay->watchHistory($course, $watching_id, $watching_type, $user->id);
|
||||
$section = $course->sections->firstWhere('id', $watchHistory->current_section_id);
|
||||
|
||||
// $submissions = null;
|
||||
// if ($assignment) {
|
||||
@ -180,7 +78,7 @@ class PlayerController extends Controller
|
||||
// }
|
||||
|
||||
return Inertia::render('course-player/index', [
|
||||
'type' => $watching_type,
|
||||
'type' => $type,
|
||||
'course' => $course,
|
||||
'section' => $section,
|
||||
'reviews' => $reviews,
|
||||
@ -191,56 +89,6 @@ class PlayerController extends Controller
|
||||
'totalReviews' => $totalReviews,
|
||||
'zoomConfig' => $zoomConfig,
|
||||
]);
|
||||
} catch (ModelNotFoundException $th) {
|
||||
$user = Auth::user();
|
||||
$course = $this->courseService->getUserCourseById($watch_history->course_id, $user);
|
||||
|
||||
if ($course) {
|
||||
$watchHistoryForUser = WatchHistory::where('course_id', $course->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
$watchHistoryId = $watchHistoryForUser?->id ?? $watch_history->id;
|
||||
|
||||
$requestedId = $lesson_id;
|
||||
$requestedType = $watching_type ?? $type;
|
||||
|
||||
// If the ID exists in the course but the type is wrong, redirect with the correct type.
|
||||
$resolved = $this->findCourseContentById($course, $requestedId);
|
||||
if ($resolved && $resolved['type'] !== $requestedType) {
|
||||
return redirect()->route('course.player', [
|
||||
'type' => $resolved['type'],
|
||||
'watch_history' => $watchHistoryId,
|
||||
'lesson_id' => $resolved['id'],
|
||||
]);
|
||||
}
|
||||
|
||||
// If the watch history points to a valid item, jump there.
|
||||
$resolved = $this->findCourseContentById($course, (string) $watch_history->current_watching_id);
|
||||
if ($resolved && ((string) $resolved['id'] !== (string) $requestedId || $resolved['type'] !== $requestedType)) {
|
||||
return redirect()->route('course.player', [
|
||||
'type' => $resolved['type'],
|
||||
'watch_history' => $watchHistoryId,
|
||||
'lesson_id' => $resolved['id'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Last resort: send the user to the first available content in the course.
|
||||
$first = $this->findFirstCourseContent($course);
|
||||
if ($first && ((string) $first['id'] !== (string) $requestedId || $first['type'] !== $requestedType)) {
|
||||
return redirect()->route('course.player', [
|
||||
'type' => $first['type'],
|
||||
'watch_history' => $watchHistoryId,
|
||||
'lesson_id' => $first['id'],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('course.details', ['slug' => $course->slug, 'id' => $course->id])
|
||||
->with('error', 'Dieser Kurs hat noch keine Lektionen oder Quizze.');
|
||||
}
|
||||
|
||||
return redirect()->route('category.courses', ['category' => 'all'])->with('error', 'Der angeforderte Inhalt konnte nicht gefunden werden.');
|
||||
} catch (\Throwable $th) {
|
||||
return redirect()->route('category.courses', ['category' => 'all'])->with('error', $th->getMessage());
|
||||
}
|
||||
@ -275,7 +123,7 @@ class PlayerController extends Controller
|
||||
$watch_history->completion_date = now();
|
||||
$watch_history->save();
|
||||
|
||||
return back()->with('success', 'Kurs erfolgreich abgeschlossen.');
|
||||
return back()->with('success', 'Course completed successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -37,7 +37,7 @@ class NotificationController extends Controller
|
||||
{
|
||||
$this->notificationService->markAllAsRead();
|
||||
|
||||
return redirect()->back()->with('success', 'Alle Benachrichtigungen wurden als gelesen markiert');
|
||||
return redirect()->back()->with('success', 'All notifications marked as read');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -30,19 +30,6 @@ class StudentController extends Controller
|
||||
*/
|
||||
public function index(Request $request, string $tab)
|
||||
{
|
||||
$system = app('system_settings');
|
||||
$fields = $system?->fields ?? [];
|
||||
$showExams = $fields['show_student_exams'] ?? true;
|
||||
$showWishlist = $fields['show_student_wishlist'] ?? true;
|
||||
|
||||
if ($tab === 'exams' && !$showExams) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($tab === 'wishlist' && !$showWishlist) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($tab !== 'courses' && !$request->user()->hasVerifiedEmail()) {
|
||||
return redirect()
|
||||
->route('student.index', ['tab' => 'courses'])
|
||||
@ -60,15 +47,6 @@ class StudentController extends Controller
|
||||
|
||||
public function show_course(int $id, string $tab)
|
||||
{
|
||||
$system = app('system_settings');
|
||||
$fields = $system?->fields ?? [];
|
||||
$showCourseCertificate = $fields['show_course_certificate'] ?? true;
|
||||
$showCourseMarksheet = $fields['show_course_marksheet'] ?? $showCourseCertificate;
|
||||
|
||||
if ($tab === 'certificate' && !$showCourseCertificate && !$showCourseMarksheet) {
|
||||
return redirect()->route('student.course.show', ['id' => $id, 'tab' => 'modules']);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$course = $this->studentService->getEnrolledCourse($id, $user);
|
||||
$props = $this->studentService->getEnrolledCourseOverview($id, $tab, $user);
|
||||
@ -88,13 +66,6 @@ class StudentController extends Controller
|
||||
|
||||
public function show_exam(Request $request, int $id, string $tab)
|
||||
{
|
||||
$system = app('system_settings');
|
||||
$fields = $system?->fields ?? [];
|
||||
|
||||
if (($fields['show_student_exams'] ?? true) === false) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$exam = $this->examEnrollment->getEnrolledExam($id, $user);
|
||||
$attempts = $this->examAttempt->getExamAttempts(['exam_id' => $id, 'user_id' => $user->id]);
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreUserRequest;
|
||||
use App\Http\Requests\UpdateUserRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\UserService;
|
||||
@ -29,16 +28,6 @@ class UsersController extends Controller
|
||||
return Inertia::render('dashboard/users/index', compact('users'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created user and send an invite.
|
||||
*/
|
||||
public function store(StoreUserRequest $request): RedirectResponse
|
||||
{
|
||||
$this->userService->inviteUser($request->validated());
|
||||
|
||||
return redirect()->back()->with('success', 'User invited successfully. A verification email has been sent.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's account.
|
||||
*/
|
||||
|
||||
@ -26,19 +26,11 @@ class CheckEnroll
|
||||
}
|
||||
|
||||
$watchHistory = $request->route('watch_history');
|
||||
|
||||
if (!($watchHistory instanceof WatchHistory)) {
|
||||
$watchHistory = WatchHistory::find($watchHistory);
|
||||
}
|
||||
|
||||
if (!$watchHistory) {
|
||||
if (!WatchHistory::find($watchHistory)) {
|
||||
return back()->with('error', 'Invalid watch history');
|
||||
}
|
||||
|
||||
$course = Course::find($watchHistory->course_id);
|
||||
if (!$course) {
|
||||
return back()->with('error', 'Invalid course');
|
||||
}
|
||||
|
||||
if ($user->role == 'instructor' && $user->instructor_id == $course->instructor_id) {
|
||||
return $next($request);
|
||||
|
||||
@ -26,6 +26,6 @@ class CheckRole
|
||||
}
|
||||
|
||||
// If user doesn't have the required role
|
||||
return redirect()->back()->with('error', 'Du hast keine Berechtigung, auf diese Seite zuzugreifen.');
|
||||
return redirect()->back()->with('error', 'You do not have permission to access this page.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,11 +65,9 @@ class HandleInertiaRequests extends Middleware
|
||||
|
||||
if (Schema::hasTable('languages')) {
|
||||
$langs = Language::where('is_active', true)->orderBy('is_default', 'desc')->get();
|
||||
$defaultLang = $langs->firstWhere('is_default', true);
|
||||
$default = $defaultLang?->code ?? config('app.locale', 'en');
|
||||
$default = $langs->where('is_default', true)->first()->code;
|
||||
config(['app.locale' => $default]);
|
||||
$requestedLocale = Cookie::get('locale', $default);
|
||||
$locale = $langs->contains('code', $requestedLocale) ? $requestedLocale : $default;
|
||||
$locale = Cookie::get('locale', $default);
|
||||
App::setLocale($locale);
|
||||
|
||||
$this->languageService->setLanguageProperties($locale);
|
||||
|
||||
@ -63,7 +63,7 @@ class StoreQuizRequest extends FormRequest
|
||||
}
|
||||
}
|
||||
],
|
||||
'retake' => 'required|numeric|min:0',
|
||||
'retake' => 'required|numeric|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreUserRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
|
||||
'status' => ['required', 'integer', 'in:0,1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -62,7 +62,7 @@ class UpdateQuizRequest extends FormRequest
|
||||
}
|
||||
}
|
||||
],
|
||||
'retake' => 'required|numeric|min:0',
|
||||
'retake' => 'required|numeric|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,13 @@ class VerifyEmailNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(protected bool $invite = false) {}
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
@ -33,19 +39,13 @@ class VerifyEmailNotification extends Notification
|
||||
*/
|
||||
protected function verificationUrl($notifiable)
|
||||
{
|
||||
$params = [
|
||||
'id' => $notifiable->getKey(),
|
||||
'hash' => sha1($notifiable->getEmailForVerification()),
|
||||
];
|
||||
|
||||
if ($this->invite) {
|
||||
$params['invite'] = 1;
|
||||
}
|
||||
|
||||
return URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(config('auth.verification.expire', 5)),
|
||||
$params
|
||||
[
|
||||
'id' => $notifiable->getKey(),
|
||||
'hash' => sha1($notifiable->getEmailForVerification()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@ -57,7 +57,7 @@ class VerifyEmailNotification extends Notification
|
||||
$verificationUrl = $this->verificationUrl($notifiable);
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('E-Mail-Adresse bestätigen')
|
||||
->subject('Verify Email Address')
|
||||
->view('mail.email-verification', [
|
||||
'user' => $notifiable,
|
||||
'url' => $verificationUrl,
|
||||
|
||||
@ -66,9 +66,22 @@ class AppServiceProvider extends ServiceProvider
|
||||
return env('FRONTEND_URL') . '/reset-password?token=' . $token . '&email=' . $user->email;
|
||||
});
|
||||
|
||||
// Force HTTPS URLs in non-local envs to avoid mixed-content issues when TLS is terminated
|
||||
// in front of the app container (e.g. Dokploy/Traefik).
|
||||
if (!$this->app->runningInConsole() && !$this->app->environment('local')) {
|
||||
// Trust proxies when running behind a reverse proxy (e.g., Docker, nginx)
|
||||
// This allows Laravel to correctly detect HTTPS when behind a proxy
|
||||
if (config('app.env') !== 'local' || request()->hasHeader('X-Forwarded-Proto')) {
|
||||
request()->setTrustedProxies(
|
||||
['*'],
|
||||
\Illuminate\Http\Request::HEADER_X_FORWARDED_FOR |
|
||||
\Illuminate\Http\Request::HEADER_X_FORWARDED_HOST |
|
||||
\Illuminate\Http\Request::HEADER_X_FORWARDED_PORT |
|
||||
\Illuminate\Http\Request::HEADER_X_FORWARDED_PROTO |
|
||||
\Illuminate\Http\Request::HEADER_X_FORWARDED_PREFIX
|
||||
);
|
||||
}
|
||||
|
||||
// Force HTTPS scheme for URLs when accessed via HTTPS
|
||||
// This ensures assets load with the correct protocol
|
||||
if (request()->header('X-Forwarded-Proto') === 'https' || request()->secure()) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,12 +24,8 @@ class CourseCouponService
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getCoupon(?string $code): ?CourseCoupon
|
||||
public function getCoupon(string $code): ?CourseCoupon
|
||||
{
|
||||
if (!$code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CourseCoupon::where('code', $code)->first();
|
||||
}
|
||||
|
||||
|
||||
@ -12,12 +12,12 @@ use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class CoursePlayerService
|
||||
{
|
||||
function getWatchingLesson(string $lesson_id, string $watching_type, ?string $course_id = null): SectionLesson | SectionQuiz
|
||||
function getWatchingLesson(string $lesson_id, string $watching_type): SectionLesson | SectionQuiz
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if ($watching_type === 'lesson') {
|
||||
$query = SectionLesson::with([
|
||||
return $watching_type === 'lesson' ?
|
||||
SectionLesson::with([
|
||||
'resources',
|
||||
'forums' => function ($query) {
|
||||
$query->with([
|
||||
@ -27,27 +27,13 @@ class CoursePlayerService
|
||||
},
|
||||
]);
|
||||
},
|
||||
]);
|
||||
|
||||
if ($course_id) {
|
||||
$query->where('course_id', $course_id);
|
||||
}
|
||||
|
||||
return $query->findOrFail($lesson_id);
|
||||
}
|
||||
|
||||
$query = SectionQuiz::with([
|
||||
])->find($lesson_id) :
|
||||
SectionQuiz::with([
|
||||
'quiz_questions',
|
||||
'quiz_submissions' => function ($query) use ($user) {
|
||||
$query->where('user_id', $user->id);
|
||||
}
|
||||
]);
|
||||
|
||||
if ($course_id) {
|
||||
$query->where('course_id', $course_id);
|
||||
}
|
||||
|
||||
return $query->findOrFail($lesson_id);
|
||||
])->find($lesson_id);
|
||||
}
|
||||
|
||||
function getWatchHistory(string $course_id, ?string $user_id): ?WatchHistory
|
||||
|
||||
@ -289,50 +289,21 @@ class CourseSectionService extends MediaService
|
||||
|
||||
public function initWatchHistory(string $course_id, string $watching_type, string $user_id): ?WatchHistory
|
||||
{
|
||||
$lesson = SectionLesson::query()->where('course_id', $course_id);
|
||||
$history = WatchHistory::where('course_id', $course_id)
|
||||
->where('user_id', $user_id)
|
||||
->first();
|
||||
|
||||
if ($history) {
|
||||
return $history;
|
||||
}
|
||||
|
||||
$course = Course::where('id', $course_id)->with([
|
||||
'sections.section_lessons',
|
||||
'sections.section_quizzes',
|
||||
])->first();
|
||||
|
||||
if (! $course) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$preferredTypes = $watching_type === 'quiz' ? ['quiz', 'lesson'] : ['lesson', 'quiz'];
|
||||
$firstItem = null;
|
||||
|
||||
foreach ($preferredTypes as $preferredType) {
|
||||
foreach ($course->sections as $section) {
|
||||
if ($preferredType === 'lesson') {
|
||||
$lesson = $section->section_lessons->first();
|
||||
if ($lesson) {
|
||||
$firstItem = ['type' => 'lesson', 'id' => $lesson->id];
|
||||
break 2;
|
||||
}
|
||||
} else {
|
||||
$quiz = $section->section_quizzes->first();
|
||||
if ($quiz) {
|
||||
$firstItem = ['type' => 'quiz', 'id' => $quiz->id];
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $firstItem) {
|
||||
return null;
|
||||
}
|
||||
if ($lesson->count() >= 0 && !$history) {
|
||||
$lesson = $lesson->orderBy('sort', 'asc')->first();
|
||||
|
||||
$coursePlay = new CoursePlayerService();
|
||||
return $coursePlay->watchHistory($course, (string) $firstItem['id'], $firstItem['type'], $user_id);
|
||||
$course = Course::where('id', $course_id)->with('sections')->first();
|
||||
|
||||
return $coursePlay->watchHistory($course, $lesson->id, $watching_type, $user_id);
|
||||
}
|
||||
|
||||
return $history;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -50,15 +50,13 @@ class SectionQuizService extends CourseSectionService
|
||||
->where('section_quiz_id', $quiz->id)
|
||||
->first();
|
||||
|
||||
$hasAttemptLimit = $quiz->retake > 0;
|
||||
|
||||
// Get or create quiz submission
|
||||
if ($submission) {
|
||||
if ($hasAttemptLimit && $submission->attempts >= $quiz->retake) {
|
||||
if ($submission->attempts >= $quiz->retake) {
|
||||
return false;
|
||||
}
|
||||
|
||||
} else {
|
||||
$submission->increment('attempts');
|
||||
}
|
||||
} else {
|
||||
$submission = QuizSubmission::create([
|
||||
'section_quiz_id' => $quiz->id,
|
||||
|
||||
@ -110,7 +110,7 @@ class StudentService extends MediaService
|
||||
->first();
|
||||
|
||||
if (!$enrollment) {
|
||||
abort(403, 'You are not enrolled in this course');
|
||||
throw new \Exception('You are not enrolled in this course');
|
||||
}
|
||||
|
||||
return Course::with(['instructor:id,user_id', 'instructor.user:id,name,photo'])->find($id);
|
||||
@ -185,20 +185,15 @@ class StudentService extends MediaService
|
||||
|
||||
function getEnrolledCourseOverview(string $course_id, string $tab, User $user): array
|
||||
{
|
||||
$system = app('system_settings');
|
||||
$fields = $system?->fields ?? [];
|
||||
$showCourseCertificate = $fields['show_course_certificate'] ?? true;
|
||||
$showCourseMarksheet = $fields['show_course_marksheet'] ?? $showCourseCertificate;
|
||||
|
||||
return [
|
||||
'modules' => $tab === 'modules' ? $this->getCourseModules($course_id) : null,
|
||||
'live_classes' => $tab === 'live_classes' ? $this->getCourseLiveClasses($course_id) : null,
|
||||
'assignments' => $tab === 'assignments' ? $this->getCourseAssignments($course_id, $user) : null,
|
||||
'quizzes' => $tab === 'quizzes' ? $this->getCourseSectionQuizzes($course_id, $user) : null,
|
||||
'resources' => $tab === 'resources' ? $this->getCourseLessonResources($course_id) : null,
|
||||
'certificateTemplate' => $tab === 'certificate' && $showCourseCertificate ? $this->certificate->getActiveCertificateTemplate('course') : null,
|
||||
'marksheetTemplate' => $tab === 'certificate' && $showCourseMarksheet ? $this->certificate->getActiveMarksheetTemplate('course') : null,
|
||||
'studentMarks' => $tab === 'certificate' && $showCourseMarksheet ? $this->calculateStudentMarks($course_id, $user->id) : null,
|
||||
'certificateTemplate' => $tab === 'certificate' ? $this->certificate->getActiveCertificateTemplate('course') : null,
|
||||
'marksheetTemplate' => $tab === 'certificate' ? $this->certificate->getActiveMarksheetTemplate('course') : null,
|
||||
'studentMarks' => $tab === 'certificate' ? $this->calculateStudentMarks($course_id, $user->id) : null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -2,14 +2,10 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\UserType;
|
||||
use App\Models\User;
|
||||
use App\Notifications\VerifyEmailNotification;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserService
|
||||
{
|
||||
@ -38,23 +34,4 @@ class UserService
|
||||
User::find($id)->update($data);
|
||||
}, 5);
|
||||
}
|
||||
|
||||
public function inviteUser(array $data): User
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'role' => UserType::STUDENT->value,
|
||||
'status' => $data['status'] ?? 1,
|
||||
'password' => Hash::make(Str::random(32)),
|
||||
]);
|
||||
|
||||
DB::afterCommit(function () use ($user) {
|
||||
$user->notify(new VerifyEmailNotification(true));
|
||||
});
|
||||
|
||||
return $user;
|
||||
}, 5);
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,10 +44,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
|
||||
]);
|
||||
|
||||
// Trust reverse proxy headers (X-Forwarded-Proto, etc.) so Laravel generates HTTPS URLs
|
||||
// correctly when TLS is terminated in front of the container (e.g. Dokploy/Traefik).
|
||||
$middleware->trustProxies(at: '*');
|
||||
|
||||
$middleware->encryptCookies(except: ['appearance']);
|
||||
|
||||
$middleware->web(append: [
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
app:
|
||||
image: git.cloudarix.de/ahmido/lms:${IMAGE_TAG}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: websites-lms:latest
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
APP_ENV: production
|
||||
APP_DEBUG: "false"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- websites_lms_storage:/var/www/html/storage
|
||||
- /etc/dokploy/compose/websites-lms-txuykq/code/.env:/var/www/html/.env
|
||||
|
||||
volumes:
|
||||
websites_lms_storage:
|
||||
@ -1,4 +1,4 @@
|
||||
APP_NAME="Mentor-LMS"
|
||||
APP_NAME="Mentor LMS"
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'failed' => 'Diese Zugangsdaten stimmen nicht mit unseren Aufzeichnungen überein.',
|
||||
'password' => 'Das angegebene Passwort ist falsch.',
|
||||
'throttle' => 'Zu viele Anmeldeversuche. Bitte versuche es in :seconds Sekunden erneut.',
|
||||
'password_updated' => 'Dein Passwort wurde aktualisiert.',
|
||||
'verification_link_sent' => 'Ein neuer Bestätigungslink wurde an deine E-Mail-Adresse gesendet.',
|
||||
'password_reset_sent' => 'Wir haben dir den Link zum Zurücksetzen des Passworts per E-Mail gesendet.',
|
||||
'google_auth_settings' => 'Google-Auth-Einstellungen',
|
||||
'google_auth_description' => 'Google-Auth-Beschreibung',
|
||||
'login_title' => 'Melde dich bei deinem Konto an',
|
||||
'login_description' => 'Gib unten deine E-Mail-Adresse und dein Passwort ein, um dich anzumelden.',
|
||||
'remember_me' => 'Angemeldet bleiben',
|
||||
'forgot_password' => 'Passwort vergessen',
|
||||
'continue_with' => 'Oder fortfahren mit',
|
||||
'no_account' => 'Du hast noch kein Konto?',
|
||||
'google_auth' => 'Google Auth',
|
||||
'register_title' => 'Konto erstellen',
|
||||
'register_description' => 'Gib unten deine Daten ein, um dein Konto zu erstellen.',
|
||||
'have_account' => 'Du hast schon ein Konto?',
|
||||
'forgot_description' => 'Gib deine E-Mail-Adresse ein, um einen Link zum Zurücksetzen des Passworts zu erhalten.',
|
||||
'return_to_login' => 'Oder zurück zu',
|
||||
'reset_title' => 'Passwort zurücksetzen',
|
||||
'reset_description' => 'Bitte gib unten dein neues Passwort ein.',
|
||||
'confirm_title' => 'Passwort bestätigen',
|
||||
'confirm_description' => 'Dies ist ein geschützter Bereich der Anwendung. Bitte bestätige dein Passwort, bevor du fortfährst.',
|
||||
'change_email' => 'E-Mail-Adresse ändern',
|
||||
'verify_title' => 'E-Mail-Adresse bestätigen',
|
||||
'verify_description' => 'Bitte bestätige deine E-Mail-Adresse, indem du auf den Link klickst, den wir dir gerade per E-Mail gesendet haben.',
|
||||
'verification_sent' => 'Ein neuer Bestätigungslink wurde an die E-Mail-Adresse gesendet, die du bei der Registrierung angegeben hast.',
|
||||
];
|
||||
@ -1,219 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'add' => 'Hinzufügen',
|
||||
'save' => 'Speichern',
|
||||
'cancel' => 'Abbrechen',
|
||||
'close' => 'Schließen',
|
||||
'submit' => 'Absenden',
|
||||
'edit' => 'Bearbeiten',
|
||||
'delete' => 'Löschen',
|
||||
'create' => 'Erstellen',
|
||||
'update' => 'Aktualisieren',
|
||||
'remove' => 'Entfernen',
|
||||
'previous' => 'Zurück',
|
||||
'apply' => 'Anwenden',
|
||||
'view_all' => 'Alle anzeigen',
|
||||
'reply' => 'Antworten',
|
||||
'reapply' => 'Erneut anwenden',
|
||||
'light' => 'Hell',
|
||||
'dark' => 'Dunkel',
|
||||
'collaborative' => 'Kollaborativ',
|
||||
'administrative' => 'Administrativ',
|
||||
'type' => 'Typ',
|
||||
'form' => 'Formular',
|
||||
'true' => 'Wahr',
|
||||
'false' => 'Falsch',
|
||||
'free' => 'Kostenlos',
|
||||
'list' => 'Liste',
|
||||
'search' => 'Suchen',
|
||||
'filter' => 'Filter',
|
||||
'sort' => 'Sortieren',
|
||||
'refresh' => 'Aktualisieren',
|
||||
'upload' => 'Hochladen',
|
||||
'download' => 'Herunterladen',
|
||||
'send' => 'Senden',
|
||||
'add_item' => 'Element hinzufügen',
|
||||
'reorder' => 'Neu anordnen',
|
||||
'active' => 'Aktiv',
|
||||
'inactive' => 'Inaktiv',
|
||||
'deactivate' => 'Deaktivieren',
|
||||
'lessons' => 'Lektionen',
|
||||
'live_classes' => 'Live-Kurse',
|
||||
'join_class' => 'Kurs beitreten',
|
||||
'edit_live_class' => 'Live-Kurs bearbeiten',
|
||||
'delete_class' => 'Kurs löschen',
|
||||
'class_note' => 'Kursnotiz',
|
||||
'add_language' => 'Sprache hinzufügen',
|
||||
'save_changes' => 'Änderungen speichern',
|
||||
'schedule_class' => 'Kurs planen',
|
||||
'add_newsletter' => 'Newsletter hinzufügen',
|
||||
'update_newsletter' => 'Newsletter aktualisieren',
|
||||
'delete_backup' => 'Sicherung löschen',
|
||||
'refresh_server' => 'Server aktualisieren',
|
||||
'backup_now' => 'Jetzt sichern',
|
||||
'update_application' => 'Anwendung aktualisieren',
|
||||
'set_default' => 'Als Standard setzen',
|
||||
'play_course' => 'Kurs starten',
|
||||
'course_player' => 'Kurs-Player',
|
||||
'enroll_now' => 'Jetzt einschreiben',
|
||||
'buy_now' => 'Jetzt kaufen',
|
||||
'add_to_cart' => 'In den Warenkorb',
|
||||
'add_to_wishlist' => 'Zur Wunschliste hinzufügen',
|
||||
'remove_from_wishlist' => 'Von der Wunschliste entfernen',
|
||||
'learn_more' => 'Mehr erfahren',
|
||||
'continue' => 'Weiter',
|
||||
'course_details' => 'Kursdetails',
|
||||
'continue_to_payment' => 'Weiter zur Zahlung',
|
||||
'post_comment' => 'Kommentar posten',
|
||||
'posting' => 'Wird gepostet...',
|
||||
'like' => 'Gefällt mir',
|
||||
'dislike' => 'Gefällt mir nicht',
|
||||
'submit_review' => 'Bewertung absenden',
|
||||
'next_step' => 'Nächster Schritt',
|
||||
'previous_step' => 'Vorheriger Schritt',
|
||||
'generate_certificate' => 'Zertifikat erstellen',
|
||||
'generating_certificate' => 'Zertifikat wird erstellt...',
|
||||
'download_as' => 'Herunterladen als',
|
||||
'mark_all_as_read' => 'Alle als gelesen markieren',
|
||||
'view_all_notifications' => 'Alle Benachrichtigungen anzeigen',
|
||||
'subscribe' => 'Abonnieren',
|
||||
'become_an_instructor' => 'Dozent werden',
|
||||
'delete_account' => 'Konto löschen',
|
||||
'are_you_sure_to_delete' => 'Bist du sicher, dass du löschen möchtest?',
|
||||
'add_section' => 'Abschnitt hinzufügen',
|
||||
'sort_section' => 'Abschnitte sortieren',
|
||||
'add_quiz' => 'Quiz hinzufügen',
|
||||
'add_lesson' => 'Lektion hinzufügen',
|
||||
'sort_lessons' => 'Lektionen sortieren',
|
||||
'sort_categories' => 'Kategorien sortieren',
|
||||
'edit_section' => 'Abschnitt bearbeiten',
|
||||
'delete_section' => 'Abschnitt löschen',
|
||||
'quiz_questions' => 'Quizfragen',
|
||||
'add_question' => 'Frage hinzufügen',
|
||||
'save_lesson' => 'Lektion speichern',
|
||||
'pick_a_date' => 'Datum auswählen',
|
||||
'toggle_theme' => 'Theme wechseln',
|
||||
'course_preview' => 'Kursvorschau',
|
||||
'submit_for_approval' => 'Zur Freigabe einreichen',
|
||||
'approval_status' => 'Freigabestatus',
|
||||
'login' => 'Anmelden',
|
||||
'logout' => 'Abmelden',
|
||||
'log_out' => 'Abmelden',
|
||||
'log_in' => 'Anmelden',
|
||||
'sign_up' => 'Registrieren',
|
||||
'create_account' => 'Konto erstellen',
|
||||
'continue_with_google' => 'Weiter mit Google',
|
||||
'forgot_password' => 'Passwort vergessen?',
|
||||
'email_password_reset_link' => 'Link zum Zurücksetzen des Passworts per E-Mail senden',
|
||||
'resend_verification_email' => 'Bestätigungs-E-Mail erneut senden',
|
||||
'confirm_password' => 'Passwort bestätigen',
|
||||
'reset_password' => 'Passwort zurücksetzen',
|
||||
'change_email' => 'E-Mail ändern',
|
||||
'change_password' => 'Passwort ändern',
|
||||
'forget_password' => 'Passwort vergessen',
|
||||
'get_email_change_link' => 'Link zum Ändern der E-Mail erhalten',
|
||||
'get_password_reset_link' => 'Link zum Zurücksetzen des Passworts erhalten',
|
||||
'upload_file' => 'Datei hochladen',
|
||||
'start_upload' => 'Upload starten',
|
||||
'cancel_upload' => 'Upload abbrechen',
|
||||
'select_different_file' => 'Andere Datei auswählen',
|
||||
'initializing_upload' => 'Upload wird initialisiert...',
|
||||
'uploading_file_chunks' => 'Dateiteile werden hochgeladen...',
|
||||
'finalizing_upload' => 'Upload wird abgeschlossen...',
|
||||
'completed_upload' => 'Upload abgeschlossen',
|
||||
'upload_completed_successfully' => 'Upload erfolgreich abgeschlossen',
|
||||
'uploading' => 'Wird hochgeladen...',
|
||||
'saving' => 'Wird gespeichert...',
|
||||
'first' => 'Erste',
|
||||
'last' => 'Letzte',
|
||||
'prev' => 'Zurück',
|
||||
'next' => 'Weiter',
|
||||
'back' => 'Zurück',
|
||||
'show_full' => 'Alles anzeigen',
|
||||
'show_less' => 'Weniger anzeigen',
|
||||
'dashboard' => 'Armaturenbrett',
|
||||
'main_menu' => 'Hauptmenü',
|
||||
'categories' => 'Kategorien',
|
||||
'courses' => 'Kurse',
|
||||
'exams' => 'Prüfungen',
|
||||
'enrollments' => 'Einschreibungen',
|
||||
'instructors' => 'Dozenten',
|
||||
'payout_report' => 'Auszahlungsbericht',
|
||||
'payment_report' => 'Zahlungsbericht',
|
||||
'payouts' => 'Auszahlungen',
|
||||
'job_circulars' => 'Stellenausschreibungen',
|
||||
'blogs' => 'Blogs',
|
||||
'newsletters' => 'Newsletter',
|
||||
'all_users' => 'Alle Nutzer',
|
||||
'certificates' => 'Zertifikate',
|
||||
'settings' => 'Einstellungen',
|
||||
'my_courses' => 'Meine Kurse',
|
||||
'wishlist' => 'Wunschliste',
|
||||
'profile' => 'Profil',
|
||||
'live_class' => 'Live-Kurs',
|
||||
'payment_methods' => 'Zahlungsmethoden',
|
||||
'profile_update' => 'Profil aktualisieren',
|
||||
'manage_courses' => 'Kurse verwalten',
|
||||
'create_course' => 'Kurs erstellen',
|
||||
'course_coupons' => 'Kursgutscheine',
|
||||
'manage_exams' => 'Prüfungen verwalten',
|
||||
'create_exam' => 'Prüfung erstellen',
|
||||
'exam_coupons' => 'Prüfungsgutscheine',
|
||||
'course_enrollments' => 'Kurseinschreibungen',
|
||||
'exam_enrollments' => 'Prüfungseinschreibungen',
|
||||
'online_payments' => 'Online-Zahlungen',
|
||||
'offline_payments' => 'Offline-Zahlungen',
|
||||
'manage_enrollments' => 'Einschreibungen verwalten',
|
||||
'add_new_enrollment' => 'Neue Einschreibung hinzufügen',
|
||||
'add_new_instructor' => 'Neuen Dozenten hinzufügen',
|
||||
'manage_instructors' => 'Dozenten verwalten',
|
||||
'create_instructor' => 'Dozenten erstellen',
|
||||
'applications' => 'Anwendungen',
|
||||
'payout_request' => 'Auszahlungsanfrage',
|
||||
'payout_history' => 'Auszahlungsverlauf',
|
||||
'withdraw' => 'Auszahlen',
|
||||
'all_jobs' => 'Alle Jobs',
|
||||
'create_job' => 'Job erstellen',
|
||||
'edit_job' => 'Job bearbeiten',
|
||||
'create_blog' => 'Blog erstellen',
|
||||
'manage_blog' => 'Blog verwalten',
|
||||
'account' => 'Konto',
|
||||
'system' => 'System',
|
||||
'pages' => 'Seiten',
|
||||
'storage' => 'Speicher',
|
||||
'smtp' => 'SMTP',
|
||||
'auth0' => 'Auth0',
|
||||
'maintenance' => 'Wartung',
|
||||
'marksheet' => 'Notenblatt',
|
||||
'become_instructor' => 'Dozent werden',
|
||||
'overview' => 'Übersicht',
|
||||
'curriculum' => 'Lehrplan',
|
||||
'details' => 'Details',
|
||||
'instructor' => 'Dozent',
|
||||
'reviews' => 'Bewertungen',
|
||||
'summery' => 'Zusammenfassung',
|
||||
'certificate' => 'Zertifikat',
|
||||
'forum' => 'Forum',
|
||||
'review' => 'Bewertung',
|
||||
'basic' => 'Grundlegend',
|
||||
'pricing' => 'Preise',
|
||||
'info' => 'Info',
|
||||
'media' => 'Medien',
|
||||
'seo' => 'SEO',
|
||||
'quiz' => 'Quiz',
|
||||
'lesson' => 'Lektion',
|
||||
'social' => 'Soziales',
|
||||
'payment' => 'Zahlung',
|
||||
'copyright' => 'Copyright',
|
||||
'social_media' => 'Soziale Medien',
|
||||
'url_items' => 'URL-Elemente',
|
||||
'dropdowns' => 'Dropdowns',
|
||||
'actions' => 'Aktionen',
|
||||
'website' => 'Website',
|
||||
'navbar' => 'Navbar',
|
||||
'footer' => 'Footer',
|
||||
'style' => 'Stil',
|
||||
'edit_navbar' => 'Navbar bearbeiten',
|
||||
'edit_footer' => 'Footer bearbeiten',
|
||||
];
|
||||
@ -1,107 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Titel',
|
||||
'description' => 'Beschreibung',
|
||||
'name' => 'Name',
|
||||
'email' => 'E-Mail',
|
||||
'password' => 'Passwort',
|
||||
'type' => 'Typ',
|
||||
'category' => 'Kategorie',
|
||||
'status' => 'Status',
|
||||
'location' => 'Standort',
|
||||
'image' => 'Bild',
|
||||
'url' => 'URL',
|
||||
'categories' => 'Kategorien',
|
||||
'students' => 'Teilnehmer',
|
||||
'courses' => 'Kurse',
|
||||
'comments' => 'Kommentare',
|
||||
'comment' => 'Kommentar',
|
||||
'price' => 'Preis',
|
||||
'rating' => 'Bewertung',
|
||||
'instructor' => 'Dozent',
|
||||
'role' => 'Rolle',
|
||||
'action' => 'Aktion',
|
||||
'actions' => 'Aktionen',
|
||||
'level' => 'Level',
|
||||
'active' => 'Aktiv',
|
||||
'inactive' => 'Inaktiv',
|
||||
'published' => 'Veröffentlicht',
|
||||
'draft' => 'Entwurf',
|
||||
'expired' => 'Abgelaufen',
|
||||
'pending' => 'Ausstehend',
|
||||
'enabled' => 'Aktiviert',
|
||||
'disabled' => 'Deaktiviert',
|
||||
'language' => 'Sprache',
|
||||
'default' => 'Standard',
|
||||
'closed' => 'Geschlossen',
|
||||
'paused' => 'Pausiert',
|
||||
'on' => 'An',
|
||||
'off' => 'Aus',
|
||||
'free' => 'Kostenlos',
|
||||
'completed' => 'Abgeschlossen',
|
||||
'search' => 'Suchen',
|
||||
'filter' => 'Filter',
|
||||
'create' => 'Erstellen',
|
||||
'update' => 'Aktualisieren',
|
||||
'edit' => 'Bearbeiten',
|
||||
'save' => 'Speichern',
|
||||
'delete' => 'Löschen',
|
||||
'cancel' => 'Abbrechen',
|
||||
'submit' => 'Absenden',
|
||||
'apply' => 'Anwenden',
|
||||
'view' => 'Ansehen',
|
||||
'preview' => 'Vorschau',
|
||||
'download' => 'Herunterladen',
|
||||
'close' => 'Schließen',
|
||||
'confirm' => 'Bestätigen',
|
||||
'yes' => 'Ja',
|
||||
'no' => 'Nein',
|
||||
'ok' => 'OK',
|
||||
'back' => 'Zurück',
|
||||
'continue' => 'Weiter',
|
||||
'skip' => 'Überspringen',
|
||||
'retry' => 'Erneut versuchen',
|
||||
'refresh' => 'Aktualisieren',
|
||||
'reload' => 'Neu laden',
|
||||
'change_email' => 'E-Mail ändern',
|
||||
'change_password' => 'Passwort ändern',
|
||||
'forget_password' => 'Passwort vergessen',
|
||||
'loading' => 'Wird geladen...',
|
||||
'processing' => 'Wird verarbeitet...',
|
||||
'saving' => 'Wird gespeichert...',
|
||||
'updating' => 'Wird aktualisiert...',
|
||||
'deleting' => 'Wird gelöscht...',
|
||||
'uploading' => 'Wird hochgeladen...',
|
||||
'searching' => 'Wird gesucht...',
|
||||
'no_results_found' => 'Keine Ergebnisse gefunden',
|
||||
'today' => 'Heute',
|
||||
'yesterday' => 'Gestern',
|
||||
'ago' => 'vor',
|
||||
'general_settings' => 'Allgemeine Einstellungen',
|
||||
'email_settings' => 'E-Mail-Einstellungen',
|
||||
'payment_settings' => 'Zahlungseinstellungen',
|
||||
'settings' => 'Einstellungen',
|
||||
'file_too_large' => 'Die Datei ist zu groß. Bitte wähle eine kleinere Datei aus.',
|
||||
'invalid_file' => 'Ungültiges Dateiformat. Bitte lade eine gültige .zip-Datei hoch.',
|
||||
'required_field' => 'Dieses Feld ist erforderlich.',
|
||||
'home' => 'Startseite',
|
||||
'about' => 'Über uns',
|
||||
'contact' => 'Kontakt',
|
||||
'help' => 'Hilfe',
|
||||
'support' => 'Support',
|
||||
'dashboard' => 'Dashboard',
|
||||
'page' => 'Seite',
|
||||
'of' => 'von',
|
||||
'first' => '<<Erste',
|
||||
'previous' => 'Zurück',
|
||||
'next' => 'Weiter',
|
||||
'last' => 'Letzte>>',
|
||||
'go_to_page' => 'Gehe zu Seite',
|
||||
'video' => 'Video',
|
||||
'audio' => 'Audio',
|
||||
'document' => 'Dokument',
|
||||
'file' => 'Datei',
|
||||
'reviews' => 'Bewertungen',
|
||||
'select_the_approval_status' => 'Freigabestatus auswählen',
|
||||
];
|
||||
@ -1,307 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'user_management' => 'Benutzerverwaltung',
|
||||
'course_management' => 'Kursverwaltung',
|
||||
'content_management' => 'Inhaltsverwaltung',
|
||||
'financial_management' => 'Finanzverwaltung',
|
||||
'refund_management' => 'Rückerstattungsverwaltung',
|
||||
'faq_management' => 'FAQ-Verwaltung',
|
||||
'security_settings' => 'Sicherheitseinstellungen',
|
||||
'backup_settings' => 'Sicherungseinstellungen',
|
||||
'tax_settings' => 'Steuereinstellungen',
|
||||
'webhook_settings' => 'Webhook-Einstellungen',
|
||||
'analytics' => 'Analysen',
|
||||
'reports' => 'Berichte',
|
||||
'visitor_analytics' => 'Besucheranalysen',
|
||||
'course_analytics' => 'Kursanalysen',
|
||||
'enrollment_statistics' => 'Einschreibestatistik',
|
||||
'revenue_statistics' => 'Umsatzstatistik',
|
||||
'course_statistics' => 'Kursstatistik',
|
||||
'sales_report' => 'Verkaufsbericht',
|
||||
'user_activity_report' => 'Nutzeraktivitätsbericht',
|
||||
'course_performance_report' => 'Kursleistungsbericht',
|
||||
'financial_report' => 'Finanzbericht',
|
||||
'export_report' => 'Bericht exportieren',
|
||||
'basic_information' => 'Grundinformationen',
|
||||
'blog_information' => 'Blog-Informationen',
|
||||
'user_information' => 'Benutzerinformationen',
|
||||
'media_details' => 'Mediendetails',
|
||||
'compensation_details' => 'Vergütung & Details',
|
||||
'total_blogs' => 'Blogs gesamt',
|
||||
'total_categories' => 'Kategorien gesamt',
|
||||
'total_jobs' => 'Jobs gesamt',
|
||||
'total_users' => 'Benutzer gesamt',
|
||||
'total_earnings' => 'Einnahmen gesamt',
|
||||
'total_withdrawals' => 'Auszahlungen gesamt',
|
||||
'select_user_type' => 'Benutzertyp auswählen',
|
||||
'select_withdrawal_method' => 'Auszahlungsmethode auswählen',
|
||||
'select_recipients' => 'Empfänger auswählen',
|
||||
'select_media' => 'Medien auswählen',
|
||||
'no_results' => 'Keine Ergebnisse.',
|
||||
'no_results_found' => 'Keine Ergebnisse gefunden',
|
||||
'are_you_absolutely_sure' => 'Bist du dir absolut sicher?',
|
||||
'update_the_category' => 'Kategorie aktualisieren',
|
||||
'image_upload_requirements' => 'Bild-Upload ist erforderlich',
|
||||
'admin' => 'Admin',
|
||||
'archived' => 'Archiviert',
|
||||
'user_list' => 'Benutzerliste',
|
||||
'instructor_list' => 'Dozentenliste',
|
||||
'course_list' => 'Kursliste',
|
||||
'newsletter_list' => 'Newsletter-Liste',
|
||||
'payout_list' => 'Auszahlungsliste',
|
||||
'lessons' => 'Lektionen',
|
||||
'quizzes' => 'Quizze',
|
||||
'total_content_items' => 'Inhaltselemente gesamt',
|
||||
'enrollment' => 'Einschreibung',
|
||||
'course_status' => 'Kursstatus',
|
||||
'update_section' => 'Abschnitt aktualisieren',
|
||||
'update_lesson' => 'Lektion aktualisieren',
|
||||
'update_quiz' => 'Quiz aktualisieren',
|
||||
'sort_items' => 'Elemente sortieren',
|
||||
'job_circulars' => 'Stellenausschreibungen',
|
||||
'no_job_circulars_found' => 'Keine Stellenausschreibungen gefunden',
|
||||
'provide_essential_job_details' => 'Gib die wichtigsten Details zur Stelle an',
|
||||
'specify_job_requirements' => 'Gib die Anforderungen und Qualifikationen für diese Stelle an',
|
||||
'provide_salary_details' => 'Gib die Gehaltsspanne und weitere Vergütungsdetails an',
|
||||
'salary_information' => 'Gehaltsinformationen',
|
||||
'salary_information_title' => 'Lege die Vergütungsspanne für diese Position fest',
|
||||
'salary_range' => 'Gehaltsspanne',
|
||||
'salary_currency' => 'Währung',
|
||||
'salary_negotiable' => 'Gehalt verhandelbar',
|
||||
'application_deadline' => 'Bewerbungsfrist',
|
||||
'contact_email' => 'Kontakt-E-Mail',
|
||||
'skills_required' => 'Erforderliche Fähigkeiten',
|
||||
'positions_available' => 'Verfügbare Stellen',
|
||||
'job_type' => 'Jobtyp',
|
||||
'work_type' => 'Arbeitsmodell',
|
||||
'experience_level' => 'Erfahrungslevel',
|
||||
'job_details' => 'Jobdetails',
|
||||
'job_details_title' => 'Lege Jobtyp, Standort und Erfahrungsanforderungen fest',
|
||||
'all' => 'Alle',
|
||||
'student' => 'Teilnehmer',
|
||||
'send_to' => 'Senden an',
|
||||
'send_newsletter' => 'Newsletter senden',
|
||||
'no_newsletters_found' => 'Keine Newsletter gefunden',
|
||||
'available' => 'Verfügbar',
|
||||
'total_payout' => 'Auszahlungen gesamt',
|
||||
'requested' => 'Angefragt',
|
||||
'withdraw_list' => 'Auszahlungsliste',
|
||||
'payout_history' => 'Auszahlungsverlauf',
|
||||
'payout_request' => 'Auszahlungsanfrage',
|
||||
'feedback' => 'Feedback',
|
||||
'course_instructor' => 'Kursdozent',
|
||||
'approval_status' => 'Freigabestatus',
|
||||
'blog' => 'Blog',
|
||||
'add_new_blog' => 'Neuen Blog hinzufügen',
|
||||
'provide_essential_details' => 'Gib die wichtigsten Details zu deinem Blogbeitrag an',
|
||||
'blog_categories' => 'Blogkategorien',
|
||||
'blog_category' => 'Blogkategorie',
|
||||
'protected_category' => 'Geschützte Kategorie',
|
||||
'default_category_description' => 'Wenn eine Kategorie gelöscht wird, werden alle Kurse dieser Kategorie in diese Standardkategorie verschoben. Daher kann die Standardkategorie nicht bearbeitet oder entfernt werden.',
|
||||
'protected_category_desc' => 'Wenn eine Kategorie gelöscht wird, werden alle Kurse dieser Kategorie in diese Standardkategorie verschoben. Daher kann die Standardkategorie nicht bearbeitet oder entfernt werden.',
|
||||
'icon_picker' => 'Icon-Auswahl',
|
||||
'sort_categories' => 'Kategorien sortieren',
|
||||
'add_category' => 'Kategorie hinzufügen',
|
||||
'create_category' => 'Kategorie erstellen',
|
||||
'add_new_category' => 'Neue Kategorie hinzufügen',
|
||||
'enter_blog_title' => 'Blogtitel eingeben',
|
||||
'blog_information_desc' => 'Gib die wichtigsten Details zu deinem Blogbeitrag an',
|
||||
'enter_category_name' => 'Kategorienamen eingeben',
|
||||
'pick_category_icon' => 'Wähle dein Kategorien-Icon',
|
||||
'category_status' => 'Kategoriestatus',
|
||||
'all_users' => 'Alle Benutzer',
|
||||
'only_students' => 'Nur Teilnehmer',
|
||||
'only_instructors' => 'Nur Dozenten',
|
||||
'pending_withdrawals' => 'Ausstehende Auszahlungen',
|
||||
'available_balance' => 'Verfügbares Guthaben',
|
||||
'withdraw_amount' => 'Auszahlungsbetrag',
|
||||
'minimum_withdraw' => 'Mindestbetrag',
|
||||
'maximum_withdraw' => 'Höchstbetrag',
|
||||
'withdrawal_method' => 'Auszahlungsmethode',
|
||||
'withdrawal_note' => 'Auszahlungsnotiz',
|
||||
'withdrawal_note_placeholder' => 'Notiz zu dieser Auszahlung hinzufügen (optional)',
|
||||
'user_role' => 'Benutzerrolle',
|
||||
'provide_essential_user_details' => 'Gib die wichtigsten Details zum Benutzer an',
|
||||
'media_library' => 'Medienbibliothek',
|
||||
'file_manager' => 'Dateimanager',
|
||||
'upload_media' => 'Medien hochladen',
|
||||
'file_name' => 'Dateiname',
|
||||
'file_size' => 'Dateigröße',
|
||||
'file_type' => 'Dateityp',
|
||||
'upload_date' => 'Upload-Datum',
|
||||
'dimensions' => 'Abmessungen',
|
||||
'notifications' => 'Benachrichtigungen',
|
||||
'messages' => 'Nachrichten',
|
||||
'announcements' => 'Ankündigungen',
|
||||
'meta_title' => 'Meta-Titel',
|
||||
'enter_meta_title' => 'Meta-Titel eingeben',
|
||||
'meta_keywords' => 'Meta-Keywords',
|
||||
'enter_meta_keywords' => 'Meta-Keywords eingeben',
|
||||
'meta_description' => 'Meta-Beschreibung',
|
||||
'enter_meta_description' => 'Meta-Beschreibung eingeben',
|
||||
'og_title' => 'OG-Titel',
|
||||
'enter_og_title' => 'OG-Titel eingeben',
|
||||
'og_description' => 'OG-Beschreibung',
|
||||
'enter_og_description' => 'OG-Beschreibung eingeben',
|
||||
'lesson_type' => 'Lektionstyp',
|
||||
'video_file' => 'Video-Datei',
|
||||
'video_url' => 'Video-URL',
|
||||
'document_file' => 'Dokument-Datei',
|
||||
'image_file' => 'Bild-Datei',
|
||||
'text_content' => 'Textinhalt',
|
||||
'embed_source' => 'Einbettungsquelle',
|
||||
'lesson_title' => 'Lektionstitel',
|
||||
'lesson_status' => 'Lektionsstatus',
|
||||
'is_free' => 'Kostenlos',
|
||||
'lesson_description' => 'Lektionsbeschreibung',
|
||||
'lesson_provider' => 'Anbieter',
|
||||
'lesson_source' => 'Quelle',
|
||||
'duration' => 'Dauer',
|
||||
'lesson_summary' => 'Lektionszusammenfassung',
|
||||
'quiz_title' => 'Quiztitel',
|
||||
'enter_quiz_title' => 'Quiztitel eingeben',
|
||||
'total_mark' => 'Gesamtpunktzahl',
|
||||
'pass_mark' => 'Bestehensgrenze',
|
||||
'retake' => 'Wiederholen',
|
||||
'quiz_summary' => 'Quiz-Zusammenfassung',
|
||||
'hours' => 'Stunden',
|
||||
'minutes' => 'Minuten',
|
||||
'seconds' => 'Sekunden',
|
||||
'question_type' => 'Fragetyp',
|
||||
'single_choice' => 'Einzelauswahl',
|
||||
'multiple_choice' => 'Mehrfachauswahl',
|
||||
'true_false' => 'Wahr oder Falsch',
|
||||
'select_question_type' => 'Fragetyp auswählen',
|
||||
'question_title' => 'Fragetitel',
|
||||
'question_options' => 'Antwortoptionen',
|
||||
'correct_answer' => 'Richtige Antwort',
|
||||
'add_question' => 'Frage hinzufügen',
|
||||
'edit_question' => 'Frage bearbeiten',
|
||||
'sections' => 'Abschnitte',
|
||||
'section_title' => 'Abschnittstitel',
|
||||
'section_description' => 'Abschnittsbeschreibung',
|
||||
'lesson_content' => 'Lektionsinhalt',
|
||||
'quiz_questions' => 'Quizfragen',
|
||||
'assignments' => 'Aufgaben',
|
||||
'user_roles' => 'Benutzerrollen',
|
||||
'permissions' => 'Berechtigungen',
|
||||
'user_activity' => 'Benutzeraktivität',
|
||||
'login_history' => 'Anmeldeverlauf',
|
||||
'user_preferences' => 'Benutzereinstellungen',
|
||||
'update_user' => 'Benutzer aktualisieren',
|
||||
'select_approval_status' => 'Freigabestatus auswählen',
|
||||
'course_progress' => 'Kursfortschritt',
|
||||
'completion_rate' => 'Abschlussquote',
|
||||
'time_spent' => 'Aufgewendete Zeit',
|
||||
'quiz_scores' => 'Quiz-Ergebnisse',
|
||||
'certificates_issued' => 'Ausgestellte Zertifikate',
|
||||
'admin_revenue' => 'Admin-Umsatz',
|
||||
'instructor_revenue' => 'Dozentenumsatz',
|
||||
'revenue_tracking' => 'Umsatzverfolgung',
|
||||
'commission_rates' => 'Provisionssätze',
|
||||
'payment_history' => 'Zahlungsverlauf',
|
||||
'instructor_revenue_this_year' => 'Dozentenumsatz dieses Jahr',
|
||||
'admin_revenue_this_year' => 'Admin-Umsatz dieses Jahr',
|
||||
'content_review' => 'Inhaltsprüfung',
|
||||
'flagged_content' => 'Gemeldete Inhalte',
|
||||
'moderation_queue' => 'Moderationswarteschlange',
|
||||
'content_guidelines' => 'Inhaltsrichtlinien',
|
||||
'help_desk' => 'Helpdesk',
|
||||
'support_tickets' => 'Support-Tickets',
|
||||
'documentation' => 'Dokumentation',
|
||||
'marketing_campaigns' => 'Marketingkampagnen',
|
||||
'promotional_codes' => 'Aktionscodes',
|
||||
'affiliate_program' => 'Affiliate-Programm',
|
||||
'email_marketing' => 'E-Mail-Marketing',
|
||||
'third_party_integrations' => 'Drittanbieter-Integrationen',
|
||||
'api_management' => 'API-Verwaltung',
|
||||
'external_services' => 'Externe Dienste',
|
||||
'published' => 'Veröffentlicht',
|
||||
'draft' => 'Entwurf',
|
||||
'active' => 'Aktiv',
|
||||
'inactive' => 'Inaktiv',
|
||||
'category_required' => 'Kategorie *',
|
||||
'select_course_instructor' => 'Kursdozent auswählen',
|
||||
'status_required' => 'Status *',
|
||||
'price' => 'Preis',
|
||||
'enter_course_title' => 'Kurstitel eingeben',
|
||||
'enter_short_description' => 'Kurzbeschreibung eingeben',
|
||||
'type_content_here' => 'Gib deinen Inhalt hier ein...',
|
||||
'type_caption_optional' => 'Bildunterschrift eingeben (optional)',
|
||||
'course_level' => 'Kurslevel',
|
||||
'course_language' => 'Kurssprache',
|
||||
'pricing_type_required' => 'Preistyp *',
|
||||
'enter_course_price' => 'Kurspreis eingeben ($0)',
|
||||
'check_course_discount' => 'Angeben, ob dieser Kurs einen Rabatt hat',
|
||||
'enter_discount_price' => 'Rabattpreis eingeben',
|
||||
'expiry_type' => 'Ablauftyp',
|
||||
'expiry_duration' => 'Ablaufdauer',
|
||||
'drip_content' => 'Drip-Content',
|
||||
'enable_drip_content' => 'Drip-Content aktivieren',
|
||||
'select_instructor' => 'Kursdozent auswählen',
|
||||
'course_category' => 'Kurskategorie',
|
||||
'select_category' => 'Kurskategorie auswählen',
|
||||
'course_preview' => 'Kursvorschau',
|
||||
'course_player' => 'Kurs-Player',
|
||||
'submit_for_approval' => 'Zur Freigabe einreichen',
|
||||
'course_approval_status' => 'Kurs-Freigabestatus',
|
||||
'course_ready_approval' => 'Dieser Kurs ist bereit zur Freigabe!',
|
||||
'course_needs_attention' => 'Dieser Kurs benötigt vor der Freigabe noch Aufmerksamkeit:',
|
||||
'course_content_summary' => 'Zusammenfassung der Kursinhalte',
|
||||
'course_faqs' => 'Kurs-FAQs',
|
||||
'requirements' => 'Voraussetzungen',
|
||||
'outcomes' => 'Ergebnisse',
|
||||
'live_classes' => 'Live-Kurse',
|
||||
'schedule_new_live_class' => 'Neuen Live-Kurs planen',
|
||||
'schedule_live_class' => 'Live-Kurs planen',
|
||||
'zoom_not_enabled' => 'Zoom ist für diesen Kurs nicht aktiviert. Bitte aktiviere Zoom, um Live-Kurse zu planen.',
|
||||
'enable_zoom' => 'Zoom aktivieren',
|
||||
'no_live_classes_scheduled' => 'Keine Live-Kurse geplant',
|
||||
'schedule_first_live_class' => 'Plane deinen ersten Live-Kurs, um mit Zoom loszulegen.',
|
||||
'class_topic_required' => 'Kursthema *',
|
||||
'enter_class_topic' => 'Kursthema eingeben',
|
||||
'start_date_time_required' => 'Startdatum & Uhrzeit *',
|
||||
'class_notes_optional' => 'Kursnotizen (optional)',
|
||||
'scheduling' => 'Wird geplant...',
|
||||
'schedule_class' => 'Kurs planen',
|
||||
'join_class' => 'Kurs beitreten',
|
||||
'edit_class' => 'Kurs bearbeiten',
|
||||
'delete_class' => 'Kurs löschen',
|
||||
'banner' => 'Banner',
|
||||
'thumbnail' => 'Thumbnail',
|
||||
'preview_video_type' => 'Vorschau-Video-Typ',
|
||||
'preview_video_url' => 'Vorschau-Video-URL',
|
||||
'enter_video_url' => 'Video-URL eingeben',
|
||||
'total_number_of_blog' => 'Gesamtzahl der Blogs',
|
||||
'update_category' => 'Kategorie aktualisieren',
|
||||
'provide_blog_details' => 'Gib die wichtigsten Details zu deinem Blogbeitrag an',
|
||||
'title_80_char' => 'Titel (80 Zeichen)',
|
||||
'keywords_80_char' => 'Keywords (80 Zeichen)',
|
||||
'enter_your_keywords' => 'Gib deine Keywords ein',
|
||||
'write_blog_content_here' => 'Schreibe deinen Blog-Inhalt hier...',
|
||||
'media_files' => 'Mediendateien',
|
||||
'upload_banner_thumbnail_desc' => 'Lade Banner- und Thumbnail-Bilder für deinen Blog hoch',
|
||||
'blog_banner' => 'Blog-Banner',
|
||||
'blog_thumbnail' => 'Blog-Thumbnail',
|
||||
'update_blog' => 'Blog aktualisieren',
|
||||
'add_blog' => 'Blog hinzufügen',
|
||||
'subtitle_80_char' => 'Untertitel (80 Zeichen)',
|
||||
'enter_category_description' => 'Kategoriebeschreibung eingeben',
|
||||
'course_title' => 'Kurstitel',
|
||||
'short_description' => 'Kurzbeschreibung',
|
||||
'made_in' => 'Hergestellt in',
|
||||
'pricing_type' => 'Preistyp',
|
||||
'expiry_period_type' => 'Ablaufzeitraum-Typ',
|
||||
'expiry_date' => 'Ablaufdatum',
|
||||
'preview_video' => 'Vorschauvideo',
|
||||
'lesson_duration' => 'Lektionsdauer',
|
||||
'class_topic' => 'Kursthema',
|
||||
'start_date_time' => 'Startdatum & Uhrzeit',
|
||||
'class_notes' => 'Kursnotizen (optional)',
|
||||
'zoom_not_enabled_message' => 'Zoom ist für diesen Kurs nicht aktiviert. Bitte aktiviere Zoom, um Live-Kurse zu planen.',
|
||||
'live' => 'live',
|
||||
'upcoming' => 'bevorstehend',
|
||||
'ended' => 'beendet',
|
||||
'scheduled' => 'geplant',
|
||||
];
|
||||
@ -1,215 +0,0 @@
|
||||
<?php
|
||||
|
||||
return array (
|
||||
'replies' => 'Antworten',
|
||||
'posting' => 'Wird veröffentlicht...',
|
||||
'replying' => 'Antwort wird gesendet...',
|
||||
'something_went_wrong' => 'Etwas ist schiefgelaufen. Bitte versuche es erneut.',
|
||||
'network_error' => 'Netzwerkfehler. Bitte überprüfe deine Verbindung.',
|
||||
'invalid_file_type' => 'Ungültiger Dateityp. Bitte wähle eine gültige Datei aus.',
|
||||
'no_element_available' => 'Kein Element verfügbar',
|
||||
'delete_warning' => 'Bist du sicher, dass du löschen möchtest?',
|
||||
'all' => 'Alle',
|
||||
'grid_view' => 'Rasteransicht',
|
||||
'list_view' => 'Listenansicht',
|
||||
'notification_list' => 'Benachrichtigungsliste',
|
||||
'no_unread_notifications' => 'Keine ungelesenen Benachrichtigungen',
|
||||
'closed' => 'Geschlossen',
|
||||
'company_fallback' => 'TechCorp Inc.',
|
||||
'join_class' => 'Kurs beitreten',
|
||||
'no_lesson_found' => 'Keine Lektion gefunden',
|
||||
'section' => 'Abschnitt',
|
||||
'section_properties' => 'Abschnittseigenschaften',
|
||||
'job_circulars' => 'Stellenausschreibungen',
|
||||
'profile_updated' => 'Profil wurde erfolgreich aktualisiert',
|
||||
'email_changed' => 'E-Mail wurde erfolgreich geändert',
|
||||
'password_changed' => 'Passwort wurde erfolgreich geändert',
|
||||
'application_submitted' => 'Bewerbung wurde erfolgreich eingereicht',
|
||||
'comment_posted' => 'Kommentar wurde erfolgreich veröffentlicht',
|
||||
'reply_posted' => 'Antwort wurde erfolgreich veröffentlicht',
|
||||
'email_not_verified' => 'Deine E-Mail-Adresse ist noch nicht bestätigt. Bitte bestätige sie, indem du auf den Link klickst, den wir dir gerade per E-Mail gesendet haben.',
|
||||
'verification_link_sent' => 'Ein neuer Bestätigungslink wurde an die E-Mail-Adresse gesendet, die du bei der Registrierung angegeben hast.',
|
||||
'privacy_policy' => 'Datenschutzerklärung',
|
||||
'terms_of_service' => 'Nutzungsbedingungen',
|
||||
'student_dashboard' => 'Teilnehmer-Dashboard',
|
||||
'first_page' => 'Erste Seite',
|
||||
'last_page' => 'Letzte Seite',
|
||||
'sort_by' => 'Sortieren nach',
|
||||
'showing_results' => 'Zeige :from bis :to von :total Ergebnissen',
|
||||
'star' => 'Stern',
|
||||
'stars' => 'Sterne',
|
||||
'edit_review' => 'Bewertung bearbeiten',
|
||||
'review' => 'Bewertung',
|
||||
'submit_review' => 'Bewertung absenden',
|
||||
'you_rated_this' => 'Du hast dies bewertet',
|
||||
'characters' => 'Zeichen',
|
||||
'no_courses_found' => 'Keine Kurse gefunden',
|
||||
'no_wishlist_items' => 'Keine Einträge in der Wunschliste gefunden',
|
||||
'day_left' => 'Noch 1 Tag',
|
||||
'days_left' => 'Noch :days Tage',
|
||||
'negotiable' => 'Verhandelbar',
|
||||
'experience_level' => 'Erfahrungslevel',
|
||||
'job_type' => 'Jobtyp',
|
||||
'work_type' => 'Arbeitsmodell',
|
||||
'location' => 'Standort',
|
||||
'positions_available' => 'Verfügbare Stellen',
|
||||
'application_deadline' => 'Bewerbungsfrist',
|
||||
'contact_email' => 'Kontakt-E-Mail',
|
||||
'skills_required' => 'Erforderliche Fähigkeiten',
|
||||
'job_description' => 'Stellenbeschreibung',
|
||||
'quick_apply' => 'Schnell bewerben',
|
||||
'send_application' => 'Sende deine Bewerbung direkt an unser Team',
|
||||
'apply_via_email' => 'Per E-Mail bewerben',
|
||||
'job_statistics' => 'Job-Statistiken',
|
||||
'posted' => 'Veröffentlicht',
|
||||
'last_updated' => 'Zuletzt aktualisiert',
|
||||
'tax' => 'Steuer',
|
||||
'total' => 'Gesamt',
|
||||
'cart_items' => 'Warenkorb-Artikel',
|
||||
'your_cart_is_empty' => 'Dein Warenkorb ist leer',
|
||||
'payment_summary' => 'Zahlungsübersicht',
|
||||
'sub_total' => 'Zwischensumme',
|
||||
'discount' => 'Rabatt',
|
||||
'all_blogs' => 'Alle Blogs',
|
||||
'latest_blog_posts' => 'Neueste Blogbeiträge',
|
||||
'post_a_comment' => 'Kommentar schreiben',
|
||||
'no_comments_yet' => 'Noch keine Kommentare. Sei der Erste, der kommentiert!',
|
||||
'like' => 'Gefällt mir',
|
||||
'dislike' => 'Gefällt mir nicht',
|
||||
'blog_banner_alt' => 'Blog-Banner',
|
||||
'blog_thumbnail_alt' => 'Blog-Thumbnail',
|
||||
'author_alt' => 'Autor',
|
||||
'author_initials_fallback' => 'AU',
|
||||
'blog_list_alt' => 'Blogliste',
|
||||
'blog_page_description' => 'Lies :total+ Artikel und Tutorials von unseren Dozenten und unserem Team. Bleib mit Einblicken, News und How-to-Guides auf dem Laufenden.',
|
||||
'blog_page_keywords' => 'blogs, artikel, tutorials, news, posts, lernen, bildung',
|
||||
'default_site_name' => 'Mentor Learning Management System',
|
||||
'loading_zoom_sdk' => 'Zoom SDK wird geladen...',
|
||||
'joining_meeting' => 'Meeting wird beigetreten...',
|
||||
'unable_to_join_meeting' => 'Meetingbeitritt nicht möglich',
|
||||
'you_can_join_directly' => 'Du kannst direkt beitreten über:',
|
||||
'open_in_zoom_app' => 'In Zoom-App öffnen',
|
||||
'try_again' => 'Erneut versuchen',
|
||||
'zoom_sdk_not_configured' => 'Zoom SDK ist nicht konfiguriert. Bitte kontaktiere den Administrator.',
|
||||
'meeting_information_not_found' => 'Meeting-Informationen nicht gefunden.',
|
||||
'failed_to_get_meeting_configuration' => 'Meeting-Konfiguration konnte nicht abgerufen werden',
|
||||
'zoom_sdk_not_loaded' => 'Zoom SDK nicht geladen',
|
||||
'failed_to_initialize_meeting' => 'Meeting konnte nicht initialisiert werden',
|
||||
'failed_to_join_meeting' => 'Meetingbeitritt fehlgeschlagen',
|
||||
'course_certificate_download' => 'Kurszertifikat herunterladen',
|
||||
'download_official_certificate' => 'Lade dein offizielles Kursabschlusszertifikat herunter',
|
||||
'certificate_of_completion' => 'Abschlusszertifikat',
|
||||
'certificate_description' => 'Dieses Zertifikat wird mit Stolz überreicht, um den erfolgreichen Abschluss aller Kursanforderungen anzuerkennen und ein starkes Engagement für berufliche Weiterentwicklung und exzellentes Lernen zu bestätigen. Hiermit wird bescheinigt, dass',
|
||||
'has_successfully_completed' => 'den Kurs erfolgreich abgeschlossen hat',
|
||||
'completed_on' => 'Abgeschlossen am: :date',
|
||||
'authorized_certificate' => 'Autorisierte Leistungsurkunde',
|
||||
'download_format' => 'Download-Format',
|
||||
'png_certificate_saved' => 'Dein PNG-Zertifikat wurde in deinem Download-Ordner gespeichert.',
|
||||
'pdf_certificate_saved' => 'Dein PDF-Zertifikat wurde in deinem Download-Ordner gespeichert.',
|
||||
'summery' => 'Zusammenfassung',
|
||||
'duration' => 'Dauer',
|
||||
'total_questions' => 'Fragen gesamt',
|
||||
'total_marks' => 'Punkte gesamt',
|
||||
'pass_marks' => 'Bestehensgrenze',
|
||||
'retake' => 'Wiederholen',
|
||||
'result' => 'Ergebnis',
|
||||
'retake_attempts' => 'Wiederholungsversuche',
|
||||
'correct_answers' => 'Richtige Antworten',
|
||||
'incorrect_answers' => 'Falsche Antworten',
|
||||
'passed' => 'Bestanden',
|
||||
'not_passed' => 'Nicht bestanden',
|
||||
'quiz_submitted' => 'Quiz abgeschickt',
|
||||
'start_quiz' => 'Quiz starten',
|
||||
'retake_quiz' => 'Quiz wiederholen',
|
||||
'true' => 'Wahr',
|
||||
'false' => 'Falsch',
|
||||
'hours' => 'Stunden',
|
||||
'minutes' => 'Minuten',
|
||||
'seconds' => 'Sekunden',
|
||||
'pdf_document' => 'PDF-Dokument',
|
||||
'text_document' => 'Textdokument',
|
||||
'document' => 'Dokument',
|
||||
'open_in_new_tab' => 'In neuem Tab öffnen',
|
||||
'download_document' => 'Dokument herunterladen',
|
||||
'unsupported_document_format' => 'Nicht unterstütztes Dokumentformat',
|
||||
'document_format_cannot_be_previewed' => 'Dieses Dokumentformat (.{extension}) kann nicht direkt angezeigt werden. Du kannst es herunterladen, um es mit einer geeigneten Anwendung zu öffnen.',
|
||||
'open_in_new_tab_button' => 'In neuem Tab öffnen',
|
||||
'download' => 'Herunterladen',
|
||||
'learn_comprehensive_course' => 'Lerne mit unserem umfassenden Kurs',
|
||||
'online_course_learning_lms' => 'Onlinekurs, Lernen, LMS',
|
||||
'default_author' => 'UiLib',
|
||||
'course_certificate' => 'Kurszertifikat',
|
||||
'enrolled_students' => 'Eingeschriebene Teilnehmer',
|
||||
'student_reviews' => 'Teilnehmerbewertungen',
|
||||
'no_reviews_found' => 'Keine Bewertungen gefunden.',
|
||||
'course_curriculum' => 'Kurslehrplan',
|
||||
'there_is_no_lesson_added' => 'Es wurde keine Lektion hinzugefügt',
|
||||
'requirements' => 'Voraussetzungen',
|
||||
'outcomes' => 'Ergebnisse',
|
||||
'view_details' => 'Details ansehen',
|
||||
'students' => 'Teilnehmer',
|
||||
'language' => 'Sprache',
|
||||
'level' => 'Level',
|
||||
'expiry_period' => 'Ablaufzeitraum',
|
||||
'certificate_included' => 'Zertifikat enthalten',
|
||||
'free' => 'Kostenlos',
|
||||
'play_course' => 'Kurs starten',
|
||||
'course_player' => 'Kurs-Player',
|
||||
'enroll_now' => 'Jetzt einschreiben',
|
||||
'buy_now' => 'Jetzt kaufen',
|
||||
'add_to_cart' => 'In den Warenkorb',
|
||||
'add_to_wishlist' => 'Zur Wunschliste hinzufügen',
|
||||
'remove_from_wishlist' => 'Von der Wunschliste entfernen',
|
||||
'prev' => 'Zurück',
|
||||
'next' => 'Weiter',
|
||||
'go_to_page_colon' => 'Gehe zu Seite:',
|
||||
'student' => 'Teilnehmer',
|
||||
'trending' => 'Im Trend',
|
||||
'course_details' => 'Kursdetails',
|
||||
'progress' => 'Fortschritt',
|
||||
'instructor' => 'Dozent',
|
||||
'all_courses' => 'Alle Kurse',
|
||||
'instructor_profile' => 'Dozentenprofil',
|
||||
'expert_instructor' => 'Experten-Dozent',
|
||||
'instructor_fallback_keywords' => 'lernen, bildung',
|
||||
'application_is' => 'Bewerbung ist',
|
||||
'application_status' => 'Bewerbungsstatus',
|
||||
'application_rejected' => 'Leider wurde deine Bewerbung abgelehnt. Bitte prüfe die Anforderungen und reiche sie mit aktualisierten Informationen erneut ein.',
|
||||
'application_under_review' => 'Deine Bewerbung wird derzeit von unserem Team geprüft. Wir melden uns so schnell wie möglich bei dir.',
|
||||
'position' => 'Stelle',
|
||||
'positions' => 'Stellen',
|
||||
'available' => 'verfügbar',
|
||||
'dashboard' => 'Dashboard',
|
||||
'courses' => 'Kurse',
|
||||
'lessons' => 'Lektionen',
|
||||
'enrollment' => 'Einschreibung',
|
||||
'course_status' => 'Kursstatus',
|
||||
'latest_pending_withdrawal_request' => 'Neueste ausstehende Auszahlungsanfrage',
|
||||
'view_all' => 'Alle anzeigen',
|
||||
'no_results' => 'Keine Ergebnisse.',
|
||||
'php_extension' => 'PHP-Erweiterung',
|
||||
'symlink_function' => 'Symlink-Funktion',
|
||||
'server_requirements_not_met' => 'Dein Server erfüllt die folgenden Anforderungen nicht',
|
||||
'important_notes' => 'Wichtige Hinweise',
|
||||
'symlink_required' => 'Erforderlich für Laravels storage:link-Befehl, um hochgeladene Dateien öffentlich zugänglich zu machen',
|
||||
'setup_complete' => 'Einrichtung abgeschlossen',
|
||||
'environment_variables_set' => 'Deine geänderten Umgebungsvariablen sind jetzt in der .env-Datei gesetzt.',
|
||||
'click_here' => 'Klicke hier',
|
||||
'get_back_to_project' => 'um zu deinem Projekt zurückzukehren.',
|
||||
'test_connection' => 'Verbindung testen',
|
||||
'database_connection' => 'Datenbankverbindung',
|
||||
'environment_setup' => 'Umgebung einrichten',
|
||||
'admin_setup' => 'Admin-Einrichtung',
|
||||
'just_now' => 'Gerade eben',
|
||||
'minute_ago' => 'Vor 1 Minute',
|
||||
'minutes_ago' => 'Vor :minutes Minuten',
|
||||
'hour_ago' => 'Vor 1 Stunde',
|
||||
'hours_ago' => 'Vor :hours Stunden',
|
||||
'days_ago' => 'Vor :days Tagen',
|
||||
'week_ago' => 'Vor 1 Woche',
|
||||
'weeks_ago' => 'Vor :weeks Wochen',
|
||||
'month_ago' => 'Vor 1 Monat',
|
||||
'months_ago' => 'Vor :months Monaten',
|
||||
'year_ago' => 'Vor 1 Jahr',
|
||||
'years_ago' => 'Vor :years Jahren',
|
||||
);
|
||||
@ -1,348 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'email' => 'E-Mail',
|
||||
'new_email' => 'Neue E-Mail',
|
||||
'your_email' => 'Deine E-Mail',
|
||||
'remember_me' => 'Angemeldet bleiben',
|
||||
'email_address' => 'E-Mail-Adresse',
|
||||
'current_email' => 'Aktuelle E-Mail',
|
||||
'password' => 'Passwort',
|
||||
'current_password' => 'Aktuelles Passwort',
|
||||
'new_password' => 'Neues Passwort',
|
||||
'confirm_password' => 'Passwort bestätigen',
|
||||
'confirm_new_password' => 'Neues Passwort bestätigen',
|
||||
'full_name' => 'Vollständiger Name',
|
||||
'phone' => 'Telefon',
|
||||
'username' => 'Benutzername',
|
||||
'profile_url' => 'Profil-URL',
|
||||
'system_email' => 'System-E-Mail',
|
||||
'account_email' => 'Konto-E-Mail',
|
||||
'contact_email' => 'Kontakt-E-Mail',
|
||||
'from_address' => 'Absenderadresse',
|
||||
'from_name' => 'Absendername',
|
||||
'allow_file_type' => 'Erlaubte Dateitypen',
|
||||
'url' => 'URL',
|
||||
'name' => 'Name',
|
||||
'title' => 'Titel',
|
||||
'status' => 'Status',
|
||||
'icon' => 'Icon',
|
||||
'description' => 'Beschreibung',
|
||||
'sub_title' => 'Untertitel',
|
||||
'thumbnail' => 'Vorschaubild',
|
||||
'banner' => 'Banner',
|
||||
'background_image' => 'Hintergrundbild',
|
||||
'background_color' => 'Hintergrundfarbe',
|
||||
'list_items' => 'Listeneinträge',
|
||||
'dropdown_items' => 'Dropdown-Einträge',
|
||||
'action_type' => 'Aktionstyp',
|
||||
'active' => 'Aktiv',
|
||||
'tags' => 'Tags',
|
||||
'slug' => 'Slug',
|
||||
'url_slug' => 'URL-Slug',
|
||||
'slogan' => 'Slogan',
|
||||
'author' => 'Autor',
|
||||
'meta_title' => 'Meta-Titel',
|
||||
'meta_keywords' => 'Meta-Keywords',
|
||||
'meta_description' => 'Meta-Beschreibung',
|
||||
'og_title' => 'OG-Titel',
|
||||
'og_description' => 'OG-Beschreibung',
|
||||
'student_name' => 'Teilnehmername',
|
||||
'course_name' => 'Kursname',
|
||||
'completion_date' => 'Abschlussdatum',
|
||||
'certificate_size' => 'Zertifikatsgröße',
|
||||
'short_description' => 'Kurzbeschreibung',
|
||||
'download_format' => 'Download-Format',
|
||||
'preview_video' => 'Vorschauvideo',
|
||||
'preview_video_type' => 'Vorschauvideo-Typ',
|
||||
'video_url' => 'Video-URL',
|
||||
'video_url_provider' => 'Video-URL-Anbieter',
|
||||
'select' => 'Auswählen',
|
||||
'select_provider' => 'Anbieter auswählen',
|
||||
'select_video' => 'Video auswählen',
|
||||
'select_document' => 'Dokument auswählen',
|
||||
'select_image' => 'Bild auswählen',
|
||||
'select_user' => 'Benutzer auswählen',
|
||||
'select_course' => 'Kurs auswählen',
|
||||
'embed_source' => 'Embed-Quelle',
|
||||
'duration' => 'Dauer',
|
||||
'lesson_type' => 'Lektionstyp',
|
||||
'requirement' => 'Voraussetzung',
|
||||
'class_topic' => 'Kursthema',
|
||||
'start_date_time' => 'Startdatum & Uhrzeit',
|
||||
'summary' => 'Zusammenfassung',
|
||||
'class_notes' => 'Kursnotizen (optional)',
|
||||
'enable_drip_content' => 'Drip-Content aktivieren',
|
||||
'course_level' => 'Kurslevel',
|
||||
'course_instructor' => 'Dozent',
|
||||
'made_in' => 'Hergestellt in',
|
||||
'pricing_type' => 'Preismodell',
|
||||
'price' => 'Preis',
|
||||
'expiry_period_type' => 'Ablaufzeitraum-Typ',
|
||||
'expiry_date' => 'Ablaufdatum',
|
||||
'expiry_duration' => 'Ablaufdauer',
|
||||
'course_language' => 'Kurs-Sprache',
|
||||
'course_discount' => 'Kursrabatt',
|
||||
'enrollment_type' => 'Einschreibetyp',
|
||||
'question_title' => 'Fragetitel',
|
||||
'title_80_character' => 'Titel (80 Zeichen)',
|
||||
'keywords' => 'Keywords',
|
||||
'keywords_80_character' => 'Keywords (80 Zeichen)',
|
||||
'subtitle_80_character' => 'Untertitel (80 Zeichen)',
|
||||
'question' => 'Frage',
|
||||
'answer' => 'Antwort',
|
||||
'outcome' => 'Ergebnis',
|
||||
'your_text' => 'Gib deinen Text ein',
|
||||
'question_type' => 'Fragetyp',
|
||||
'options' => 'Optionen',
|
||||
'hours' => 'Stunden',
|
||||
'minutes' => 'Minuten',
|
||||
'seconds' => 'Sekunden',
|
||||
'total_mark' => 'Gesamtpunkte',
|
||||
'pass_mark' => 'Bestehensgrenze',
|
||||
'retake_attempts' => 'Wiederholungsversuche',
|
||||
'section_title' => 'Abschnittstitel',
|
||||
'feedback' => 'Feedback',
|
||||
'review' => 'Deine Bewertung',
|
||||
'rating' => 'Bewertung',
|
||||
'designation' => 'Bezeichnung',
|
||||
'resume' => 'Lebenslauf',
|
||||
'skills' => 'Skills',
|
||||
'skills_required' => 'Erforderliche Skills',
|
||||
'biography' => 'Biografie',
|
||||
'job_title' => 'Jobtitel',
|
||||
'job_description' => 'Stellenbeschreibung',
|
||||
'job_type' => 'Jobtyp',
|
||||
'work_type' => 'Arbeitsmodell',
|
||||
'experience_level' => 'Erfahrungslevel',
|
||||
'positions_available' => 'Offene Stellen',
|
||||
'location' => 'Standort',
|
||||
'application_deadline' => 'Bewerbungsfrist',
|
||||
'salary_is_negotiable' => 'Gehalt ist verhandelbar',
|
||||
'minimum_salary' => 'Mindestgehalt',
|
||||
'maximum_salary' => 'Höchstgehalt',
|
||||
'currency' => 'Währung',
|
||||
'subject' => 'Betreff',
|
||||
'send_to' => 'Senden an',
|
||||
'test_api_key' => 'Test-API-Key',
|
||||
'live_api_key' => 'Live-API-Key',
|
||||
'public_test_key' => 'Öffentlicher Test-Key',
|
||||
'secret_test_key' => 'Geheimer Test-Key',
|
||||
'public_live_key' => 'Öffentlicher Live-Key',
|
||||
'secret_live_key' => 'Geheimer Live-Key',
|
||||
'webhook_secret' => 'Webhook-Secret',
|
||||
'sandbox_client_id' => 'Sandbox-Client-ID',
|
||||
'sandbox_secret_key' => 'Sandbox-Secret-Key',
|
||||
'production_client_id' => 'Production-Client-ID',
|
||||
'production_secret_key' => 'Production-Secret-Key',
|
||||
'test_public_key' => 'Öffentlicher Test-Key',
|
||||
'test_secret_key' => 'Geheimer Test-Key',
|
||||
'live_public_key' => 'Öffentlicher Live-Key',
|
||||
'live_secret_key' => 'Geheimer Live-Key',
|
||||
'client_id' => 'Client-ID',
|
||||
'secret_key' => 'Secret-Key',
|
||||
'amount' => 'Betrag',
|
||||
'account_id' => 'Account-ID',
|
||||
'client_secret' => 'Client-Secret',
|
||||
'meeting_sdk_client_id' => 'Meeting SDK Client-ID',
|
||||
'meeting_sdk_client_secret' => 'Meeting SDK Client-Secret',
|
||||
'do_you_want_use_web_sdk' => 'Möchtest du das Web SDK für deinen Live-Kurs verwenden?',
|
||||
'mail_driver' => 'Mail-Treiber',
|
||||
'host' => 'Host',
|
||||
'port' => 'Port',
|
||||
'encryption' => 'Verschlüsselung',
|
||||
'storage_driver' => 'Storage-Treiber',
|
||||
'access_key_id' => 'Access Key ID',
|
||||
'secret_access_key' => 'Secret Access Key',
|
||||
'default_region' => 'Standard-Region',
|
||||
'bucket_name' => 'Bucket-Name',
|
||||
'api_key' => 'API-Key',
|
||||
'api_secret' => 'API-Secret',
|
||||
'store_id' => 'Store-ID',
|
||||
'store_password' => 'Store-Passwort',
|
||||
'page_contents' => 'Seiteninhalte',
|
||||
'google_client_id' => 'Google Client-ID',
|
||||
'google_client_secret' => 'Google Client-Secret',
|
||||
'google_redirect_uri' => 'Google Redirect-URI',
|
||||
'merchant_id_public_key' => 'Merchant-ID / Public Key',
|
||||
'merchant_key_secret_key' => 'Merchant-Key / Secret-Key',
|
||||
'website' => 'Website',
|
||||
'github' => 'GitHub',
|
||||
'twitter' => 'Twitter',
|
||||
'linkedin' => 'LinkedIn',
|
||||
'facebook' => 'Facebook',
|
||||
'website_name' => 'Website-Name',
|
||||
'website_title' => 'Website-Titel',
|
||||
'logo_dark' => 'Logo (dunkel)',
|
||||
'logo_light' => 'Logo (hell)',
|
||||
'favicon' => 'Favicon',
|
||||
'course_selling_currency' => 'Kurs-Verkaufswährung',
|
||||
'course_selling_tax' => 'Kurs-Verkaufssteuer (%)',
|
||||
'instructor_revenue' => 'Dozentenanteil (%)',
|
||||
'category' => 'Kategorie',
|
||||
'category_icon' => 'Kategorie-Icon',
|
||||
'category_status' => 'Kategorie-Status',
|
||||
'coupon' => 'Gutschein',
|
||||
'select_zip_file' => 'Datei auswählen (.zip nur)',
|
||||
'blog_banner' => 'Blog-Banner',
|
||||
'blog_thumbnail' => 'Blog-Vorschaubild',
|
||||
'select_option' => 'Option auswählen',
|
||||
'mail_host' => 'Mail-Host',
|
||||
'mail_port' => 'Mail-Port',
|
||||
'mail_encryption' => 'Mail-Verschlüsselung',
|
||||
'mail_username' => 'Mail-Benutzername',
|
||||
'mail_password' => 'Mail-Passwort',
|
||||
'mail_from_address' => 'Mail-Absenderadresse',
|
||||
'mail_from_name' => 'Mail-Absendername',
|
||||
'aws_access_key_id' => 'AWS Access Key ID',
|
||||
'aws_default_region' => 'AWS Standard-Region',
|
||||
|
||||
// Placeholders
|
||||
'email_placeholder' => 'email@example.com',
|
||||
'password_placeholder' => 'Passwort eingeben',
|
||||
'confirm_password_placeholder' => 'Passwort bestätigen',
|
||||
'current_email_placeholder' => 'Gib deine aktuelle E-Mail ein',
|
||||
'new_email_placeholder' => 'Gib deine neue E-Mail ein',
|
||||
'current_password_placeholder' => 'Gib dein aktuelles Passwort ein',
|
||||
'new_password_placeholder' => 'Gib dein neues Passwort ein',
|
||||
'rewrite_password_placeholder' => 'Gib dein neues Passwort erneut ein',
|
||||
'full_name_placeholder' => 'Gib deinen vollständigen Namen ein',
|
||||
'your_name_placeholder' => 'Gib deinen Namen ein',
|
||||
'username_placeholder' => 'Gib deinen Benutzernamen ein',
|
||||
'phone_number_placeholder' => 'Telefonnummer eingeben',
|
||||
'contact_email_placeholder' => 'hr@company.com',
|
||||
'system_type_placeholder' => 'Systemtyp auswählen',
|
||||
'name_placeholder' => 'Gib deinen Namen ein',
|
||||
'title_placeholder' => 'Titel eingeben',
|
||||
'slug_placeholder' => 'Eindeutigen Slug eingeben',
|
||||
'url_placeholder' => 'URL eingeben (z.B. /courses, https://example.com)',
|
||||
'description_placeholder' => 'Beschreibung eingeben',
|
||||
'icon_placeholder' => 'Icon auswählen',
|
||||
'status_placeholder' => 'Status auswählen',
|
||||
'action_type_placeholder' => 'Aktionstyp auswählen',
|
||||
'image_url_placeholder' => 'Bild-URL oder Pfad',
|
||||
'section_title_placeholder' => 'Gib deinen Abschnittstitel ein',
|
||||
'content_here_placeholder' => 'Gib deinen Inhalt hier ein...',
|
||||
'caption_placeholder' => 'Bildunterschrift eingeben (optional)',
|
||||
'meta_title_placeholder' => 'Meta-Titel eingeben',
|
||||
'meta_keywords_placeholder' => 'Meta-Keywords eingeben',
|
||||
'meta_description_placeholder' => 'Meta-Beschreibung eingeben',
|
||||
'og_title_placeholder' => 'OG-Titel eingeben',
|
||||
'og_description_placeholder' => 'OG-Beschreibung eingeben',
|
||||
'page_name_placeholder' => 'Seitenname eingeben',
|
||||
'page_slug_placeholder' => 'Seiten-Slug eingeben',
|
||||
'page_title_placeholder' => 'Seitentitel eingeben',
|
||||
'tags_placeholder' => 'Tags eingeben...',
|
||||
'course_name_placeholder' => 'Kursname eingeben',
|
||||
'certificate_size_placeholder' => 'Zertifikatsgröße auswählen',
|
||||
'course_title_placeholder' => 'Kurstitel eingeben',
|
||||
'short_description_placeholder' => 'Kurzbeschreibung eingeben',
|
||||
'category_placeholder' => 'Kategorie auswählen',
|
||||
'course_level_placeholder' => 'Kurslevel auswählen',
|
||||
'course_language_placeholder' => 'Kurs-Sprache auswählen',
|
||||
'course_price_placeholder' => 'Kurspreis eingeben ($0)',
|
||||
'discount_price_placeholder' => 'Rabattpreis eingeben ($0)',
|
||||
'video_url_placeholder' => 'Video-URL eingeben',
|
||||
'approval_status_placeholder' => 'Freigabestatus auswählen',
|
||||
'lesson_title_placeholder' => 'Lektionstitel',
|
||||
'provider_placeholder' => 'Anbieter auswählen',
|
||||
'type_video_url_placeholder' => 'Video-URL eingeben',
|
||||
'embed_source_placeholder' => 'Embed-Quell-URL eingeben',
|
||||
'duration_placeholder' => '00:00:00',
|
||||
'class_topic_placeholder' => 'Kursthema eingeben',
|
||||
'quiz_title_placeholder' => 'Quiz-Titel eingeben',
|
||||
'instructor_placeholder' => 'Dozent auswählen',
|
||||
'expiry_duration_placeholder' => 'Dauer auswählen',
|
||||
'question_placeholder' => 'Frage',
|
||||
'answer_placeholder' => 'Antwort eingeben',
|
||||
'outcome_placeholder' => 'Ergebnis',
|
||||
'question_type_placeholder' => 'Fragetyp auswählen',
|
||||
'question_options_placeholder' => 'Antwortmöglichkeiten eingeben',
|
||||
'answer_options_placeholder' => 'Antwortoptionen eingeben',
|
||||
'hours_placeholder' => '00 Stunden',
|
||||
'minutes_placeholder' => '00 Minuten',
|
||||
'seconds_placeholder' => '00 Sekunden',
|
||||
'experience_placeholder' => 'Teile deine Erfahrung mit diesem Kurs...',
|
||||
'your_subject_placeholder' => 'Betreff eingeben',
|
||||
'review_placeholder' => 'Bewertung eingeben',
|
||||
'biography_placeholder' => 'Schreibe etwas über dich',
|
||||
'designation_placeholder' => 'Bezeichnung eingeben',
|
||||
'skills_tag_placeholder' => 'Skills als Tag eingeben',
|
||||
'about_yourself_placeholder' => 'Schreibe etwas über dich',
|
||||
'job_title_placeholder' => 'z.B. Senior React Developer',
|
||||
'url_slug_placeholder' => 'senior-react-developer',
|
||||
'job_description_placeholder' => 'Beschreibe die Rolle, Erwartungen und was die Stelle spannend macht...',
|
||||
'location_placeholder' => 'z.B. Dhaka, Bangladesch',
|
||||
'currency_placeholder' => 'Währung auswählen',
|
||||
'minimum_salary_placeholder' => 'Mindestgehalt eingeben',
|
||||
'maximum_salary_placeholder' => 'Höchstgehalt eingeben',
|
||||
'store_id_placeholder' => 'Store-ID eingeben',
|
||||
'store_password_placeholder' => 'Store-Passwort eingeben',
|
||||
'api_key_placeholder' => 'API-Key eingeben',
|
||||
'api_secret_placeholder' => 'API-Secret eingeben',
|
||||
'test_api_key_placeholder' => 'Test-API-Key eingeben',
|
||||
'live_api_key_placeholder' => 'Live-API-Key eingeben',
|
||||
'mollie_test_api_key_placeholder' => 'Mollie Test API-Key eingeben',
|
||||
'mollie_live_api_key_placeholder' => 'Mollie Live API-Key eingeben',
|
||||
'sandbox_client_id_placeholder' => 'Sandbox Client-ID eingeben',
|
||||
'production_client_id_placeholder' => 'Production Client-ID eingeben',
|
||||
'sandbox_secret_key_placeholder' => 'Sandbox Secret-Key eingeben',
|
||||
'production_secret_key_placeholder' => 'Production Secret-Key eingeben',
|
||||
'test_public_key_placeholder' => 'Öffentlichen Test-Key eingeben',
|
||||
'test_secret_key_placeholder' => 'Geheimen Test-Key eingeben',
|
||||
'live_public_key_placeholder' => 'Öffentlichen Live-Key eingeben',
|
||||
'live_secret_key_placeholder' => 'Geheimen Live-Key eingeben',
|
||||
'webhook_secret_placeholder' => 'Webhook-Secret eingeben',
|
||||
'your_amount_placeholder' => 'Betrag eingeben',
|
||||
'paytm_merchant_id_placeholder' => 'Paytm Merchant-ID eingeben',
|
||||
'paytm_merchant_key_placeholder' => 'Paytm Merchant-Key eingeben',
|
||||
'razorpay_public_key_placeholder' => 'Razorpay Public Key eingeben',
|
||||
'razorpay_secret_key_placeholder' => 'Razorpay Secret Key eingeben',
|
||||
'sslcommerz_public_key_placeholder' => 'SSLCommerz Public Key eingeben',
|
||||
'sslcommerz_secret_key_placeholder' => 'SSLCommerz Secret Key eingeben',
|
||||
'coupon_placeholder' => 'Gutschein eingeben',
|
||||
'zoom_account_email_placeholder' => 'Zoom Konto-E-Mail eingeben',
|
||||
'zoom_account_id_placeholder' => 'Zoom Account-ID eingeben',
|
||||
'zoom_client_id_placeholder' => 'Zoom Client-ID eingeben',
|
||||
'zoom_client_secret_placeholder' => 'Zoom Client-Secret eingeben',
|
||||
'meeting_sdk_client_id_placeholder' => 'Meeting SDK Client-ID eingeben',
|
||||
'meeting_sdk_client_secret_placeholder' => 'Meeting SDK Client-Secret eingeben',
|
||||
'mail_driver_placeholder' => 'Mail-Treiber auswählen',
|
||||
'smtp_example_placeholder' => 'smtp.example.com',
|
||||
'port_587_placeholder' => '587',
|
||||
'encryption_placeholder' => 'Verschlüsselung auswählen',
|
||||
'noreply_example_placeholder' => 'noreply@example.com',
|
||||
'your_company_name_placeholder' => 'Dein Firmenname',
|
||||
'storage_driver_placeholder' => 'Storage-Treiber auswählen',
|
||||
'access_key_id_placeholder' => 'Access Key ID eingeben',
|
||||
'secret_access_key_placeholder' => 'Secret Access Key eingeben',
|
||||
'default_region_placeholder' => 'Standard-Region eingeben',
|
||||
'bucket_name_placeholder' => 'Bucket-Name eingeben',
|
||||
'google_client_id_placeholder' => 'Google Client-ID eingeben',
|
||||
'google_client_secret_placeholder' => 'Google Client-Secret eingeben',
|
||||
'google_redirect_url_placeholder' => 'Google Redirect-URL eingeben',
|
||||
'https_placeholder' => 'https://example.com',
|
||||
'website_name_placeholder' => 'Website-Name eingeben',
|
||||
'website_title_placeholder' => 'Website-Titel eingeben',
|
||||
'keywords_placeholder' => 'Keywords eingeben',
|
||||
'website_description_placeholder' => 'Website-Beschreibung eingeben',
|
||||
'author_name_placeholder' => 'Autorname eingeben',
|
||||
'website_slogan_placeholder' => 'Website-Slogan eingeben',
|
||||
'system_email_placeholder' => 'System-E-Mail eingeben',
|
||||
'select_logo_placeholder' => 'Logo auswählen',
|
||||
'select_favicon_placeholder' => 'Favicon auswählen',
|
||||
'select_banner_placeholder' => 'Banner auswählen',
|
||||
'select_user_placeholder' => 'Benutzer auswählen',
|
||||
'select_course_placeholder' => 'Kurs auswählen',
|
||||
'selling_currency_placeholder' => 'Verkaufswährung auswählen',
|
||||
'selling_tax_percentage_placeholder' => 'Verkaufssteuer (%) eingeben',
|
||||
'revenue_percentage_placeholder' => 'Dozentenanteil (%) eingeben',
|
||||
'mail_host_placeholder' => 'smtp.example.com',
|
||||
'mail_port_placeholder' => '587',
|
||||
'mail_username_placeholder' => 'deine-email@example.com',
|
||||
'mail_password_placeholder' => 'Mail-Passwort eingeben',
|
||||
'mail_from_address_placeholder' => 'noreply@example.com',
|
||||
'mail_from_name_placeholder' => 'Dein Anwendungsname',
|
||||
'aws_access_key_id_placeholder' => 'AWS Access Key ID eingeben',
|
||||
'aws_default_region_placeholder' => 'us-east-1',
|
||||
];
|
||||
@ -1,6 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'previous' => '« Zurück',
|
||||
'next' => 'Weiter »',
|
||||
];
|
||||
@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'reset' => 'Dein Passwort wurde zurückgesetzt.',
|
||||
'sent' => 'Wir haben dir den Link zum Zurücksetzen des Passworts per E-Mail gesendet.',
|
||||
'throttled' => 'Bitte warte, bevor du es erneut versuchst.',
|
||||
'token' => 'Dieser Passwort-Zurücksetzen-Token ist ungültig.',
|
||||
'user' => 'Wir können keinen Benutzer mit dieser E-Mail-Adresse finden.',
|
||||
];
|
||||
@ -1,215 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'social_links' => 'Social Links',
|
||||
'system_settings' => 'Systemeinstellungen',
|
||||
'page_settings' => 'Seiteneinstellungen',
|
||||
'live_class_settings' => 'Live-Kurs-Einstellungen',
|
||||
'payment_gateways' => 'Zahlungsanbieter',
|
||||
'custom_global_style' => 'Benutzerdefiniertes globales Design',
|
||||
'account_settings' => 'Kontoeinstellungen',
|
||||
'smtp_settings' => 'SMTP-Einstellungen',
|
||||
'storage_settings' => 'Speicher-Einstellungen',
|
||||
'language_settings' => 'Spracheinstellungen',
|
||||
'translation_settings' => 'Übersetzungseinstellungen',
|
||||
'add_language' => 'Sprache hinzufügen',
|
||||
'translation_scope_information' => 'Informationen zum Übersetzungsbereich',
|
||||
'translation_scope_dashboard' => 'Übersetzungen werden auf die Dashboard-Oberflächen (Administrator, Dozent, Student) angewendet.',
|
||||
'translation_scope_public_pages' => 'Öffentliche Seiten sind von diesen Übersetzungen nicht betroffen, da sie über den Seiteneditor vollständig anpassbar sind.',
|
||||
|
||||
'configure_zoom' => 'Zoom Server-to-Server OAuth-Zugangsdaten konfigurieren',
|
||||
'email_settings_description' => 'Konfiguriere deine E-Mail-Versandeinstellungen',
|
||||
'storage_settings_description' => 'Konfiguriere deine Speicher-Einstellungen',
|
||||
|
||||
'application_update' => 'Anwendungsaktualisierung',
|
||||
'application_backup' => 'Anwendungs-Backup',
|
||||
'application_updated' => 'Anwendung wurde erfolgreich aktualisiert',
|
||||
'updating_application' => 'Anwendung wird aktualisiert...',
|
||||
|
||||
'backup_name' => 'Backup-Name',
|
||||
'backup_date' => 'Backup-Datum',
|
||||
'backup_size' => 'Größe',
|
||||
'backup_status' => 'Status',
|
||||
'backup_actions' => 'Aktionen',
|
||||
'backup_history' => 'Backup-Verlauf',
|
||||
'backup_created' => 'Backup wurde erfolgreich erstellt',
|
||||
'backup_deleted' => 'Backup wurde erfolgreich gelöscht',
|
||||
'backup_restored' => 'Backup wurde erfolgreich wiederhergestellt',
|
||||
'backup_failed' => 'Backup-Erstellung fehlgeschlagen. Bitte versuche es erneut.',
|
||||
'backup_recommendation' => 'Wir empfehlen dringend, vor dem Aktualisieren ein Backup zu erstellen.',
|
||||
|
||||
'maintenance_description' => 'Aktualisiere, sichere und stelle deine Anwendung sicher und automatisch wieder her.',
|
||||
'update_description' => 'Lade Updates hoch und installiere sie sicher',
|
||||
'backup_description' => 'Erstelle ein vollständiges Backup deiner Anwendung inkl. Dateien und Datenbank',
|
||||
'backup_history_description' => 'Backups deiner Anwendung ansehen und verwalten',
|
||||
'home_pages_description' => 'Liste aller Startseiten im System',
|
||||
'custom_pages_description' => 'Benutzerdefinierte Seiten verwalten',
|
||||
'css_description' => 'Schreibe eigenes CSS, das global auf der Website angewendet wird.',
|
||||
'system_settings_description' => 'Kerneinstellungen deines Systems verwalten',
|
||||
'translation_description' => 'Spracheigenschaften übersetzen',
|
||||
'edit_custom_page' => 'Benutzerdefinierte Seite bearbeiten',
|
||||
'translation_update' => 'Übersetzungsaktualisierung',
|
||||
'elements' => 'Elemente',
|
||||
'available_home_pages' => 'Verfügbare Startseiten',
|
||||
|
||||
// Application Update
|
||||
'confirm_application_update' => 'Anwendungsaktualisierung bestätigen',
|
||||
'do_not_close_window' => 'Bitte schließe dieses Fenster nicht',
|
||||
'update_application_with' => 'Bist du sicher, dass du die Anwendung mit ":filename" aktualisieren möchtest?',
|
||||
'this_update_will' => 'Dieses Update wird:',
|
||||
'put_site_maintenance' => 'Die Website in den Wartungsmodus versetzen',
|
||||
'replace_application_files' => 'Alle Anwendungsdateien ersetzen',
|
||||
'run_database_migrations' => 'Datenbank-Migrationen ausführen',
|
||||
'process_may_take_minutes' => 'Der Vorgang kann einige Minuten dauern',
|
||||
'backup_first_warning' => 'Stelle sicher, dass du vorher ein Backup erstellt hast! Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
'select_zip_file' => 'Datei auswählen (.zip nur)',
|
||||
'selected_file' => 'Ausgewählte Datei:',
|
||||
'file_selected_successfully' => 'Datei erfolgreich ausgewählt. Klicke auf „Anwendung aktualisieren“, um fortzufahren.',
|
||||
'update_application' => 'Anwendung aktualisieren',
|
||||
'uploading' => 'Wird hochgeladen...',
|
||||
'important_update_guidelines' => 'Wichtige Update-Hinweise',
|
||||
'refresh_server_guideline' => 'Vor jedem Update den Server aktualisieren',
|
||||
'backup_first_guideline' => 'Vor jedem Update immer ein Backup erstellen',
|
||||
'file_format_guideline' => 'Upload muss eine gültige ZIP-Datei sein',
|
||||
'maintenance_mode_guideline' => 'Die Website ist während des Updates vorübergehend nicht verfügbar',
|
||||
'migrations_guideline' => 'Datenbank-Migrationen werden automatisch ausgeführt',
|
||||
'downtime_guideline' => 'Der Update-Prozess kann einige Minuten dauern',
|
||||
'browser_guideline' => 'Während des Updates den Browser nicht aktualisieren oder schließen',
|
||||
'compatibility_guideline' => 'Stelle sicher, dass das Update mit deinem System kompatibel ist',
|
||||
'refresh_server' => 'Server aktualisieren',
|
||||
'backup_first' => 'Backup zuerst',
|
||||
'file_format' => 'Dateiformat',
|
||||
'maintenance_mode' => 'Wartungsmodus',
|
||||
'migrations' => 'Migrationen',
|
||||
'downtime' => 'Downtime',
|
||||
'browser' => 'Browser',
|
||||
'compatibility' => 'Kompatibilität',
|
||||
'application_update_title' => 'Anwendungsaktualisierung',
|
||||
'upload_install_description' => 'Lade die neueste Version deiner Anwendung hoch und installiere sie',
|
||||
'refresh_server_button' => 'Server aktualisieren',
|
||||
'updating_application_button' => 'Anwendung wird aktualisiert...',
|
||||
|
||||
// Payment Gateway Settings
|
||||
'paypal_settings' => 'PayPal-Einstellungen',
|
||||
'stripe_settings' => 'Stripe-Einstellungen',
|
||||
'mollie_settings' => 'Mollie-Einstellungen',
|
||||
'paystack_settings' => 'Paystack-Einstellungen',
|
||||
'razorpay_settings' => 'Razorpay-Einstellungen',
|
||||
'sslcommerz_settings' => 'SSLCommerz-Einstellungen',
|
||||
'paytm_settings' => 'Paytm-Einstellungen',
|
||||
'configure_payment_gateway' => ':gateway Zahlungsanbieter konfigurieren',
|
||||
|
||||
// Environment Settings
|
||||
'test_mode' => 'Testmodus',
|
||||
'using_test_environment' => 'Testumgebung wird verwendet',
|
||||
'using_live_environment' => 'Live-Umgebung wird verwendet',
|
||||
'using_sandbox_environment' => 'Sandbox-Umgebung wird verwendet',
|
||||
'using_production_environment' => 'Produktivumgebung wird verwendet',
|
||||
'using_staging_environment' => 'Staging-Umgebung wird verwendet',
|
||||
'using_test_keys' => 'Test-Keys werden verwendet',
|
||||
'using_live_keys' => 'Live-Keys werden verwendet',
|
||||
|
||||
// Credential Sections
|
||||
'api_credentials' => 'API-Zugangsdaten',
|
||||
'test_credentials' => 'Test-Zugangsdaten',
|
||||
'live_credentials' => 'Live-Zugangsdaten',
|
||||
'sandbox_credentials' => 'Sandbox-Zugangsdaten',
|
||||
'production_credentials' => 'Produktiv-Zugangsdaten',
|
||||
|
||||
// Helper Messages
|
||||
'use_test_mode_key' => 'Verwende deinen Testmodus-Key :key',
|
||||
'use_live_mode_key' => 'Verwende deinen Live-Key :key',
|
||||
'use_staging_key' => 'Verwende deinen Staging-Key :key',
|
||||
'use_production_key' => 'Verwende deinen Produktiv-Key :key',
|
||||
|
||||
// Warnings / Errors
|
||||
'delete_backup_warning' => 'Bist du sicher, dass du dieses Backup löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
'restore_backup_warning' => 'Bist du sicher, dass du dieses Backup wiederherstellen möchtest? Dies wird:',
|
||||
'system_type_warning' => 'Bist du sicher, dass du den Systemtyp ändern möchtest? Das beeinflusst das gesamte Verhalten der Anwendung.',
|
||||
'update_system_type_warning' => 'Bist du sicher, dass du den Systemtyp aktualisieren möchtest?',
|
||||
'update_warning' => 'Dieses Update wird:',
|
||||
'update_failed' => 'Update fehlgeschlagen. Bitte versuche es erneut.',
|
||||
'restore_failed' => 'Backup-Wiederherstellung fehlgeschlagen. Bitte versuche es erneut.',
|
||||
'delete_failed' => 'Backup-Löschen fehlgeschlagen. Bitte versuche es erneut.',
|
||||
'server_refreshed' => 'Server erfolgreich aktualisiert',
|
||||
|
||||
// Common Configuration
|
||||
'config_not_found' => 'Konfiguration nicht gefunden.',
|
||||
'footer_config_not_found' => 'Footer-Konfiguration nicht gefunden.',
|
||||
'navbar_config_not_found' => 'Navbar-Konfiguration nicht gefunden.',
|
||||
'configuration' => 'Konfiguration: Umgebungs- und Konfigurationsdateien',
|
||||
|
||||
// General Settings / UI
|
||||
'manage_core_settings' => 'Kerneinstellungen deines Systems verwalten',
|
||||
'app_maintenance' => 'App-Wartung',
|
||||
'app_version' => 'App-Version',
|
||||
'current_version' => 'Aktuelle Version:',
|
||||
|
||||
// Navbar & Footer
|
||||
'live_navbar_preview' => 'Live-Navigation Vorschau',
|
||||
'live_footer_preview' => 'Live-Footer Vorschau',
|
||||
'interactive_preview' => 'Interaktive Vorschau von',
|
||||
'before_login' => 'Vor dem Login',
|
||||
'after_login' => 'Nach dem Login',
|
||||
|
||||
// Pages
|
||||
'collaborative' => 'Kollaborativ',
|
||||
'administrative' => 'Administrativ',
|
||||
'custom_pages' => 'Benutzerdefinierte Seiten',
|
||||
|
||||
// Maintenance / Backup Management
|
||||
'note' => 'Hinweis',
|
||||
'what_backed_up' => 'Was wird gesichert?',
|
||||
'source_code' => 'Quellcode: Alle Anwendungsdateien und Code',
|
||||
'database' => 'Datenbank: Vollständiger MySQL-Datenbank-Dump',
|
||||
'assets' => 'Assets: Hochgeladene Medien und öffentliche Dateien',
|
||||
'refresh_note' => 'Hinweis: Vor jedem Backup den Server aktualisieren.',
|
||||
'deleting_backup' => 'Backup wird gelöscht...',
|
||||
'restoring_backup' => 'Backup wird wiederhergestellt...',
|
||||
'do_not_close' => 'Bitte schließe dieses Fenster nicht',
|
||||
'restore_backup_confirmation' => 'Bist du sicher, dass du das Backup ":backup_name" wiederherstellen möchtest?',
|
||||
'backup_details' => 'Backup-Details',
|
||||
'permanently_delete_files' => 'Backup-Dateien dauerhaft aus dem Speicher löschen',
|
||||
'remove_backup_record' => 'Backup-Eintrag aus der Datenbank entfernen',
|
||||
'cannot_be_undone' => 'Kann nicht rückgängig gemacht oder wiederhergestellt werden',
|
||||
'replace_current_files' => 'Alle aktuellen Anwendungsdateien ersetzen',
|
||||
'restore_database_state' => 'Die gesamte Datenbank durch Backup-Daten ersetzen',
|
||||
'current_data_lost' => 'Alle aktuellen Daten und Dateien gehen verloren',
|
||||
'action_cannot_undone' => 'Diese Aktion kann nicht rückgängig gemacht werden',
|
||||
'critical_warning' => 'Wichtiger Hinweis',
|
||||
'restore_process_time' => 'Der Vorgang kann einige Minuten dauern',
|
||||
'maintenance_mode_enabled' => 'Website in den Wartungsmodus versetzen',
|
||||
|
||||
// Update process (upload UI)
|
||||
'select_update_file' => 'Update-Datei auswählen',
|
||||
'drag_drop_update_file' => 'Ziehe deine Update-Datei hierher oder klicke zum Auswählen',
|
||||
'update_file_requirements' => 'Nur .zip-Dateien erlaubt. Maximale Dateigröße: 500MB',
|
||||
'no_file_selected' => 'Keine Datei ausgewählt',
|
||||
'file_selected' => 'Datei ausgewählt',
|
||||
'browse_files' => 'Dateien durchsuchen',
|
||||
|
||||
// Website Settings
|
||||
'website_information' => 'Website-Informationen',
|
||||
'contact_information' => 'Kontaktinformationen',
|
||||
'media_settings' => 'Medien-Einstellungen',
|
||||
'logo_favicon' => 'Logo & Favicon',
|
||||
'social_media_links' => 'Social-Media-Links',
|
||||
|
||||
// Update Process
|
||||
'replace_files' => 'Alle Anwendungsdateien ersetzen',
|
||||
'run_migrations' => 'Datenbank-Migrationen ausführen',
|
||||
'process_update' => 'Update automatisch durchführen',
|
||||
|
||||
// Backup empty state
|
||||
'no_backups' => 'Keine Backups gefunden',
|
||||
'no_backups_description' => 'Du hast noch keine Backups erstellt. Erstelle dein erstes Backup, um loszulegen.',
|
||||
'restore_database' => 'Datenbank auf den Backup-Stand wiederherstellen',
|
||||
'overwrite_changes' => 'Änderungen seit dem Backup überschreiben',
|
||||
|
||||
// Live Class Settings
|
||||
'zoom_setup_guide' => 'Zoom-Setup-Anleitung',
|
||||
'setup_instructions' => 'Folge diesen Schritten, um Zoom zu integrieren:',
|
||||
'create_zoom_app' => 'Erstelle eine Server-to-Server OAuth App im Zoom Marketplace',
|
||||
'get_credentials' => 'Hole dir Account ID, Client ID und Client Secret',
|
||||
'configure_scopes' => 'Konfiguriere die erforderlichen Scopes für deine App',
|
||||
];
|
||||
@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'type' => 'Typ',
|
||||
'csv' => 'CSV',
|
||||
'resume' => 'Lebenslauf',
|
||||
'view_resume' => 'Lebenslauf ansehen',
|
||||
'img_placeholder' => 'Bild',
|
||||
'no_data' => 'Keine Daten verfügbar',
|
||||
'go_to_page' => 'Gehe zu Seite:',
|
||||
'previous' => 'Zurück',
|
||||
'showing_results' => 'Ergebnisse werden angezeigt',
|
||||
'total_results' => 'Gesamtergebnisse',
|
||||
'delete_instructor_warning' => 'Nach dem Löschen des Dozenten wird der Admin als neuer Dozent für alle Kurse dieses Dozenten zugewiesen.',
|
||||
'delete_course_warning' => 'Nach dem Löschen des Kurses werden alle zugehörigen Daten (z. B. Kursabschnitte, Lektionen, Quizze, Einschreibungen usw.) automatisch gelöscht.',
|
||||
'name' => 'Name',
|
||||
'email' => 'E-Mail',
|
||||
'role' => 'Rolle',
|
||||
'slug' => 'Slug',
|
||||
'title' => 'Titel',
|
||||
'use_case' => 'Anwendungsfall',
|
||||
'sections' => 'Abschnitte',
|
||||
'creator' => 'Ersteller',
|
||||
'enrolled_course' => 'Eingeschriebener Kurs',
|
||||
'enrolled_date' => 'Einschreibedatum',
|
||||
'expiry_date' => 'Ablaufdatum',
|
||||
'payout_amount' => 'Auszahlungsbetrag',
|
||||
'payout_method' => 'Auszahlungsmethode',
|
||||
'processed_date' => 'Verarbeitungsdatum',
|
||||
'payout_date' => 'Auszahlungsdatum',
|
||||
'enrollments' => 'Einschreibungen',
|
||||
'course_title' => 'Kurstitel',
|
||||
'number_of_course' => 'Anzahl der Kurse',
|
||||
'category_name' => 'Kategoriename',
|
||||
'category_child' => 'Unterkategorie',
|
||||
'meta_description' => 'Meta-Beschreibung',
|
||||
'meta_keywords' => 'Meta-Keywords',
|
||||
'action' => 'Aktion',
|
||||
'status' => 'Status',
|
||||
'custom_pages' => 'Benutzerdefinierte Seiten',
|
||||
'add_custom_page' => 'Benutzerdefinierte Seite hinzufügen',
|
||||
'pay' => 'Bezahlen',
|
||||
'print' => 'Drucken',
|
||||
'select' => 'Auswählen',
|
||||
'selected' => 'Ausgewählt',
|
||||
'edit_page' => 'Seite bearbeiten',
|
||||
'copy_url' => 'URL kopieren',
|
||||
'preview_page' => 'Seite ansehen',
|
||||
'lifetime_access' => 'Lebenslanger Zugriff',
|
||||
'url_copied' => 'URL in die Zwischenablage kopiert',
|
||||
'best_single_instructor' => 'Am besten für einen einzelnen Dozenten',
|
||||
'best_multiple_instructors' => 'Am besten für mehrere Dozenten',
|
||||
];
|
||||
@ -1,175 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'accepted' => 'Das Feld :attribute muss akzeptiert werden.',
|
||||
'accepted_if' => 'Das Feld :attribute muss akzeptiert werden, wenn :other :value ist.',
|
||||
'active_url' => 'Das Feld :attribute muss eine gültige URL sein.',
|
||||
'after' => 'Das Feld :attribute muss ein Datum nach dem :date sein.',
|
||||
'after_or_equal' => 'Das Feld :attribute muss ein Datum nach oder gleich :date sein.',
|
||||
'alpha' => 'Das Feld :attribute darf nur Buchstaben enthalten.',
|
||||
'alpha_dash' => 'Das Feld :attribute darf nur Buchstaben, Zahlen, Bindestriche und Unterstriche enthalten.',
|
||||
'alpha_num' => 'Das Feld :attribute darf nur Buchstaben und Zahlen enthalten.',
|
||||
'any_of' => 'Das Feld :attribute ist ungültig.',
|
||||
'array' => 'Das Feld :attribute muss ein Array sein.',
|
||||
'ascii' => 'Das Feld :attribute darf nur Single-Byte alphanumerische Zeichen und Symbole enthalten.',
|
||||
'before' => 'Das Feld :attribute muss ein Datum vor dem :date sein.',
|
||||
'before_or_equal' => 'Das Feld :attribute muss ein Datum vor oder gleich :date sein.',
|
||||
'between' =>
|
||||
array (
|
||||
'array' => 'Das Feld :attribute muss zwischen :min und :max Elemente enthalten.',
|
||||
'file' => 'Das Feld :attribute muss zwischen :min und :max Kilobyte groß sein.',
|
||||
'numeric' => 'Das Feld :attribute muss zwischen :min und :max liegen.',
|
||||
'string' => 'Das Feld :attribute muss zwischen :min und :max Zeichen enthalten.',
|
||||
),
|
||||
'boolean' => 'Das Feld :attribute muss wahr oder falsch sein.',
|
||||
'can' => 'Das Feld :attribute enthält einen nicht autorisierten Wert.',
|
||||
'confirmed' => 'Die Bestätigung von :attribute stimmt nicht überein.',
|
||||
'contains' => 'Dem Feld :attribute fehlt ein erforderlicher Wert.',
|
||||
'current_password' => 'Das Passwort ist falsch.',
|
||||
'date' => 'Das Feld :attribute muss ein gültiges Datum sein.',
|
||||
'date_equals' => 'Das Feld :attribute muss ein Datum gleich :date sein.',
|
||||
'date_format' => 'Das Feld :attribute muss dem Format :format entsprechen.',
|
||||
'decimal' => 'Das Feld :attribute muss :decimal Dezimalstellen haben.',
|
||||
'declined' => 'Das Feld :attribute muss abgelehnt werden.',
|
||||
'declined_if' => 'Das Feld :attribute muss abgelehnt werden, wenn :other :value ist.',
|
||||
'different' => 'Die Felder :attribute und :other müssen unterschiedlich sein.',
|
||||
'digits' => 'Das Feld :attribute muss :digits Ziffern enthalten.',
|
||||
'digits_between' => 'Das Feld :attribute muss zwischen :min und :max Ziffern enthalten.',
|
||||
'dimensions' => 'Das Feld :attribute hat ungültige Bildabmessungen.',
|
||||
'distinct' => 'Das Feld :attribute enthält einen doppelten Wert.',
|
||||
'doesnt_contain' => 'Das Feld :attribute darf keinen der folgenden Werte enthalten: :values.',
|
||||
'doesnt_end_with' => 'Das Feld :attribute darf nicht mit einem der folgenden Werte enden: :values.',
|
||||
'doesnt_start_with' => 'Das Feld :attribute darf nicht mit einem der folgenden Werte beginnen: :values.',
|
||||
'email' => 'Das Feld :attribute muss eine gültige E-Mail-Adresse sein.',
|
||||
'ends_with' => 'Das Feld :attribute muss mit einem der folgenden Werte enden: :values.',
|
||||
'enum' => 'Der ausgewählte Wert für :attribute ist ungültig.',
|
||||
'exists' => 'Der ausgewählte Wert für :attribute ist ungültig.',
|
||||
'extensions' => 'Das Feld :attribute muss eine der folgenden Dateiendungen haben: :values.',
|
||||
'file' => 'Das Feld :attribute muss eine Datei sein.',
|
||||
'filled' => 'Das Feld :attribute muss einen Wert enthalten.',
|
||||
'gt' =>
|
||||
array (
|
||||
'array' => 'Das Feld :attribute muss mehr als :value Elemente enthalten.',
|
||||
'file' => 'Das Feld :attribute muss größer als :value Kilobyte sein.',
|
||||
'numeric' => 'Das Feld :attribute muss größer als :value sein.',
|
||||
'string' => 'Das Feld :attribute muss mehr als :value Zeichen enthalten.',
|
||||
),
|
||||
'gte' =>
|
||||
array (
|
||||
'array' => 'Das Feld :attribute muss :value oder mehr Elemente enthalten.',
|
||||
'file' => 'Das Feld :attribute muss größer oder gleich :value Kilobyte sein.',
|
||||
'numeric' => 'Das Feld :attribute muss größer oder gleich :value sein.',
|
||||
'string' => 'Das Feld :attribute muss mindestens :value Zeichen enthalten.',
|
||||
),
|
||||
'hex_color' => 'Das Feld :attribute muss eine gültige hexadezimale Farbe sein.',
|
||||
'image' => 'Das Feld :attribute muss ein Bild sein.',
|
||||
'in' => 'Der ausgewählte Wert für :attribute ist ungültig.',
|
||||
'in_array' => 'Das Feld :attribute muss in :other vorhanden sein.',
|
||||
'in_array_keys' => 'Das Feld :attribute muss mindestens einen der folgenden Schlüssel enthalten: :values.',
|
||||
'integer' => 'Das Feld :attribute muss eine ganze Zahl sein.',
|
||||
'ip' => 'Das Feld :attribute muss eine gültige IP-Adresse sein.',
|
||||
'ipv4' => 'Das Feld :attribute muss eine gültige IPv4-Adresse sein.',
|
||||
'ipv6' => 'Das Feld :attribute muss eine gültige IPv6-Adresse sein.',
|
||||
'json' => 'Das Feld :attribute muss eine gültige JSON-Zeichenkette sein.',
|
||||
'list' => 'Das Feld :attribute muss eine Liste sein.',
|
||||
'lowercase' => 'Das Feld :attribute muss klein geschrieben sein.',
|
||||
'lt' =>
|
||||
array (
|
||||
'array' => 'Das Feld :attribute muss weniger als :value Elemente enthalten.',
|
||||
'file' => 'Das Feld :attribute muss kleiner als :value Kilobyte sein.',
|
||||
'numeric' => 'Das Feld :attribute muss kleiner als :value sein.',
|
||||
'string' => 'Das Feld :attribute muss weniger als :value Zeichen enthalten.',
|
||||
),
|
||||
'lte' =>
|
||||
array (
|
||||
'array' => 'Das Feld :attribute darf nicht mehr als :value Elemente enthalten.',
|
||||
'file' => 'Das Feld :attribute muss kleiner oder gleich :value Kilobyte sein.',
|
||||
'numeric' => 'Das Feld :attribute muss kleiner oder gleich :value sein.',
|
||||
'string' => 'Das Feld :attribute darf nicht mehr als :value Zeichen enthalten.',
|
||||
),
|
||||
'mac_address' => 'Das Feld :attribute muss eine gültige MAC-Adresse sein.',
|
||||
'max' =>
|
||||
array (
|
||||
'array' => 'Das Feld :attribute darf nicht mehr als :max Elemente enthalten.',
|
||||
'file' => 'Das Feld :attribute darf nicht größer als :max Kilobyte sein.',
|
||||
'numeric' => 'Das Feld :attribute darf nicht größer als :max sein.',
|
||||
'string' => 'Das Feld :attribute darf nicht mehr als :max Zeichen enthalten.',
|
||||
),
|
||||
'max_digits' => 'Das Feld :attribute darf nicht mehr als :max Ziffern enthalten.',
|
||||
'mimes' => 'Das Feld :attribute muss eine Datei vom Typ: :values sein.',
|
||||
'mimetypes' => 'Das Feld :attribute muss eine Datei vom Typ: :values sein.',
|
||||
'min' =>
|
||||
array (
|
||||
'array' => 'Das Feld :attribute muss mindestens :min Elemente enthalten.',
|
||||
'file' => 'Das Feld :attribute muss mindestens :min Kilobyte groß sein.',
|
||||
'numeric' => 'Das Feld :attribute muss mindestens :min sein.',
|
||||
'string' => 'Das Feld :attribute muss mindestens :min Zeichen enthalten.',
|
||||
),
|
||||
'min_digits' => 'Das Feld :attribute muss mindestens :min Ziffern enthalten.',
|
||||
'missing' => 'Das Feld :attribute muss fehlen.',
|
||||
'missing_if' => 'Das Feld :attribute muss fehlen, wenn :other :value ist.',
|
||||
'missing_unless' => 'Das Feld :attribute muss fehlen, sofern :other nicht :value ist.',
|
||||
'missing_with' => 'Das Feld :attribute muss fehlen, wenn :values vorhanden ist.',
|
||||
'missing_with_all' => 'Das Feld :attribute muss fehlen, wenn :values vorhanden sind.',
|
||||
'multiple_of' => 'Das Feld :attribute muss ein Vielfaches von :value sein.',
|
||||
'not_in' => 'Der ausgewählte Wert für :attribute ist ungültig.',
|
||||
'not_regex' => 'Das Format des Feldes :attribute ist ungültig.',
|
||||
'numeric' => 'Das Feld :attribute muss eine Zahl sein.',
|
||||
'password' =>
|
||||
array (
|
||||
'letters' => 'Das Feld :attribute muss mindestens einen Buchstaben enthalten.',
|
||||
'mixed' => 'Das Feld :attribute muss mindestens einen Groß- und einen Kleinbuchstaben enthalten.',
|
||||
'numbers' => 'Das Feld :attribute muss mindestens eine Zahl enthalten.',
|
||||
'symbols' => 'Das Feld :attribute muss mindestens ein Symbol enthalten.',
|
||||
'uncompromised' => 'Das angegebene :attribute ist in einem Datenleck aufgetaucht. Bitte wähle ein anderes :attribute.',
|
||||
),
|
||||
'present' => 'Das Feld :attribute muss vorhanden sein.',
|
||||
'present_if' => 'Das Feld :attribute muss vorhanden sein, wenn :other :value ist.',
|
||||
'present_unless' => 'Das Feld :attribute muss vorhanden sein, sofern :other nicht :value ist.',
|
||||
'present_with' => 'Das Feld :attribute muss vorhanden sein, wenn :values vorhanden ist.',
|
||||
'present_with_all' => 'Das Feld :attribute muss vorhanden sein, wenn :values vorhanden sind.',
|
||||
'prohibited' => 'Das Feld :attribute ist unzulässig.',
|
||||
'prohibited_if' => 'Das Feld :attribute ist unzulässig, wenn :other :value ist.',
|
||||
'prohibited_if_accepted' => 'Das Feld :attribute ist unzulässig, wenn :other akzeptiert ist.',
|
||||
'prohibited_if_declined' => 'Das Feld :attribute ist unzulässig, wenn :other abgelehnt ist.',
|
||||
'prohibited_unless' => 'Das Feld :attribute ist unzulässig, sofern :other nicht in :values ist.',
|
||||
'prohibits' => 'Das Feld :attribute verhindert, dass :other vorhanden ist.',
|
||||
'regex' => 'Das Format des Feldes :attribute ist ungültig.',
|
||||
'required' => 'Das Feld :attribute ist erforderlich.',
|
||||
'required_array_keys' => 'Das Feld :attribute muss Einträge enthalten für: :values.',
|
||||
'required_if' => 'Das Feld :attribute ist erforderlich, wenn :other :value ist.',
|
||||
'required_if_accepted' => 'Das Feld :attribute ist erforderlich, wenn :other akzeptiert ist.',
|
||||
'required_if_declined' => 'Das Feld :attribute ist erforderlich, wenn :other abgelehnt ist.',
|
||||
'required_unless' => 'Das Feld :attribute ist erforderlich, sofern :other nicht in :values ist.',
|
||||
'required_with' => 'Das Feld :attribute ist erforderlich, wenn :values vorhanden ist.',
|
||||
'required_with_all' => 'Das Feld :attribute ist erforderlich, wenn :values vorhanden sind.',
|
||||
'required_without' => 'Das Feld :attribute ist erforderlich, wenn :values nicht vorhanden ist.',
|
||||
'required_without_all' => 'Das Feld :attribute ist erforderlich, wenn keines von :values vorhanden ist.',
|
||||
'same' => 'Das Feld :attribute muss mit :other übereinstimmen.',
|
||||
'size' =>
|
||||
array (
|
||||
'array' => 'Das Feld :attribute muss :size Elemente enthalten.',
|
||||
'file' => 'Das Feld :attribute muss :size Kilobyte groß sein.',
|
||||
'numeric' => 'Das Feld :attribute muss :size sein.',
|
||||
'string' => 'Das Feld :attribute muss :size Zeichen enthalten.',
|
||||
),
|
||||
'starts_with' => 'Das Feld :attribute muss mit einem der folgenden Werte beginnen: :values.',
|
||||
'string' => 'Das Feld :attribute muss eine Zeichenkette sein.',
|
||||
'timezone' => 'Das Feld :attribute muss eine gültige Zeitzone sein.',
|
||||
'unique' => ':attribute ist bereits vergeben.',
|
||||
'uploaded' => ':attribute konnte nicht hochgeladen werden.',
|
||||
'uppercase' => 'Das Feld :attribute muss groß geschrieben sein.',
|
||||
'url' => 'Das Feld :attribute muss eine gültige URL sein.',
|
||||
'ulid' => 'Das Feld :attribute muss eine gültige ULID sein.',
|
||||
'uuid' => 'Das Feld :attribute muss eine gültige UUID sein.',
|
||||
'custom' =>
|
||||
array (
|
||||
'attribute-name' =>
|
||||
array (
|
||||
'rule-name' => 'custom-message',
|
||||
),
|
||||
),
|
||||
'attributes' =>
|
||||
[
|
||||
],
|
||||
];
|
||||
@ -60,7 +60,6 @@ return [
|
||||
'refresh_server' => 'Refresh Server',
|
||||
'backup_now' => 'Backup Now',
|
||||
'update_application' => 'Update Application',
|
||||
'set_default' => 'Set Default',
|
||||
|
||||
// ==================================================
|
||||
// 03. Action Buttons
|
||||
@ -163,17 +162,14 @@ return [
|
||||
'main_menu' => 'Main Menu',
|
||||
'categories' => 'Categories',
|
||||
'courses' => 'Courses',
|
||||
'exams' => 'Exams',
|
||||
'enrollments' => 'Enrollments',
|
||||
'instructors' => 'Instructors',
|
||||
'payout_report' => 'Payout Report',
|
||||
'payment_report' => 'Payment Report',
|
||||
'payouts' => 'Payouts',
|
||||
'job_circulars' => 'Job Circulars',
|
||||
'blogs' => 'Blogs',
|
||||
'newsletters' => 'Newsletters',
|
||||
'all_users' => 'All Users',
|
||||
'certificates' => 'Certificates',
|
||||
'settings' => 'Settings',
|
||||
'my_courses' => 'My Courses',
|
||||
'wishlist' => 'Wishlist',
|
||||
@ -183,14 +179,6 @@ return [
|
||||
'profile_update' => 'Profile Update',
|
||||
'manage_courses' => 'Manage Courses',
|
||||
'create_course' => 'Create Course',
|
||||
'course_coupons' => 'Course Coupons',
|
||||
'manage_exams' => 'Manage Exams',
|
||||
'create_exam' => 'Create Exam',
|
||||
'exam_coupons' => 'Exam Coupons',
|
||||
'course_enrollments' => 'Course Enrollments',
|
||||
'exam_enrollments' => 'Exam Enrollments',
|
||||
'online_payments' => 'Online Payments',
|
||||
'offline_payments' => 'Offline Payments',
|
||||
'manage_enrollments' => 'Manage Enrollments',
|
||||
'add_new_enrollment' => 'Add New Enrollment',
|
||||
'add_new_instructor' => 'Add New Instructor',
|
||||
@ -212,7 +200,6 @@ return [
|
||||
'smtp' => 'SMTP',
|
||||
'auth0' => 'Auth0',
|
||||
'maintenance' => 'Maintenance',
|
||||
'marksheet' => 'Marksheet',
|
||||
|
||||
// ==================================================
|
||||
// 08. Sub Navigation Buttons
|
||||
|
||||
@ -25,9 +25,6 @@ return [
|
||||
'language_settings' => 'Language Settings',
|
||||
'translation_settings' => 'Translation Settings',
|
||||
'add_language' => 'Add Language',
|
||||
'translation_scope_information' => 'Translation Scope Information',
|
||||
'translation_scope_dashboard' => 'Translations will be applied to dashboard interfaces (admin, instructor, student).',
|
||||
'translation_scope_public_pages' => 'Public pages are not affected by these translations as they are fully customizable through the page editor.',
|
||||
|
||||
// Common Settings
|
||||
'account_settings' => 'Account Settings',
|
||||
|
||||
@ -18,7 +18,6 @@ const CourseCard1 = ({ course, viewType = 'grid', className, wishlists }: Props)
|
||||
const { user } = props.auth;
|
||||
const { translate } = props;
|
||||
const { button, frontend, common } = translate;
|
||||
const showWishlist = props.system.fields?.show_student_wishlist !== false;
|
||||
|
||||
const isWishlisted = wishlists?.find((wishlist) => wishlist.course_id === course.id);
|
||||
const currency = systemCurrency(props.system.fields['selling_currency']);
|
||||
@ -56,7 +55,7 @@ const CourseCard1 = ({ course, viewType = 'grid', className, wishlists }: Props)
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{wishlists && showWishlist && (
|
||||
{wishlists && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="absolute top-3 right-3 z-10 opacity-0 group-hover:opacity-100">
|
||||
|
||||
@ -17,7 +17,6 @@ const CourseCard2 = ({ course, className, wishlists }: Props) => {
|
||||
const { props } = usePage<SharedData>();
|
||||
const { user } = props.auth;
|
||||
const { button, common, frontend } = props.translate;
|
||||
const showWishlist = props.system.fields?.show_student_wishlist !== false;
|
||||
|
||||
const isWishlisted = wishlists?.find((wishlist) => wishlist.course_id === course.id);
|
||||
const currency = systemCurrency(props.system.fields['selling_currency']);
|
||||
@ -53,7 +52,7 @@ const CourseCard2 = ({ course, className, wishlists }: Props) => {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{wishlists && showWishlist && (
|
||||
{wishlists && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="absolute top-2 right-2 z-10">
|
||||
|
||||
@ -17,7 +17,6 @@ const CourseCard6 = ({ course, type = 'grid', className, wishlists }: Props) =>
|
||||
const { props } = usePage<SharedData>();
|
||||
const { user } = props.auth;
|
||||
const { button, common, frontend } = props.translate;
|
||||
const showWishlist = props.system.fields?.show_student_wishlist !== false;
|
||||
|
||||
const isWishlisted = wishlists?.find((wishlist) => wishlist.course_id === course.id);
|
||||
const currency = systemCurrency(props.system.fields['selling_currency']);
|
||||
@ -55,7 +54,7 @@ const CourseCard6 = ({ course, type = 'grid', className, wishlists }: Props) =>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{wishlists && showWishlist && (
|
||||
{wishlists && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="absolute top-2 right-2 z-10">
|
||||
|
||||
@ -62,7 +62,7 @@ const ExamCard7 = ({ exam, attempts, bestAttempt, className }: Props) => {
|
||||
<p className="text-muted-foreground flex items-center justify-between text-sm font-medium">
|
||||
<span>Attempts</span>
|
||||
<span>
|
||||
{exam.max_attempts === 0 ? `${totalAttempts} / Unlimited` : `${totalAttempts} / ${exam.max_attempts}`}
|
||||
{totalAttempts} / {exam.max_attempts}
|
||||
</span>
|
||||
</p>
|
||||
<Progress value={progressPercentage} className="h-1.5" />
|
||||
@ -87,7 +87,7 @@ const ExamCard7 = ({ exam, attempts, bestAttempt, className }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(exam.max_attempts === 0 || totalAttempts < exam.max_attempts) && (
|
||||
{totalAttempts < exam.max_attempts && (
|
||||
<ButtonGradientPrimary
|
||||
asChild
|
||||
shadow={false}
|
||||
|
||||
@ -20,7 +20,7 @@ const CertificateGenerator = () => {
|
||||
|
||||
const handleGenerateCertificate = async () => {
|
||||
if (!studentName || !courseName || !completionDate) {
|
||||
toast.error('Bitte fülle alle Pflichtfelder aus.');
|
||||
toast.error('Please fill in all required fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ const CertificateGenerator = () => {
|
||||
// Simulate certificate generation
|
||||
setTimeout(() => {
|
||||
setIsGenerating(false);
|
||||
toast.success('Dein Kursabschlusszertifikat wurde erfolgreich erstellt.');
|
||||
toast.success('Your course completion certificate has been created successfully.');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
@ -73,7 +73,7 @@ const CertificateGenerator = () => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success('Dein PNG-Zertifikat wurde in deinem Download-Ordner gespeichert.');
|
||||
toast.success('Your PNG certificate has been saved to your downloads folder.');
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
@ -107,7 +107,7 @@ const CertificateGenerator = () => {
|
||||
// Save the PDF
|
||||
pdf.save(`${studentName}_${courseName}_Certificate.pdf`);
|
||||
|
||||
toast.success('Dein PDF-Zertifikat wurde in deinem Download-Ordner gespeichert.');
|
||||
toast.success('Your PDF certificate has been saved to your downloads folder.');
|
||||
};
|
||||
|
||||
const drawCertificate = (ctx: CanvasRenderingContext2D, dimensions: { width: number; height: number }) => {
|
||||
@ -135,7 +135,7 @@ const CertificateGenerator = () => {
|
||||
|
||||
// Title
|
||||
ctx.font = 'bold 42px serif';
|
||||
ctx.fillText('Abschlusszertifikat', dimensions.width / 2, 120);
|
||||
ctx.fillText('Certificate of Completion', dimensions.width / 2, 120);
|
||||
|
||||
// Decorative line under title
|
||||
ctx.strokeStyle = '#f59e0b';
|
||||
@ -148,7 +148,7 @@ const CertificateGenerator = () => {
|
||||
// "This is to certify that"
|
||||
ctx.font = '22px serif';
|
||||
ctx.fillStyle = '#4b5563';
|
||||
ctx.fillText('Hiermit wird bescheinigt, dass', dimensions.width / 2, 190);
|
||||
ctx.fillText('This is to certify that', dimensions.width / 2, 190);
|
||||
|
||||
// Student name with underline
|
||||
ctx.font = 'bold 36px serif';
|
||||
@ -167,7 +167,7 @@ const CertificateGenerator = () => {
|
||||
// "has successfully completed the course"
|
||||
ctx.font = '22px serif';
|
||||
ctx.fillStyle = '#4b5563';
|
||||
ctx.fillText('den Kurs erfolgreich abgeschlossen hat', dimensions.width / 2, 320);
|
||||
ctx.fillText('has successfully completed the course', dimensions.width / 2, 320);
|
||||
|
||||
// Course name
|
||||
ctx.font = 'bold 28px serif';
|
||||
@ -177,7 +177,7 @@ const CertificateGenerator = () => {
|
||||
// Completion date
|
||||
ctx.font = '18px serif';
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.fillText(`Abgeschlossen am: ${completionDate}`, dimensions.width / 2, 430);
|
||||
ctx.fillText(`Completed on: ${completionDate}`, dimensions.width / 2, 430);
|
||||
|
||||
// Footer
|
||||
ctx.font = '16px serif';
|
||||
@ -190,8 +190,8 @@ const CertificateGenerator = () => {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold text-gray-800">Zertifikatsgenerator</h1>
|
||||
<p className="text-muted-foreground text-lg">Erstelle dein offizielles Kursabschlusszertifikat</p>
|
||||
<h1 className="mb-4 text-4xl font-bold text-gray-800">Course Certificate Generator</h1>
|
||||
<p className="text-muted-foreground text-lg">Generate your official course completion certificate</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
@ -200,23 +200,23 @@ const CertificateGenerator = () => {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Award className="h-5 w-5 text-amber-600" />
|
||||
Zertifikatsdetails
|
||||
Certificate Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="studentName">Name des Teilnehmers *</Label>
|
||||
<Label htmlFor="studentName">Student Name *</Label>
|
||||
<Input
|
||||
id="studentName"
|
||||
value={studentName}
|
||||
onChange={(e) => setStudentName(e.target.value)}
|
||||
placeholder="Gib deinen vollständigen Namen ein"
|
||||
placeholder="Enter your full name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="courseName">Kursname *</Label>
|
||||
<Input id="courseName" value={courseName} onChange={(e) => setCourseName(e.target.value)} placeholder="Gib den Kursnamen ein" />
|
||||
<Label htmlFor="courseName">Course Name *</Label>
|
||||
<Input id="courseName" value={courseName} onChange={(e) => setCourseName(e.target.value)} placeholder="Enter the course name" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@ -232,33 +232,33 @@ const CertificateGenerator = () => {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Zertifikatsgröße</Label>
|
||||
<Label>Certificate Size</Label>
|
||||
<Select value={certificateSize} onValueChange={setCertificateSize}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Wähle Zertifikatsgröße" />
|
||||
<SelectValue placeholder="Select certificate size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">Standard (800×600)</SelectItem>
|
||||
<SelectItem value="a4">A4 Querformat (842×595)</SelectItem>
|
||||
<SelectItem value="standard">Standard (800x600)</SelectItem>
|
||||
<SelectItem value="a4">A4 Landscape (842x595)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Download-Format</Label>
|
||||
<Label>Download Format</Label>
|
||||
<RadioGroup value={downloadFormat} onValueChange={setDownloadFormat} className="flex space-x-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem className="cursor-pointer" value="png" id="png" />
|
||||
<Label htmlFor="png" className="flex cursor-pointer items-center gap-2">
|
||||
<FileImage className="h-4 w-4" />
|
||||
PNG-Bild
|
||||
PNG Image
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem className="cursor-pointer" value="pdf" id="pdf" />
|
||||
<Label htmlFor="pdf" className="flex cursor-pointer items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
PDF-Dokument
|
||||
PDF Document
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
@ -269,12 +269,12 @@ const CertificateGenerator = () => {
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-b-2 border-white" />
|
||||
Zertifikat wird erstellt...
|
||||
Generating Certificate...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Award className="mr-2 h-4 w-4" />
|
||||
Zertifikat erstellen
|
||||
Generate Certificate
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@ -284,7 +284,7 @@ const CertificateGenerator = () => {
|
||||
{/* Preview Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Zertifikatsvorschau</CardTitle>
|
||||
<CardTitle>Certificate Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
@ -297,26 +297,26 @@ const CertificateGenerator = () => {
|
||||
<div className="relative z-10">
|
||||
<div className="mb-6">
|
||||
<Award className="mx-auto mb-3 h-12 w-12 text-amber-600" />
|
||||
<h2 className="mb-2 font-serif text-2xl font-bold text-gray-800">Abschlusszertifikat</h2>
|
||||
<h2 className="mb-2 font-serif text-2xl font-bold text-gray-800">Certificate of Completion</h2>
|
||||
<div className="mx-auto h-0.5 w-32 bg-amber-400"></div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-gray-700">
|
||||
<p className="font-serif text-lg">Hiermit wird bescheinigt, dass</p>
|
||||
<p className="font-serif text-lg">This is to certify that</p>
|
||||
<div className="relative">
|
||||
<p className="mx-8 pb-2 font-serif text-2xl font-bold text-indigo-800">{studentName || 'Student Name'}</p>
|
||||
<div className="absolute bottom-0 left-1/2 h-0.5 w-48 -translate-x-1/2 transform bg-amber-400"></div>
|
||||
</div>
|
||||
<p className="font-serif text-lg">den Kurs erfolgreich abgeschlossen hat</p>
|
||||
<p className="font-serif text-xl font-semibold text-indigo-700">{courseName || 'Kursname'}</p>
|
||||
<p className="font-serif text-lg">has successfully completed the course</p>
|
||||
<p className="font-serif text-xl font-semibold text-indigo-700">{courseName || 'Course Name'}</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<Calendar className="text-muted-foreground h-4 w-4" />
|
||||
<p className="text-muted-foreground font-serif text-sm">Abgeschlossen am: {completionDate || 'Datum'}</p>
|
||||
<p className="text-muted-foreground font-serif text-sm">Completed on: {completionDate || 'Date'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-amber-400 pt-4">
|
||||
<p className="font-serif text-sm text-gray-500">Autorisierte Leistungsurkunde</p>
|
||||
<p className="font-serif text-sm text-gray-500">Authorized Certificate of Achievement</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -324,7 +324,7 @@ const CertificateGenerator = () => {
|
||||
{studentName && courseName && completionDate && (
|
||||
<Button variant="outline" className="mt-4 w-full" onClick={handleDownloadCertificate}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Herunterladen als {downloadFormat.toUpperCase()}
|
||||
Download as {downloadFormat.toUpperCase()}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@ -49,7 +49,7 @@ const Certificate = ({ course, watchHistory }: { course: Course; watchHistory: W
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${studentName}_${courseName}_Zertifikat.png`;
|
||||
a.download = `${studentName}_${courseName}_Certificate.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
@ -86,7 +86,7 @@ const Certificate = ({ course, watchHistory }: { course: Course; watchHistory: W
|
||||
pdf.addImage(imgData, 'PNG', 0, 0, dimensions.width, dimensions.height);
|
||||
|
||||
// Save the PDF
|
||||
pdf.save(`${studentName}_${courseName}_Zertifikat.pdf`);
|
||||
pdf.save(`${studentName}_${courseName}_Certificate.pdf`);
|
||||
|
||||
toast.success(frontend.pdf_certificate_saved);
|
||||
};
|
||||
@ -253,21 +253,21 @@ const Certificate = ({ course, watchHistory }: { course: Course; watchHistory: W
|
||||
<RadioGroupItem className="cursor-pointer" value="png" id="png" />
|
||||
<Label htmlFor="png" className="flex cursor-pointer items-center gap-2">
|
||||
<FileImage className="h-4 w-4" />
|
||||
PNG-Bild
|
||||
PNG Image
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem className="cursor-pointer" value="pdf" id="pdf" />
|
||||
<Label htmlFor="pdf" className="flex cursor-pointer items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
PDF-Dokument
|
||||
PDF Document
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={handleDownloadCertificate}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Herunterladen als {downloadFormat.toUpperCase()}
|
||||
Download as {downloadFormat.toUpperCase()}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@ -68,13 +68,13 @@ const DynamicCertificate = ({ template, courseName, studentName, completionDate
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${studentName}_${courseName}_Zertifikat.png`;
|
||||
a.download = `${studentName}_${courseName}_Certificate.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success('Zertifikat als PNG gespeichert!');
|
||||
toast.success('Certificate saved as PNG!');
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
@ -111,7 +111,7 @@ const DynamicCertificate = ({ template, courseName, studentName, completionDate
|
||||
pdf.addImage(imgData, 'PNG', 0, 0, dimensions.width, dimensions.height);
|
||||
pdf.save(`${studentName}_${courseName}_Certificate.pdf`);
|
||||
|
||||
toast.success('Zertifikat als PDF gespeichert!');
|
||||
toast.success('Certificate saved as PDF!');
|
||||
};
|
||||
|
||||
const wrapText = (ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number, lineHeight: number) => {
|
||||
@ -228,7 +228,7 @@ const DynamicCertificate = ({ template, courseName, studentName, completionDate
|
||||
// Completion date
|
||||
ctx.font = `18px ${template_data.fontFamily}`;
|
||||
ctx.fillStyle = template_data.secondaryColor;
|
||||
ctx.fillText(`Abgeschlossen am: ${completionDate}`, dimensions.width / 2, currentY);
|
||||
ctx.fillText(`Completed on: ${completionDate}`, dimensions.width / 2, currentY);
|
||||
currentY += 60;
|
||||
|
||||
// Footer
|
||||
@ -352,7 +352,7 @@ const DynamicCertificate = ({ template, courseName, studentName, completionDate
|
||||
color: template_data.secondaryColor,
|
||||
}}
|
||||
>
|
||||
Abgeschlossen am: {completionDate}
|
||||
Completed on: {completionDate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -382,21 +382,21 @@ const DynamicCertificate = ({ template, courseName, studentName, completionDate
|
||||
<RadioGroupItem className="cursor-pointer" value="png" id="png" />
|
||||
<Label htmlFor="png" className="flex cursor-pointer items-center gap-2">
|
||||
<FileImage className="h-4 w-4" />
|
||||
PNG-Bild
|
||||
PNG Image
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem className="cursor-pointer" value="pdf" id="pdf" />
|
||||
<Label htmlFor="pdf" className="flex cursor-pointer items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
PDF-Dokument
|
||||
PDF Document
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={handleDownloadCertificate}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Herunterladen als {downloadFormat.toUpperCase()}
|
||||
Download as {downloadFormat.toUpperCase()}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@ -69,13 +69,13 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${studentName}_${courseName}_Notenblatt.png`;
|
||||
a.download = `${studentName}_${courseName}_Marksheet.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success('Notenblatt als PNG gespeichert!');
|
||||
toast.success('Marksheet saved as PNG!');
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
@ -110,9 +110,9 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
pdf.addImage(imgData, 'PNG', 0, 0, dimensions.width, dimensions.height);
|
||||
pdf.save(`${studentName}_${courseName}_Notenblatt.pdf`);
|
||||
pdf.save(`${studentName}_${courseName}_Marksheet.pdf`);
|
||||
|
||||
toast.success('Notenblatt als PDF gespeichert!');
|
||||
toast.success('Marksheet saved as PDF!');
|
||||
};
|
||||
|
||||
const drawMarksheet = async (
|
||||
@ -174,7 +174,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
// Column 1 - Student Name
|
||||
ctx.font = `16px ${template_data.fontFamily}`;
|
||||
ctx.fillStyle = template_data.secondaryColor;
|
||||
ctx.fillText('Name des Studierenden', col1X, currentY);
|
||||
ctx.fillText('Student Name', col1X, currentY);
|
||||
|
||||
ctx.font = `bold 20px ${template_data.fontFamily}`;
|
||||
ctx.fillStyle = template_data.primaryColor;
|
||||
@ -183,7 +183,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
// Column 2 - Course
|
||||
ctx.font = `16px ${template_data.fontFamily}`;
|
||||
ctx.fillStyle = template_data.secondaryColor;
|
||||
ctx.fillText('Kurs', col2X, currentY);
|
||||
ctx.fillText('Course', col2X, currentY);
|
||||
|
||||
ctx.font = `bold 20px ${template_data.fontFamily}`;
|
||||
ctx.fillStyle = template_data.primaryColor;
|
||||
@ -215,7 +215,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
// Column 1 - Completion Date
|
||||
ctx.font = `16px ${template_data.fontFamily}`;
|
||||
ctx.fillStyle = template_data.secondaryColor;
|
||||
ctx.fillText('Abschlussdatum', col1X, currentY);
|
||||
ctx.fillText('Completion Date', col1X, currentY);
|
||||
|
||||
ctx.font = `18px ${template_data.fontFamily}`;
|
||||
ctx.fillStyle = template_data.primaryColor;
|
||||
@ -224,7 +224,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
// Column 2 - Overall Grade
|
||||
ctx.font = `16px ${template_data.fontFamily}`;
|
||||
ctx.fillStyle = template_data.secondaryColor;
|
||||
ctx.fillText('Gesamtnote', col2X, currentY);
|
||||
ctx.fillText('Overall Grade', col2X, currentY);
|
||||
|
||||
ctx.font = `bold 20px ${template_data.fontFamily}`;
|
||||
ctx.fillStyle = template_data.primaryColor;
|
||||
@ -236,7 +236,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = `bold 22px ${template_data.fontFamily}`;
|
||||
ctx.fillStyle = template_data.primaryColor;
|
||||
ctx.fillText('Prüfungsart', leftMargin, currentY);
|
||||
ctx.fillText('Exam Type', leftMargin, currentY);
|
||||
currentY += 35;
|
||||
|
||||
// Table Header Background
|
||||
@ -247,9 +247,9 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
// Table Headers
|
||||
ctx.font = `bold 18px ${template_data.fontFamily}`;
|
||||
ctx.fillStyle = template_data.primaryColor;
|
||||
ctx.fillText('Prüfungsart', leftMargin + 15, currentY + 28);
|
||||
ctx.fillText('Exam Type', leftMargin + 15, currentY + 28);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('Gesamtpunkte', rightMargin - 15, currentY + 28);
|
||||
ctx.fillText('Total Marks', rightMargin - 15, currentY + 28);
|
||||
|
||||
// Table Border
|
||||
ctx.strokeStyle = template_data.borderColor;
|
||||
@ -264,7 +264,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = `18px ${template_data.fontFamily}`;
|
||||
ctx.fillStyle = template_data.secondaryColor;
|
||||
ctx.fillText('Aufgabe', leftMargin + 15, currentY + 28);
|
||||
ctx.fillText('Assignment', leftMargin + 15, currentY + 28);
|
||||
|
||||
ctx.textAlign = 'right';
|
||||
ctx.font = `bold 18px ${template_data.fontFamily}`;
|
||||
@ -378,7 +378,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
color: template_data.secondaryColor,
|
||||
}}
|
||||
>
|
||||
Name des Studierenden
|
||||
Student Name
|
||||
</p>
|
||||
<p
|
||||
className="text-lg font-semibold"
|
||||
@ -396,7 +396,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
color: template_data.secondaryColor,
|
||||
}}
|
||||
>
|
||||
Kurs
|
||||
Course
|
||||
</p>
|
||||
<p
|
||||
className="text-lg font-semibold"
|
||||
@ -414,7 +414,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
color: template_data.secondaryColor,
|
||||
}}
|
||||
>
|
||||
Abschlussdatum
|
||||
Completion Date
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar
|
||||
@ -440,7 +440,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
color: template_data.secondaryColor,
|
||||
}}
|
||||
>
|
||||
Gesamtnote
|
||||
Overall Grade
|
||||
</p>
|
||||
<p
|
||||
className="text-2xl font-bold"
|
||||
@ -569,21 +569,21 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
|
||||
<RadioGroupItem className="cursor-pointer" value="png" id="marksheet-png" />
|
||||
<Label htmlFor="marksheet-png" className="flex cursor-pointer items-center gap-2">
|
||||
<FileImage className="h-4 w-4" />
|
||||
PNG-Bild
|
||||
PNG Image
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem className="cursor-pointer" value="pdf" id="marksheet-pdf" />
|
||||
<Label htmlFor="marksheet-pdf" className="flex cursor-pointer items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
PDF-Dokument
|
||||
PDF Document
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={handleDownloadMarksheet}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Herunterladen als {downloadFormat.toUpperCase()}
|
||||
Download as {downloadFormat.toUpperCase()}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@ -20,7 +20,6 @@ const ExamCard1 = ({ exam, variant = 'default', viewType = 'grid', onAddToCart,
|
||||
const { props } = usePage<SharedData>();
|
||||
const { translate } = props;
|
||||
const { common } = translate;
|
||||
const showWishlist = props.system.fields?.show_student_wishlist !== false;
|
||||
|
||||
const isCompact = variant === 'compact';
|
||||
const examUrl = route('exams.details', { slug: exam.slug, id: exam.id });
|
||||
@ -98,7 +97,7 @@ const ExamCard1 = ({ exam, variant = 'default', viewType = 'grid', onAddToCart,
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{showWishlist && onAddToWishlist && (
|
||||
{onAddToWishlist && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
|
||||
@ -24,14 +24,9 @@ const Notification = () => {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<h4 className="font-semibold">{dashboard.notifications}</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto px-2 text-xs leading-snug whitespace-normal"
|
||||
onClick={() => router.put(route('notifications.mark-all-as-read'))}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="px-2 text-xs" onClick={() => router.put(route('notifications.mark-all-as-read'))}>
|
||||
{button.mark_all_as_read}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -9,7 +9,6 @@ const ProfileToggle = () => {
|
||||
const { props } = usePage<SharedData>();
|
||||
const { user } = props.auth;
|
||||
const { button } = props.translate;
|
||||
const showWishlist = props.system.fields?.show_student_wishlist !== false;
|
||||
|
||||
const studentMenuItems = [
|
||||
{
|
||||
@ -18,16 +17,12 @@ const ProfileToggle = () => {
|
||||
slug: 'courses',
|
||||
Icon: GraduationCap,
|
||||
},
|
||||
...(showWishlist
|
||||
? [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: button.wishlist,
|
||||
slug: 'wishlist',
|
||||
Icon: Heart,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: nanoid(),
|
||||
name: button.profile,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
import { SidebarMenuButton, useSidebar } from '@/components/ui/sidebar';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { routeLastSegment, routeSecondSegment } from '@/lib/route';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedData } from '@/types/global';
|
||||
@ -15,7 +14,6 @@ const NavMainItem = (props: NavMainItemProps) => {
|
||||
const page = usePage<SharedData>();
|
||||
const { auth, direction } = page.props;
|
||||
const { state, toggleSidebar } = useSidebar();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const { pageRoute } = props;
|
||||
const { Icon, name, path, children, slug } = pageRoute;
|
||||
@ -69,7 +67,7 @@ const NavMainItem = (props: NavMainItemProps) => {
|
||||
if (access.includes(auth.user.role)) {
|
||||
return (
|
||||
<SidebarMenuButton asChild key={index} isActive={activeChildRoute(pageRoute.slug, slug)} className="h-9 px-3">
|
||||
<Link href={path} prefetch={!isMobile}>
|
||||
<Link href={path} prefetch>
|
||||
<Dot className="w-12" />
|
||||
<span className="text-sm font-normal capitalize">{name}</span>
|
||||
</Link>
|
||||
@ -90,7 +88,7 @@ const NavMainItem = (props: NavMainItemProps) => {
|
||||
: '',
|
||||
)}
|
||||
>
|
||||
<Link href={path} prefetch={!isMobile}>
|
||||
<Link href={path} prefetch>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{name}</span>
|
||||
</Link>
|
||||
|
||||
@ -7,13 +7,12 @@ import { usePage } from '@inertiajs/react';
|
||||
import { GitCompareArrows } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import NavMainItem from './nav-main-item';
|
||||
import getRoutes from './routes';
|
||||
import routes from './routes';
|
||||
|
||||
export function NavMain() {
|
||||
const page = usePage<SharedData>();
|
||||
const { auth, system, translate } = page.props;
|
||||
const { auth, system } = page.props;
|
||||
const [openAccordions, setOpenAccordions] = useState<string>('');
|
||||
const routes = getRoutes(translate);
|
||||
|
||||
// Set initial accordion state based on URL
|
||||
useEffect(() => {
|
||||
@ -48,7 +47,7 @@ export function NavMain() {
|
||||
<SidebarMenuButton asChild className={cn('h-9')}>
|
||||
<a target="_blank" href={route('system.maintenance')}>
|
||||
<GitCompareArrows className="h-4 w-4" />
|
||||
<span>{translate.button.maintenance}</span>
|
||||
<span>Maintenance</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@ -1,19 +1,14 @@
|
||||
import { routeLastSegment } from '@/lib/route';
|
||||
import { Award, Book, Briefcase, CassetteTape, CreditCard, LayoutDashboard, Newspaper, Receipt, School, Settings, Users } from 'lucide-react';
|
||||
|
||||
const label = (value: unknown, fallback: string): string => (typeof value === 'string' && value.length ? value : fallback);
|
||||
|
||||
export default function getDashboardRoutes(translate: LanguageTranslations): DashboardRoute[] {
|
||||
const { button, settings } = translate;
|
||||
|
||||
return [
|
||||
const dashboardRoutes: DashboardRoute[] = [
|
||||
{
|
||||
title: label(button.main_menu, 'Main Menu'),
|
||||
title: 'Main Menu',
|
||||
slug: 'main-menu',
|
||||
pages: [
|
||||
{
|
||||
Icon: LayoutDashboard,
|
||||
name: label(button.dashboard, 'Dashboard'),
|
||||
name: 'Dashboard',
|
||||
path: route('dashboard'),
|
||||
slug: routeLastSegment(route('dashboard')),
|
||||
active: true,
|
||||
@ -22,32 +17,32 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
{
|
||||
Icon: School,
|
||||
name: label(button.courses, 'Courses'),
|
||||
name: 'Courses',
|
||||
path: '',
|
||||
slug: 'courses',
|
||||
active: true,
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
children: [
|
||||
{
|
||||
name: label(button.categories, 'Categories'),
|
||||
name: 'Categories',
|
||||
path: route('categories.index'),
|
||||
slug: routeLastSegment(route('categories.index')),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.manage_courses, 'Manage Courses'),
|
||||
name: 'Manage Courses',
|
||||
slug: routeLastSegment(route('courses.index')),
|
||||
path: route('courses.index'),
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.create_course, 'Create Course'),
|
||||
name: 'Create Course',
|
||||
slug: routeLastSegment(route('courses.create')),
|
||||
path: route('courses.create'),
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.course_coupons, 'Course Coupons'),
|
||||
name: 'Course Coupons',
|
||||
slug: routeLastSegment(route('course-coupons.index')),
|
||||
path: route('course-coupons.index'),
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
@ -56,32 +51,32 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
{
|
||||
Icon: Book,
|
||||
name: label(button.exams, 'Exams'),
|
||||
name: 'Exams',
|
||||
path: '',
|
||||
slug: 'exams',
|
||||
active: true,
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
children: [
|
||||
{
|
||||
name: label(button.categories, 'Categories'),
|
||||
name: 'Categories',
|
||||
slug: routeLastSegment(route('exam-categories.index')),
|
||||
path: route('exam-categories.index'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.manage_exams, 'Manage Exams'),
|
||||
name: 'Manage Exams',
|
||||
slug: routeLastSegment(route('exams.index')),
|
||||
path: route('exams.index'),
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.create_exam, 'Create Exam'),
|
||||
name: 'Create Exam',
|
||||
slug: routeLastSegment(route('exams.create')),
|
||||
path: route('exams.create'),
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.exam_coupons, 'Exam Coupons'),
|
||||
name: 'Exam Coupons',
|
||||
slug: routeLastSegment(route('exam-coupons.index')),
|
||||
path: route('exam-coupons.index'),
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
@ -90,20 +85,20 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
{
|
||||
Icon: CassetteTape,
|
||||
name: label(button.enrollments, 'Enrollments'),
|
||||
name: 'Enrollments',
|
||||
path: '',
|
||||
slug: 'enrollments',
|
||||
active: true,
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
children: [
|
||||
{
|
||||
name: label(button.course_enrollments, 'Course Enrollments'),
|
||||
name: 'Course Enrollments',
|
||||
slug: routeLastSegment(route('course-enrollments.index')),
|
||||
path: route('course-enrollments.index'),
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.exam_enrollments, 'Exam Enrollments'),
|
||||
name: 'Exam Enrollments',
|
||||
slug: routeLastSegment(route('exam-enrollments.index')),
|
||||
path: route('exam-enrollments.index'),
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
@ -112,26 +107,26 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
{
|
||||
Icon: Users,
|
||||
name: label(button.instructors, 'Instructors'),
|
||||
name: 'Instructors',
|
||||
path: '',
|
||||
slug: 'instructors',
|
||||
active: true,
|
||||
access: ['admin', 'collaborative'],
|
||||
children: [
|
||||
{
|
||||
name: label(button.manage_instructors, 'Manage Instructors'),
|
||||
name: 'Manage Instructors',
|
||||
slug: routeLastSegment(route('instructors.index')),
|
||||
path: route('instructors.index'),
|
||||
access: ['admin', 'collaborative'],
|
||||
},
|
||||
{
|
||||
name: label(button.create_instructor, 'Create Instructor'),
|
||||
name: 'Create Instructor',
|
||||
slug: routeLastSegment(route('instructors.create')),
|
||||
path: route('instructors.create'),
|
||||
access: ['admin', 'collaborative'],
|
||||
},
|
||||
{
|
||||
name: label(button.applications, 'Applications'),
|
||||
name: 'Applications',
|
||||
slug: routeLastSegment(route('instructors.applications')),
|
||||
path: route('instructors.applications', {
|
||||
status: 'pending',
|
||||
@ -142,20 +137,20 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
{
|
||||
Icon: Receipt,
|
||||
name: label(button.payouts, 'Payouts'),
|
||||
name: 'Payouts',
|
||||
path: '',
|
||||
slug: 'payouts',
|
||||
active: true,
|
||||
access: ['instructor', 'collaborative'],
|
||||
children: [
|
||||
{
|
||||
name: label(button.withdraw, 'Withdraw'),
|
||||
name: 'Withdraw',
|
||||
slug: routeLastSegment(route('payouts.index')),
|
||||
path: route('payouts.index'),
|
||||
access: ['instructor', 'collaborative'],
|
||||
},
|
||||
{
|
||||
name: label(button.settings, 'Settings'),
|
||||
name: 'Settings',
|
||||
slug: routeLastSegment(route('payouts.settings.index')),
|
||||
path: route('payouts.settings.index'),
|
||||
access: ['instructor', 'collaborative'],
|
||||
@ -164,20 +159,20 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
{
|
||||
Icon: Receipt,
|
||||
name: label(button.payout_report, 'Payout Report'),
|
||||
name: 'Payout Report',
|
||||
path: '',
|
||||
slug: 'payouts',
|
||||
active: true,
|
||||
access: ['admin', 'collaborative'],
|
||||
children: [
|
||||
{
|
||||
name: label(button.payout_request, 'Payout Request'),
|
||||
name: 'Payout Request',
|
||||
slug: routeLastSegment(route('payouts.request.index')),
|
||||
path: route('payouts.request.index'),
|
||||
access: ['admin', 'collaborative'],
|
||||
},
|
||||
{
|
||||
name: label(button.payout_history, 'Payout History'),
|
||||
name: 'Payout History',
|
||||
slug: routeLastSegment(route('payouts.history.index')),
|
||||
path: route('payouts.history.index'),
|
||||
access: ['admin', 'collaborative'],
|
||||
@ -186,20 +181,20 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
{
|
||||
Icon: CreditCard,
|
||||
name: label(button.payment_report, 'Payment Report'),
|
||||
name: 'Payment Report',
|
||||
path: '',
|
||||
slug: 'payment-reports',
|
||||
active: true,
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
children: [
|
||||
{
|
||||
name: label(button.online_payments, 'Online Payments'),
|
||||
name: 'Online Payments',
|
||||
slug: routeLastSegment(route('payment-reports.online.index')),
|
||||
path: route('payment-reports.online.index'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.offline_payments, 'Offline Payments'),
|
||||
name: 'Offline Payments',
|
||||
slug: routeLastSegment(route('payment-reports.offline.index')),
|
||||
path: route('payment-reports.offline.index'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
@ -208,20 +203,20 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
{
|
||||
Icon: Briefcase,
|
||||
name: label(button.job_circulars, 'Job Circulars'),
|
||||
name: 'Job Circulars',
|
||||
path: '',
|
||||
slug: 'job-circulars',
|
||||
active: true,
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
children: [
|
||||
{
|
||||
name: label(button.all_jobs, 'All Jobs'),
|
||||
name: 'All Jobs',
|
||||
slug: routeLastSegment(route('job-circulars.index')),
|
||||
path: route('job-circulars.index'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.create_job, 'Create Job'),
|
||||
name: 'Create Job',
|
||||
slug: routeLastSegment(route('job-circulars.create')),
|
||||
path: route('job-circulars.create'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
@ -230,26 +225,26 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
{
|
||||
Icon: Book,
|
||||
name: label(button.blogs, 'Blogs'),
|
||||
name: 'Blogs',
|
||||
path: '',
|
||||
slug: 'blogs',
|
||||
active: true,
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
children: [
|
||||
{
|
||||
name: label(button.categories, 'Categories'),
|
||||
name: 'Categories',
|
||||
slug: routeLastSegment(route('blogs.categories.index')),
|
||||
path: route('blogs.categories.index'),
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.create_blog, 'Create Blog'),
|
||||
name: 'Create Blog',
|
||||
slug: routeLastSegment(route('blogs.create')),
|
||||
path: route('blogs.create'),
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.manage_blog, 'Manage Blog'),
|
||||
name: 'Manage Blog',
|
||||
slug: routeLastSegment(route('blogs.index')),
|
||||
path: route('blogs.index'),
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
@ -258,7 +253,7 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
{
|
||||
Icon: Newspaper,
|
||||
name: label(button.newsletters, 'Newsletters'),
|
||||
name: 'Newsletters',
|
||||
path: route('newsletters.index'),
|
||||
slug: routeLastSegment(route('newsletters.index')),
|
||||
active: true,
|
||||
@ -267,7 +262,7 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
{
|
||||
Icon: Users,
|
||||
name: label(button.all_users, 'All Users'),
|
||||
name: 'All Users',
|
||||
path: route('users.index'),
|
||||
slug: routeLastSegment(route('users.index')),
|
||||
active: true,
|
||||
@ -276,20 +271,20 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
{
|
||||
Icon: Award,
|
||||
name: label(button.certificates, 'Certificates'),
|
||||
name: 'Certificates',
|
||||
path: '',
|
||||
slug: 'certification',
|
||||
active: true,
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
children: [
|
||||
{
|
||||
name: label(button.certificate, 'Certificate'),
|
||||
name: 'Certificate',
|
||||
slug: routeLastSegment(route('certificate.templates.index')),
|
||||
path: route('certificate.templates.index'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.marksheet, 'Marksheet'),
|
||||
name: 'Marksheet',
|
||||
slug: routeLastSegment(route('marksheet.templates.index')),
|
||||
path: route('marksheet.templates.index'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
@ -298,62 +293,62 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
{
|
||||
Icon: Settings,
|
||||
name: label(button.settings, 'Settings'),
|
||||
name: 'Settings',
|
||||
path: '',
|
||||
slug: 'settings',
|
||||
active: true,
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
children: [
|
||||
{
|
||||
name: label(button.account, 'Account'),
|
||||
name: 'Account',
|
||||
slug: routeLastSegment(route('settings.account')),
|
||||
path: route('settings.account'),
|
||||
access: ['admin', 'instructor', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.system, 'System'),
|
||||
name: 'System',
|
||||
slug: routeLastSegment(route('settings.system')),
|
||||
path: route('settings.system'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.pages, 'Pages'),
|
||||
name: 'Pages',
|
||||
slug: routeLastSegment(route('settings.pages')),
|
||||
path: route('settings.pages'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.storage, 'Storage'),
|
||||
name: 'Storage',
|
||||
slug: routeLastSegment(route('settings.storage')),
|
||||
path: route('settings.storage'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.payment, 'Payment'),
|
||||
name: 'Payment',
|
||||
slug: routeLastSegment(route('settings.payment')),
|
||||
path: route('settings.payment'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.smtp, 'SMTP'),
|
||||
name: 'SMTP',
|
||||
slug: routeLastSegment(route('settings.smtp')),
|
||||
path: route('settings.smtp'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.auth0, 'Auth'),
|
||||
name: 'Auth',
|
||||
slug: routeLastSegment(route('settings.auth0')),
|
||||
path: route('settings.auth0'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(button.live_class, 'Live Class'),
|
||||
name: 'Live Class',
|
||||
slug: routeLastSegment(route('settings.live-class')),
|
||||
path: route('settings.live-class'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
},
|
||||
{
|
||||
name: label(settings.translation_settings, 'Translation'),
|
||||
name: 'Translation',
|
||||
slug: routeLastSegment(route('language.index')),
|
||||
path: route('language.index'),
|
||||
access: ['admin', 'collaborative', 'administrative'],
|
||||
@ -362,5 +357,6 @@ export default function getDashboardRoutes(translate: LanguageTranslations): Das
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
];
|
||||
|
||||
export default dashboardRoutes;
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import AppLogo from '@/components/app-logo';
|
||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { NavMain } from '@/layouts/dashboard/partials/nav-main';
|
||||
import { NavUser } from '@/layouts/dashboard/partials/nav-user';
|
||||
import { SharedData } from '@/types/global';
|
||||
@ -10,7 +9,6 @@ const DashboardSidebar = () => {
|
||||
const { state } = useSidebar();
|
||||
const { props } = usePage<SharedData>();
|
||||
const compact = state === 'collapsed' ? true : false;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset" side={props.direction === 'rtl' ? 'right' : 'left'} className="shadow-md">
|
||||
@ -18,7 +16,7 @@ const DashboardSidebar = () => {
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem className="pt-1 pb-5">
|
||||
<Link href="/" prefetch={!isMobile}>
|
||||
<Link href="/" prefetch>
|
||||
<AppLogo className="h-[26px]" />
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@ -19,7 +19,6 @@ const Actions = ({ language }: { language: boolean }) => {
|
||||
const { screen } = useScreen();
|
||||
const [open, setOpen] = useState(false);
|
||||
const sortedItems = navbar.navbar_items.sort((a, b) => a.sort - b.sort);
|
||||
const showCart = system.fields?.show_course_cart !== false;
|
||||
|
||||
const actionElements = () =>
|
||||
sortedItems.map((item) => {
|
||||
@ -29,7 +28,7 @@ const Actions = ({ language }: { language: boolean }) => {
|
||||
return <Language key={item.id} />;
|
||||
} else if (isLoggedIn && item.slug === 'notification') {
|
||||
return <Notification key={item.id} />;
|
||||
} else if (isLoggedIn && item.slug === 'cart' && showCart) {
|
||||
} else if (isLoggedIn && item.slug === 'cart') {
|
||||
return <CourseCart key={item.id} />;
|
||||
} else {
|
||||
return null;
|
||||
|
||||
@ -21,7 +21,6 @@ const IntroNavbar = () => {
|
||||
const navbar = getPageSection(page, 'navbar');
|
||||
|
||||
const user = auth.user;
|
||||
const showWishlist = props.system.fields?.show_student_wishlist !== false;
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
@ -106,7 +105,7 @@ const IntroNavbar = () => {
|
||||
)}
|
||||
|
||||
{(user.role === 'student' || user.role === 'instructor') &&
|
||||
getStudentMenuItems(showWishlist).map(({ id, name, Icon, slug }) => (
|
||||
studentMenuItems.map(({ id, name, Icon, slug }) => (
|
||||
<DropdownMenuItem
|
||||
key={id}
|
||||
className="cursor-pointer px-3"
|
||||
@ -217,23 +216,19 @@ const IntroNavbar = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getStudentMenuItems = (showWishlist: boolean) => [
|
||||
const studentMenuItems = [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'My Courses',
|
||||
slug: 'courses',
|
||||
Icon: GraduationCap,
|
||||
},
|
||||
...(showWishlist
|
||||
? [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'Wishlist',
|
||||
slug: 'wishlist',
|
||||
Icon: Heart,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'My Profile',
|
||||
|
||||
@ -21,7 +21,6 @@ const LandingNavbar = () => {
|
||||
const navbar = getPageSection(page, 'navbar');
|
||||
|
||||
const user = auth.user;
|
||||
const showWishlist = props.system.fields?.show_student_wishlist !== false;
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
@ -93,12 +92,12 @@ const LandingNavbar = () => {
|
||||
{(user.role === 'admin' || user.role === 'instructor') && (
|
||||
<DropdownMenuItem className="cursor-pointer px-3" onClick={() => router.get(route('dashboard'))}>
|
||||
<LayoutDashboard className="mr-1 h-4 w-4" />
|
||||
<span>Übersicht</span>
|
||||
<span>Dashboard</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{(user.role === 'student' || user.role === 'instructor') &&
|
||||
getStudentMenuItems(showWishlist).map(({ id, name, Icon, slug }) => (
|
||||
studentMenuItems.map(({ id, name, Icon, slug }) => (
|
||||
<DropdownMenuItem
|
||||
key={id}
|
||||
className="cursor-pointer px-3"
|
||||
@ -111,7 +110,7 @@ const LandingNavbar = () => {
|
||||
|
||||
<DropdownMenuItem className="cursor-pointer px-3" onClick={() => router.post('/logout')}>
|
||||
<LogOut className="mr-1 h-4 w-4" />
|
||||
<span>Abmelden</span>
|
||||
<span>Log Out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@ -119,10 +118,10 @@ const LandingNavbar = () => {
|
||||
) : (
|
||||
<div className="hidden items-center gap-3 md:flex">
|
||||
<Button asChild variant="outline" className="h-auto rounded-sm px-5 py-2.5 shadow-none">
|
||||
<Link href={route('register')}>Registrieren</Link>
|
||||
<Link href={route('register')}>Sign Up</Link>
|
||||
</Button>
|
||||
<Button asChild className="h-auto rounded-sm px-5 py-2.5 shadow-none">
|
||||
<Link href={route('login')}>Anmelden</Link>
|
||||
<Link href={route('login')}>Log In</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@ -154,10 +153,10 @@ const LandingNavbar = () => {
|
||||
user.role === 'admin' ? (
|
||||
<>
|
||||
<Link href={route('dashboard')} className="text-sm font-normal">
|
||||
Übersicht
|
||||
Dashboard
|
||||
</Link>
|
||||
<Button variant="outline" onClick={() => router.post(route('logout'))}>
|
||||
Abmelden
|
||||
Log Out
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@ -168,19 +167,19 @@ const LandingNavbar = () => {
|
||||
</Link>
|
||||
)}
|
||||
<Link href={route('student.index', { tab: 'courses' })} className="text-sm font-normal">
|
||||
Meine Kurse
|
||||
My Courses
|
||||
</Link>
|
||||
<Link href={route('student.index', { tab: 'wishlist' })} className="text-sm font-normal">
|
||||
Wunschliste
|
||||
Wishlist
|
||||
</Link>
|
||||
<Link href={route('student.index', { tab: 'profile' })} className="text-sm font-normal">
|
||||
Mein Profil
|
||||
My Profile
|
||||
</Link>
|
||||
<Link href={route('student.index', { tab: 'settings' })} className="text-sm font-normal">
|
||||
Einstellungen
|
||||
Settings
|
||||
</Link>
|
||||
<Button variant="secondary" onClick={() => router.post(route('logout'))}>
|
||||
Abmelden
|
||||
Log Out
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
@ -206,23 +205,19 @@ const LandingNavbar = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getStudentMenuItems = (showWishlist: boolean) => [
|
||||
const studentMenuItems = [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'My Courses',
|
||||
slug: 'courses',
|
||||
Icon: GraduationCap,
|
||||
},
|
||||
...(showWishlist
|
||||
? [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'Wishlist',
|
||||
slug: 'wishlist',
|
||||
Icon: Heart,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'My Profile',
|
||||
|
||||
@ -29,7 +29,7 @@ export default function Recaptcha({ status }: { status?: string }) {
|
||||
{button.submit}
|
||||
</Button>
|
||||
|
||||
<Button type="button" onClick={() => router.post(route('logout'))} className="mx-auto block text-sm">
|
||||
<Button onClick={() => router.post(route('logout'))} className="mx-auto block text-sm">
|
||||
{button.logout}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@ -5,7 +5,6 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { CoursePlayerProps } from '@/types/page';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import jsPDF from 'jspdf';
|
||||
import { Award, Calendar, Download, FileImage, FileText } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
@ -18,9 +17,7 @@ const Certificate = () => {
|
||||
|
||||
const courseName = props.course.title;
|
||||
const studentName = props.auth.user.name;
|
||||
const completionDate = props.watchHistory?.completion_date
|
||||
? format(parseISO(props.watchHistory.completion_date), 'dd. MMMM yyyy', { locale: de })
|
||||
: '';
|
||||
const completionDate = format(parseISO(props.watchHistory.completion_date), 'MMM d, yyyy');
|
||||
const [downloadFormat, setDownloadFormat] = useState('png');
|
||||
const certificateRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = { width: 900, height: 600 }; // Standard
|
||||
@ -52,7 +49,7 @@ const Certificate = () => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${studentName}_${courseName}_Zertifikat.png`;
|
||||
a.download = `${studentName}_${courseName}_Certificate.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
@ -89,7 +86,7 @@ const Certificate = () => {
|
||||
pdf.addImage(imgData, 'PNG', 0, 0, dimensions.width, dimensions.height);
|
||||
|
||||
// Save the PDF
|
||||
pdf.save(`${studentName}_${courseName}_Zertifikat.pdf`);
|
||||
pdf.save(`${studentName}_${courseName}_Certificate.pdf`);
|
||||
|
||||
toast.success(frontend.pdf_certificate_saved);
|
||||
};
|
||||
@ -258,21 +255,21 @@ const Certificate = () => {
|
||||
<RadioGroupItem className="cursor-pointer" value="png" id="png" />
|
||||
<Label htmlFor="png" className="flex cursor-pointer items-center gap-2">
|
||||
<FileImage className="h-4 w-4" />
|
||||
PNG-Bild
|
||||
PNG Image
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem className="cursor-pointer" value="pdf" id="pdf" />
|
||||
<Label htmlFor="pdf" className="flex cursor-pointer items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
PDF-Dokument
|
||||
PDF Document
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={handleDownloadCertificate}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Herunterladen als {downloadFormat.toUpperCase()}
|
||||
Download as {downloadFormat.toUpperCase()}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@ -27,12 +27,8 @@ interface ContentListProps {
|
||||
|
||||
const ContentList = ({ completedContents, courseCompletion }: ContentListProps) => {
|
||||
const { props } = usePage<CoursePlayerProps>();
|
||||
const { course, zoomConfig, section, watchHistory, translate, direction, system } = props;
|
||||
const { button, common, frontend } = translate;
|
||||
const showCourseCertificate = system?.fields?.show_course_certificate ?? true;
|
||||
const showCourseMarksheet = system?.fields?.show_course_marksheet ?? showCourseCertificate;
|
||||
const showCertificateTab = showCourseCertificate || showCourseMarksheet;
|
||||
const certificateLabel = showCourseCertificate ? frontend.course_certificate : button.marksheet || 'Notenblatt';
|
||||
const { course, zoomConfig, section, watchHistory, translate, direction } = props;
|
||||
const { button, common } = translate;
|
||||
|
||||
// Get live classes from course data
|
||||
const liveClasses = course.live_classes || [];
|
||||
@ -107,7 +103,7 @@ const ContentList = ({ completedContents, courseCompletion }: ContentListProps)
|
||||
</>
|
||||
) : (
|
||||
<div className="px-4 py-3 text-center">
|
||||
<p>Keine Lektion vorhanden</p>
|
||||
<p>There is no lesson added</p>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
@ -119,11 +115,11 @@ const ContentList = ({ completedContents, courseCompletion }: ContentListProps)
|
||||
<Link
|
||||
href={route('student.course.show', {
|
||||
id: course.id,
|
||||
tab: showCertificateTab ? 'certificate' : 'modules',
|
||||
tab: 'certificate',
|
||||
})}
|
||||
>
|
||||
<Button className="w-full" variant="secondary" size="lg" disabled={courseCompletion.percentage !== '100.00'}>
|
||||
{showCertificateTab ? certificateLabel : 'Module'}
|
||||
Course Certificate
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@ -131,11 +127,11 @@ const ContentList = ({ completedContents, courseCompletion }: ContentListProps)
|
||||
<div>
|
||||
{!watchHistory.next_watching_id ? (
|
||||
<Button className="w-full" variant="secondary" size="lg" onClick={finishCourseHandler}>
|
||||
Kurs abschließen
|
||||
Finish Course
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" variant="secondary" size="lg" disabled>
|
||||
Kurs abschließen
|
||||
Finish Course
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -143,7 +139,7 @@ const ContentList = ({ completedContents, courseCompletion }: ContentListProps)
|
||||
</Accordion>
|
||||
) : (
|
||||
<div className="p-6 text-center">
|
||||
<p>Kein Abschnitt vorhanden</p>
|
||||
<p>There is no section added</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
@ -152,8 +148,8 @@ const ContentList = ({ completedContents, courseCompletion }: ContentListProps)
|
||||
{liveClasses.length <= 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Calendar className="mx-auto mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium">Keine Live-Sitzungen geplant</h3>
|
||||
<p className="text-gray-500">Plane deine erste Live-Session, um mit Zoom zu starten.</p>
|
||||
<h3 className="mb-2 text-lg font-medium">No Live Classes Scheduled</h3>
|
||||
<p className="text-gray-500">Schedule your first live class to get started with Zoom.</p>
|
||||
</Card>
|
||||
) : (
|
||||
liveClasses.map((liveClass) => {
|
||||
@ -182,7 +178,7 @@ const ContentList = ({ completedContents, courseCompletion }: ContentListProps)
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="item-1" className="bg-muted overflow-hidden rounded-lg border-none">
|
||||
<AccordionTrigger className="[&[data-state=open]]:!bg-secondary-lighter px-3 py-1.5 text-sm font-normal hover:no-underline">
|
||||
Hinweis zur Sitzung
|
||||
Class Note
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-3">
|
||||
<Renderer value={liveClass.class_note} />
|
||||
|
||||
@ -17,7 +17,6 @@ const Navbar = () => {
|
||||
const { translate } = props;
|
||||
const { button } = translate;
|
||||
const user = props.auth.user;
|
||||
const showWishlist = props.system.fields?.show_student_wishlist !== false;
|
||||
|
||||
return (
|
||||
<header className="bg-primary dark:bg-primary-dark text-primary-foreground dark:text-primary sticky top-0 z-50 h-[60px]">
|
||||
@ -62,7 +61,7 @@ const Navbar = () => {
|
||||
)}
|
||||
|
||||
{(user.role === 'student' || user.role === 'instructor') &&
|
||||
getStudentMenuItems(button, showWishlist).map(({ id, name, Icon, slug }) => (
|
||||
getStudentMenuItems(button).map(({ id, name, Icon, slug }) => (
|
||||
<DropdownMenuItem
|
||||
key={id}
|
||||
className="cursor-pointer px-3"
|
||||
@ -96,23 +95,19 @@ const Navbar = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getStudentMenuItems = (button: any, showWishlist: boolean) => [
|
||||
const getStudentMenuItems = (button: any) => [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: button.my_courses,
|
||||
slug: 'courses',
|
||||
Icon: GraduationCap,
|
||||
},
|
||||
...(showWishlist
|
||||
? [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: button.wishlist,
|
||||
slug: 'wishlist',
|
||||
Icon: Heart,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: nanoid(),
|
||||
name: button.profile,
|
||||
|
||||
@ -29,8 +29,6 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
|
||||
const [finished, setFinished] = useState(false);
|
||||
const [currentTab, setCurrentTab] = useState('summary');
|
||||
const submissions = quiz.quiz_submissions;
|
||||
const attemptsUsed = submissions[0]?.attempts || 0;
|
||||
const hasAttemptLimit = quiz.retake > 0;
|
||||
|
||||
const { data, setData, post, reset, processing } = useForm({
|
||||
submission_id: submissions.length > 0 ? submissions[0].id : null,
|
||||
@ -190,7 +188,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
|
||||
</div>
|
||||
<div className="flex gap-2 text-sm">
|
||||
<p className="text-gray-500">{frontend.retake}</p>
|
||||
<p>: {hasAttemptLimit ? quiz.retake : 'Unlimited'}</p>
|
||||
<p>: {quiz.retake}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -198,7 +196,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
|
||||
|
||||
<div className="flex gap-2 text-sm">
|
||||
<p className="text-gray-500">{frontend.retake_attempts}</p>
|
||||
<p>: {attemptsUsed}</p>
|
||||
<p>: {submissions[0]?.attempts || 0}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 text-sm">
|
||||
<p className="text-gray-500">{frontend.correct_answers}</p>
|
||||
@ -220,7 +218,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center p-6">
|
||||
{hasAttemptLimit && attemptsUsed >= quiz.retake ? (
|
||||
{submissions[0]?.attempts >= quiz.retake ? (
|
||||
<Button type="button" size="lg">
|
||||
{frontend.quiz_submitted}
|
||||
</Button>
|
||||
|
||||
@ -70,9 +70,8 @@ const EnrollmentButton = ({ auth, course }: { auth: Auth; course: Course }) => {
|
||||
};
|
||||
|
||||
const EnrollOrPlayerButton = () => {
|
||||
const { auth, course, enrollment, watchHistory, approvalStatus, wishlists, translate, system } = usePage<CourseDetailsProps>().props;
|
||||
const { auth, course, enrollment, watchHistory, approvalStatus, wishlists, translate } = usePage<CourseDetailsProps>().props;
|
||||
const { frontend } = translate;
|
||||
const showWishlist = system.fields?.show_student_wishlist !== false;
|
||||
|
||||
// Compute access conditions - improves readability
|
||||
const isEnrolled = !!enrollment;
|
||||
@ -98,11 +97,9 @@ const EnrollOrPlayerButton = () => {
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{showWishlist && (
|
||||
<Button className="w-full px-1 sm:px-3" variant="outline" size="lg" onClick={handleWishlist}>
|
||||
{isWishlisted ? frontend.remove_from_wishlist : frontend.add_to_wishlist}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<EnrollmentButton auth={auth} course={course} />
|
||||
</>
|
||||
|
||||
@ -117,7 +117,7 @@ const CoursePreview = () => {
|
||||
<Mail className="h-5 w-5" />
|
||||
{frontend.certificate_included}
|
||||
</span>
|
||||
<span>{course.certificate_included ? 'Ja' : 'Nein'}</span>
|
||||
<span>Yes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,61 +1,24 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import DashboardLayout from '@/layouts/dashboard/layout';
|
||||
import { SharedData } from '@/types/global';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { Award, Plus } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import CertificateCard from './partials/certificate-card';
|
||||
|
||||
interface CertificatePageProps extends SharedData {
|
||||
templates: CertificateTemplate[];
|
||||
}
|
||||
|
||||
const CertificateIndex = ({ templates, system }: CertificatePageProps) => {
|
||||
const CertificateIndex = ({ templates }: CertificatePageProps) => {
|
||||
const examTemplates = templates.filter((template) => template.type === 'exam');
|
||||
const courseTemplates = templates.filter((template) => template.type === 'course');
|
||||
|
||||
const initialCourseCertificateEnabled = system?.fields?.show_course_certificate ?? true;
|
||||
const [courseCertificateEnabled, setCourseCertificateEnabled] = useState<boolean>(initialCourseCertificateEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
setCourseCertificateEnabled(initialCourseCertificateEnabled);
|
||||
}, [initialCourseCertificateEnabled]);
|
||||
|
||||
const handleCourseCertificateToggle = (checked: boolean) => {
|
||||
const previous = courseCertificateEnabled;
|
||||
setCourseCertificateEnabled(checked);
|
||||
router.post(
|
||||
route('certification.settings.update'),
|
||||
{ show_course_certificate: checked },
|
||||
{ preserveScroll: true, preserveState: true, onError: () => setCourseCertificateEnabled(previous) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Certificate Templates" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label htmlFor="show_course_certificate" className="text-base font-semibold">
|
||||
Course Certificate (Student)
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">Enable/disable course certificates for students.</p>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
id="show_course_certificate"
|
||||
checked={courseCertificateEnabled}
|
||||
onCheckedChange={handleCourseCertificateToggle}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Certificate Templates</h2>
|
||||
|
||||
@ -1,57 +1,24 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import DashboardLayout from '@/layouts/dashboard/layout';
|
||||
import { SharedData } from '@/types/global';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { ClipboardList, Plus } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import MarkSheetCard from './partials/marksheet-card';
|
||||
|
||||
interface MarksheetPageProps extends SharedData {
|
||||
templates: MarksheetTemplate[];
|
||||
}
|
||||
|
||||
const MarksheetIndex = ({ templates, system }: MarksheetPageProps) => {
|
||||
const MarksheetIndex = ({ templates }: MarksheetPageProps) => {
|
||||
const examTemplates = templates.filter((template) => template.type === 'exam');
|
||||
const courseTemplates = templates.filter((template) => template.type === 'course');
|
||||
|
||||
const initialCourseMarksheetEnabled = system?.fields?.show_course_marksheet ?? true;
|
||||
const [courseMarksheetEnabled, setCourseMarksheetEnabled] = useState<boolean>(initialCourseMarksheetEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
setCourseMarksheetEnabled(initialCourseMarksheetEnabled);
|
||||
}, [initialCourseMarksheetEnabled]);
|
||||
|
||||
const handleCourseMarksheetToggle = (checked: boolean) => {
|
||||
const previous = courseMarksheetEnabled;
|
||||
setCourseMarksheetEnabled(checked);
|
||||
router.post(
|
||||
route('certification.settings.update'),
|
||||
{ show_course_marksheet: checked },
|
||||
{ preserveScroll: true, preserveState: true, onError: () => setCourseMarksheetEnabled(previous) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Certificate & Marksheet Templates" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label htmlFor="show_course_marksheet" className="text-base font-semibold">
|
||||
Course Marksheet (Student)
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">Enable/disable course marksheets for students.</p>
|
||||
</div>
|
||||
|
||||
<Switch id="show_course_marksheet" checked={courseMarksheetEnabled} onCheckedChange={handleCourseMarksheetToggle} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Marksheet Templates</h2>
|
||||
|
||||
@ -22,10 +22,10 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
|
||||
secondaryColor: '#4b5563',
|
||||
backgroundColor: '#dbeafe',
|
||||
borderColor: '#f59e0b',
|
||||
titleText: 'Zertifikat über den Abschluss',
|
||||
descriptionText: 'Dieses Zertifikat wird feierlich überreicht an',
|
||||
completionText: 'für den erfolgreichen Abschluss des Kurses',
|
||||
footerText: 'Offizielles Zertifikat',
|
||||
titleText: 'Certificate of Completion',
|
||||
descriptionText: 'This certificate is proudly presented to',
|
||||
completionText: 'for successfully completing the course',
|
||||
footerText: 'Authorized Certificate',
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
});
|
||||
@ -53,31 +53,31 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Grundinformationen</CardTitle>
|
||||
<CardDescription>Vorlagenname und Aktivierungsstatus festlegen</CardDescription>
|
||||
<CardTitle>Basic Information</CardTitle>
|
||||
<CardDescription>Set the template name and activation status</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Vorlagentyp</Label>
|
||||
<Label htmlFor="type">Template Type</Label>
|
||||
<Select value={data.type} onValueChange={(value) => setData('type', value as any)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Vorlagentyp wählen" />
|
||||
<SelectValue placeholder="Select template type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="course">Kurs</SelectItem>
|
||||
<SelectItem value="exam">Prüfung</SelectItem>
|
||||
<SelectItem value="course">Course</SelectItem>
|
||||
<SelectItem value="exam">Exam</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.type && <p className="text-sm text-red-500">{errors.type}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Vorlagenname</Label>
|
||||
<Label htmlFor="name">Template Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={data.name}
|
||||
onChange={(e) => setData('name', e.target.value)}
|
||||
placeholder="z. B. Modernes blaues Zertifikat"
|
||||
placeholder="e.g., Modern Blue Certificate"
|
||||
/>
|
||||
{errors.name && <p className="text-sm text-red-500">{errors.name}</p>}
|
||||
</div>
|
||||
@ -87,11 +87,11 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Logo & Branding</CardTitle>
|
||||
<CardDescription>Logo Ihrer Institution oder des Kurses hochladen</CardDescription>
|
||||
<CardDescription>Upload your institution or course logo</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logo">Logo-Bild</Label>
|
||||
<Label htmlFor="logo">Logo Image</Label>
|
||||
<div className="space-y-2">
|
||||
{logoPreview && (
|
||||
<div className="h-20 w-20 overflow-hidden rounded border">
|
||||
@ -102,7 +102,7 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
|
||||
<Input id="logo" type="file" accept="image/*" onChange={(e) => onLogoChange('logo', e.target.files?.[0])} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">Empfohlen: PNG oder SVG, max. 1MB</p>
|
||||
<p className="text-muted-foreground text-xs">Recommended: PNG or SVG, max 1MB</p>
|
||||
<InputError message={errors.logo} />
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -110,13 +110,13 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Farben</CardTitle>
|
||||
<CardDescription>Farbgestaltung des Zertifikats anpassen</CardDescription>
|
||||
<CardTitle>Colors</CardTitle>
|
||||
<CardDescription>Customize the certificate color scheme</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="primaryColor">Primärfarbe</Label>
|
||||
<Label htmlFor="primaryColor">Primary Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="primaryColor"
|
||||
@ -134,7 +134,7 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="secondaryColor">Sekundärfarbe</Label>
|
||||
<Label htmlFor="secondaryColor">Secondary Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="secondaryColor"
|
||||
@ -152,7 +152,7 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundColor">Hintergrundfarbe</Label>
|
||||
<Label htmlFor="backgroundColor">Background Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="backgroundColor"
|
||||
@ -170,7 +170,7 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderColor">Rahmenfarbe</Label>
|
||||
<Label htmlFor="borderColor">Border Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="borderColor"
|
||||
@ -192,12 +192,12 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Typografie</CardTitle>
|
||||
<CardDescription>Schriftstil für das Zertifikat wählen</CardDescription>
|
||||
<CardTitle>Typography</CardTitle>
|
||||
<CardDescription>Choose the font style for your certificate</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontFamily">Schriftfamilie</Label>
|
||||
<Label htmlFor="fontFamily">Font Family</Label>
|
||||
<Select
|
||||
value={data.template_data.fontFamily}
|
||||
onValueChange={(value) => setData('template_data', { ...data.template_data, fontFamily: value })}
|
||||
@ -206,10 +206,10 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="serif">Serif (Klassisch)</SelectItem>
|
||||
<SelectItem value="serif">Serif (Classic)</SelectItem>
|
||||
<SelectItem value="sans-serif">Sans Serif (Modern)</SelectItem>
|
||||
<SelectItem value="monospace">Monospace (Technisch)</SelectItem>
|
||||
<SelectItem value="cursive">Kursive (Elegant)</SelectItem>
|
||||
<SelectItem value="monospace">Monospace (Technical)</SelectItem>
|
||||
<SelectItem value="cursive">Cursive (Elegant)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@ -218,48 +218,48 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Zertifikattext</CardTitle>
|
||||
<CardDescription>Textinhalt des Zertifikats anpassen</CardDescription>
|
||||
<CardTitle>Certificate Text</CardTitle>
|
||||
<CardDescription>Customize the text content of your certificate</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="titleText">Titeltext</Label>
|
||||
<Label htmlFor="titleText">Title Text</Label>
|
||||
<Input
|
||||
id="titleText"
|
||||
value={data.template_data.titleText}
|
||||
onChange={(e) => setData('template_data', { ...data.template_data, titleText: e.target.value })}
|
||||
placeholder="Zertifikat über den Abschluss"
|
||||
placeholder="Certificate of Completion"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="descriptionText">Beschreibungstext</Label>
|
||||
<Label htmlFor="descriptionText">Description Text</Label>
|
||||
<Textarea
|
||||
id="descriptionText"
|
||||
value={data.template_data.descriptionText}
|
||||
onChange={(e) => setData('template_data', { ...data.template_data, descriptionText: e.target.value })}
|
||||
placeholder="Dieses Zertifikat wird feierlich überreicht an"
|
||||
placeholder="This certificate is proudly presented to"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="completionText">Abschlusstext</Label>
|
||||
<Label htmlFor="completionText">Completion Text</Label>
|
||||
<Input
|
||||
id="completionText"
|
||||
value={data.template_data.completionText}
|
||||
onChange={(e) => setData('template_data', { ...data.template_data, completionText: e.target.value })}
|
||||
placeholder="für den erfolgreichen Abschluss des Kurses"
|
||||
placeholder="for successfully completing the course"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="footerText">Fußzeilentext</Label>
|
||||
<Label htmlFor="footerText">Footer Text</Label>
|
||||
<Input
|
||||
id="footerText"
|
||||
value={data.template_data.footerText}
|
||||
onChange={(e) => setData('template_data', { ...data.template_data, footerText: e.target.value })}
|
||||
placeholder="Offizielles Zertifikat"
|
||||
placeholder="Authorized Certificate"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -267,7 +267,7 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
|
||||
|
||||
<Button onClick={handleSubmit} disabled={processing} className="w-full">
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{processing ? 'Speichere...' : template ? 'Vorlage aktualisieren' : 'Vorlage erstellen'}
|
||||
{processing ? 'Saving...' : template ? 'Update Template' : 'Create Template'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -275,8 +275,8 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
|
||||
<div className="lg:sticky lg:top-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Echtzeit-Vorschau</CardTitle>
|
||||
<CardDescription>So sieht Ihr Zertifikat aus</CardDescription>
|
||||
<CardTitle>Live Preview</CardTitle>
|
||||
<CardDescription>See how your certificate will look</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CertificatePreview
|
||||
|
||||
@ -146,7 +146,7 @@ const CertificatePreview = ({ template, studentName, courseName, completionDate,
|
||||
fontFamily: template_data.fontFamily,
|
||||
}}
|
||||
>
|
||||
Abgeschlossen am: {completionDate}
|
||||
Completed on: {completionDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ const QuizForm = ({ title, quiz, handler, sectionId }: Props) => {
|
||||
course_id: props.course.id,
|
||||
total_mark: quiz?.total_mark || '',
|
||||
pass_mark: quiz?.pass_mark || '',
|
||||
retake: quiz?.retake ?? 1,
|
||||
retake: quiz?.retake || 1,
|
||||
summary: quiz?.summary || '',
|
||||
hours: quiz?.hours || '',
|
||||
minutes: quiz?.minutes || '',
|
||||
@ -135,7 +135,7 @@ const QuizForm = ({ title, quiz, handler, sectionId }: Props) => {
|
||||
<div>
|
||||
<Label>{input.retake_attempts}</Label>
|
||||
<Input
|
||||
min="0"
|
||||
min="1"
|
||||
required
|
||||
type="number"
|
||||
name="retake"
|
||||
@ -143,7 +143,6 @@ const QuizForm = ({ title, quiz, handler, sectionId }: Props) => {
|
||||
placeholder="00"
|
||||
onChange={(e) => onHandleChange(e, setData)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">0 = Unlimited attempts</p>
|
||||
<InputError message={errors.retake} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -218,14 +218,10 @@ const CreateExam = (props: Props) => {
|
||||
type="number"
|
||||
name="max_attempts"
|
||||
value={data.max_attempts.toString()}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
setData('max_attempts', Number.isNaN(value) ? 0 : value);
|
||||
}}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
onChange={(e) => setData('max_attempts', parseInt(e.target.value) || 1)}
|
||||
placeholder="3"
|
||||
min="1"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">0 = Unlimited attempts</p>
|
||||
<InputError message={errors.max_attempts} />
|
||||
</div>
|
||||
|
||||
|
||||
@ -12,8 +12,6 @@ interface Props {
|
||||
}
|
||||
|
||||
const ExamSettingsForm = ({ data, setData, errors }: Props) => {
|
||||
const attemptsValue = Number.isFinite(data.max_attempts) ? data.max_attempts : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@ -81,18 +79,16 @@ const ExamSettingsForm = ({ data, setData, errors }: Props) => {
|
||||
<Label htmlFor="max_attempts">Maximum Attempts Allowed *</Label>
|
||||
<div className="space-y-2">
|
||||
<Slider
|
||||
value={[attemptsValue]}
|
||||
value={[data.max_attempts || 1]}
|
||||
onValueChange={(values) => setData('max_attempts', values[0])}
|
||||
min={0}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="py-4"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>0 (Unlimited)</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{attemptsValue === 0 ? 'Unlimited attempts' : `${attemptsValue} attempt(s)`}
|
||||
</span>
|
||||
<span>1 attempt</span>
|
||||
<span className="font-semibold text-gray-900">{data.max_attempts || 1} attempt(s)</span>
|
||||
<span>10 attempts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -15,7 +15,7 @@ const ExamSettings = () => {
|
||||
duration_hours: exam.duration_hours || 1,
|
||||
duration_minutes: exam.duration_minutes || 0,
|
||||
pass_mark: exam.pass_mark || 50,
|
||||
max_attempts: exam.max_attempts ?? 3,
|
||||
max_attempts: exam.max_attempts || 3,
|
||||
total_marks: exam.total_marks || 100,
|
||||
});
|
||||
|
||||
@ -77,15 +77,12 @@ const ExamSettings = () => {
|
||||
type="number"
|
||||
name="max_attempts"
|
||||
value={data.max_attempts.toString()}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
setData('max_attempts', Number.isNaN(value) ? 0 : value);
|
||||
}}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
onChange={(e) => setData('max_attempts', parseInt(e.target.value) || 1)}
|
||||
placeholder="3"
|
||||
min="1"
|
||||
/>
|
||||
<InputError message={errors.max_attempts} />
|
||||
<p className="mt-1 text-xs text-gray-500">0 = Unlimited attempts</p>
|
||||
<p className="mt-1 text-xs text-gray-500">Maximum number of attempts allowed per student</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@ -110,7 +110,7 @@ const ShowExam = ({ exam, stats }: Props) => {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Max Attempts</p>
|
||||
<p className="font-semibold">{exam.max_attempts === 0 ? 'Unlimited' : exam.max_attempts}</p>
|
||||
<p className="font-semibold">{exam.max_attempts}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Level</p>
|
||||
|
||||
@ -6,7 +6,6 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import currencies from '@/data/currencies';
|
||||
import { onHandleChange } from '@/lib/inertia';
|
||||
import { SharedData } from '@/types/global';
|
||||
@ -25,12 +24,6 @@ const Website = () => {
|
||||
const { translate } = props;
|
||||
const { input, settings } = translate;
|
||||
|
||||
const featureDefaults = {
|
||||
show_course_cart: props.system.fields?.show_course_cart ?? true,
|
||||
show_student_exams: props.system.fields?.show_student_exams ?? true,
|
||||
show_student_wishlist: props.system.fields?.show_student_wishlist ?? true,
|
||||
};
|
||||
|
||||
const mediaFields: MediaFields = {
|
||||
new_logo_dark: null,
|
||||
new_logo_light: null,
|
||||
@ -39,7 +32,6 @@ const Website = () => {
|
||||
};
|
||||
|
||||
const { data, setData, post, errors, processing } = useForm({
|
||||
...featureDefaults,
|
||||
...(props.system.fields as SystemFields),
|
||||
...(mediaFields as MediaFields),
|
||||
});
|
||||
@ -279,40 +271,6 @@ const Website = () => {
|
||||
<InputError message={errors.instructor_revenue} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="md:col-span-3 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Show Course Cart</Label>
|
||||
<p className="text-muted-foreground text-xs">Enable/disable course cart page and icon.</p>
|
||||
</div>
|
||||
<Switch id="show_course_cart" checked={!!data.show_course_cart} onCheckedChange={(checked) => setData('show_course_cart', checked)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Show Student Exams</Label>
|
||||
<p className="text-muted-foreground text-xs">Toggle visibility of the student exams tab.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="show_student_exams"
|
||||
checked={!!data.show_student_exams}
|
||||
onCheckedChange={(checked) => setData('show_student_exams', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Show Wishlist</Label>
|
||||
<p className="text-muted-foreground text-xs">Toggle visibility of the student wishlist tab.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="show_student_wishlist"
|
||||
checked={!!data.show_student_wishlist}
|
||||
onCheckedChange={(checked) => setData('show_student_wishlist', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -5,14 +5,14 @@ import { Card } from '@/components/ui/card';
|
||||
import Dashboard from '@/layouts/dashboard/layout';
|
||||
import { SharedData } from '@/types/global';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { Pencil, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import AddLanguage from './partials/add-language';
|
||||
|
||||
const Index = () => {
|
||||
const { props } = usePage<SharedData>();
|
||||
const { translate } = props;
|
||||
const { settings, common, button: buttonLabels } = translate;
|
||||
const { settings, common } = translate;
|
||||
|
||||
const languageStatus = (lang: Language, checked: boolean) => {
|
||||
router.put(
|
||||
@ -30,12 +30,6 @@ const Index = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const syncFromFiles = (lang: Language) => {
|
||||
router.post(route('language.sync', lang.code), {}, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={settings.language_settings} />
|
||||
@ -47,10 +41,10 @@ const Index = () => {
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-blue-50 p-4 text-sm text-blue-700">
|
||||
<div className="mb-2 font-medium">{settings.translation_scope_information}</div>
|
||||
<div className="mb-2 font-medium">Translation Scope Information</div>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>{settings.translation_scope_dashboard}</li>
|
||||
<li>{settings.translation_scope_public_pages}</li>
|
||||
<li>Translations will be applied to dashboard interfaces (admin, instructor, student).</li>
|
||||
<li>Public pages are not affected by these translations as they are fully customizable through the page editor.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@ -69,9 +63,6 @@ const Index = () => {
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="icon" variant="secondary" className="mr-3 h-7 w-7 rounded-full text-blue-500" onClick={() => syncFromFiles(lang)}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Switch disabled checked name={lang.code} />
|
||||
</div>
|
||||
</div>
|
||||
@ -84,7 +75,7 @@ const Index = () => {
|
||||
<div className="flex items-center gap-3">
|
||||
{lang.is_active ? (
|
||||
<Button onClick={() => defaultLanguage(lang)} size="sm" variant="secondary" className="rounded-full">
|
||||
{buttonLabels.set_default}
|
||||
Set Default
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@ -93,9 +84,6 @@ const Index = () => {
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="icon" variant="secondary" className="h-7 w-7 rounded-full text-blue-500" onClick={() => syncFromFiles(lang)}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<DeleteModal
|
||||
routePath={route('language.destroy', lang.id)}
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
import InputError from '@/components/input-error';
|
||||
import LoadingButton from '@/components/loading-button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { SharedData } from '@/types/global';
|
||||
import { useForm, usePage } from '@inertiajs/react';
|
||||
import { ReactNode, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
actionComponent: ReactNode;
|
||||
}
|
||||
|
||||
const InviteForm = ({ actionComponent }: Props) => {
|
||||
const { props } = usePage<SharedData>();
|
||||
const { translate } = props;
|
||||
const { dashboard, input, common, button } = translate;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data, post, setData, processing, errors, reset } = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
post(route('users.store'), {
|
||||
onSuccess: () => {
|
||||
reset();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{actionComponent}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dashboard.invite_user}</DialogTitle>
|
||||
<p className="text-muted-foreground text-sm">{dashboard.invite_user_description}</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-4 space-y-4 text-start">
|
||||
<div>
|
||||
<Label>{input.name}</Label>
|
||||
<Input required value={data.name} onChange={(e) => setData('name', e.target.value)} />
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{input.email}</Label>
|
||||
<Input type="email" required value={data.email} onChange={(e) => setData('email', e.target.value)} />
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{input.status}</Label>
|
||||
<Select value={data.status === 1 ? 'active' : 'inactive'} onValueChange={(value) => setData('status', value === 'active' ? 1 : 0)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={dashboard.select_approval_status} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">{common.active}</SelectItem>
|
||||
<SelectItem value="inactive">{common.inactive}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.status} />
|
||||
</div>
|
||||
|
||||
<LoadingButton loading={processing} className="w-full">
|
||||
{button.send ?? button.submit}
|
||||
</LoadingButton>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteForm;
|
||||
@ -1,15 +1,12 @@
|
||||
import DeleteModal from '@/components/inertia/delete-modal';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { ArrowUpDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import EditForm from './edit-form';
|
||||
|
||||
const TableColumn = (translate: LanguageTranslations): ColumnDef<User>[] => {
|
||||
const { table, common } = translate;
|
||||
const verifiedLabel = common?.verified ?? 'Verified';
|
||||
const notVerifiedLabel = common?.not_verified ?? 'Not Verified';
|
||||
|
||||
return [
|
||||
{
|
||||
@ -47,15 +44,6 @@ const TableColumn = (translate: LanguageTranslations): ColumnDef<User>[] => {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'email_verified_at',
|
||||
header: table?.verified ?? 'Verified',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.email_verified_at ? 'default' : 'secondary'}>
|
||||
{row.original.email_verified_at ? verifiedLabel : notVerifiedLabel}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: table.role,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user