feat: implement automated Swiper slider seeding and integrate slider component into training details page

This commit is contained in:
2026-07-28 09:54:51 +07:00
parent bc27a9ea9c
commit 01a3b83672
23 changed files with 10726 additions and 230 deletions
@@ -0,0 +1,86 @@
---
name: gitnexus-cli
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
---
# GitNexus CLI Commands
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
## Commands
### analyze — Build or refresh the index
```bash
node .gitnexus/run.cjs analyze
```
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
| Flag | Effect |
| -------------- | ---------------------------------------------------------------- |
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
### status — Check index freshness
```bash
node .gitnexus/run.cjs status
```
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
### clean — Delete the index
```bash
node .gitnexus/run.cjs clean
```
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
| Flag | Effect |
| --------- | ------------------------------------------------- |
| `--force` | Skip confirmation prompt |
| `--all` | Clean all indexed repos, not just the current one |
### wiki — Generate documentation from the graph
```bash
node .gitnexus/run.cjs wiki
```
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
| Flag | Effect |
| ------------------- | ----------------------------------------- |
| `--force` | Force full regeneration |
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
| `--gist` | Publish wiki as a public GitHub Gist |
### list — Show all indexed repos
```bash
node .gitnexus/run.cjs list
```
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
## After Indexing
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
## Troubleshooting
- **"Not inside a git repository"**: Run from a directory inside a git repo
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
@@ -0,0 +1,101 @@
---
name: gitnexus-debugging
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
---
# Debugging with GitNexus
## When to Use
- "Why is this function failing?"
- "Trace where this error comes from"
- "Who calls this method?"
- "This endpoint returns 500"
- Investigating bugs, errors, or unexpected behavior
## Workflow
```
1. query({search_query: "<error or symptom>"}) → Find related execution flows
2. context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. cypher({statement: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist
```
- [ ] Understand the symptom (error message, unexpected behavior)
- [ ] query for error text or related code
- [ ] Identify the suspect function from returned processes
- [ ] context to see callers and callees
- [ ] Trace execution flow via process resource if applicable
- [ ] cypher for custom call chain traces if needed
- [ ] Read source files to confirm root cause
```
## Debugging Patterns
| Symptom | GitNexus Approach |
| -------------------- | ---------------------------------------------------------- |
| Error message | `query` for error text → `context` on throw sites |
| Wrong return value | `context` on the function → trace callees for data flow |
| Intermittent failure | `context` → look for external calls, async deps |
| Performance issue | `context` → find symbols with many callers (hot paths) |
| Recent regression | `detect_changes` to see what your changes affect |
| "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call |
## Tools
**query** — find code related to error:
```
query({search_query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
**context** — full context for a suspect:
```
context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates (external API!)
→ Processes: CheckoutFlow (step 3/7)
```
**cypher** — custom call chain traces:
```cypher
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
RETURN [n IN nodes(path) | n.name] AS chain
```
**trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops:
```
trace({ from: "processCheckout", to: "fetchRates" })
→ status: ok, hopCount: 3
→ hops: processCheckout → validatePayment → verifyCard → fetchRates
→ edges: CALLS (1.0), CALLS (0.95), CALLS (1.0)
```
When no path exists, `trace` reports the furthest reachable node — exactly where the chain breaks (dynamic dispatch, reflection, or an external boundary).
## Example: "Payment endpoint returns 500 intermittently"
```
1. query({search_query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError
2. context({name: "validatePayment"})
→ Outgoing calls: verifyCard, fetchRates (external API!)
3. READ gitnexus://repo/my-app/process/CheckoutFlow
→ Step 3: validatePayment → calls fetchRates (external)
4. Root cause: fetchRates calls external API without proper timeout
```
@@ -0,0 +1,78 @@
---
name: gitnexus-exploring
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
---
# Exploring Codebases with GitNexus
## When to Use
- "How does authentication work?"
- "What's the project structure?"
- "Show me the main components"
- "Where is the database logic?"
- Understanding code you haven't seen before
## Workflow
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
4. context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist
```
- [ ] READ gitnexus://repo/{name}/context
- [ ] query for the concept you want to understand
- [ ] Review returned processes (execution flows)
- [ ] context on key symbols for callers/callees
- [ ] READ process resource for full execution traces
- [ ] Read source files for implementation details
```
## Resources
| Resource | What you get |
| --------------------------------------- | ------------------------------------------------------- |
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
## Tools
**query** — find execution flows related to a concept:
```
query({search_query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
**context** — 360-degree view of a symbol:
```
context({name: "validateUser"})
→ Incoming calls: loginHandler, apiMiddleware
→ Outgoing calls: checkToken, getUserById
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
```
## Example: "How does payment processing work?"
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. query({search_query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. context({name: "processPayment"})
→ Incoming: checkoutHandler, webhookHandler
→ Outgoing: validateCard, chargeStripe, saveTransaction
4. Read src/payments/processor.ts for implementation details
```
@@ -0,0 +1,138 @@
---
name: gitnexus-guide
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
---
# GitNexus Guide
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
## Always Start Here
For any task involving code understanding, debugging, impact analysis, or refactoring:
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**
> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
## Skills
| Task | Skill to read |
| -------------------------------------------- | ------------------- |
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
| Rename / extract / split / refactor | `gitnexus-refactoring` |
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
## Tools Reference
| Tool | What it gives you |
| ---------------- | ------------------------------------------------------------------------ |
| `query` | Process-grouped code intelligence — execution flows related to a concept |
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
| `trace` | Shortest path between two symbols — "how does A reach B?" in one call |
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
| `pdg_query` | Control/data dependence — what gates X (CDG) / where Y flows (REACHING_DEF); needs `analyze --pdg` |
| `check` | Check graph invariants such as circular imports |
| `route_map` | API route map — which components/hooks fetch which endpoints, and the handler files that serve them |
| `shape_check` | Response-shape drift — keys each route returns vs keys its consumers access (flags MISMATCH) |
| `api_impact` | Pre-change report for an API route — consumers, middleware, shape mismatches, risk level |
| `tool_map` | MCP/RPC tool definitions and the files that handle them |
| `group_list` | List configured multi-repo groups, or one group's config |
| `group_sync` | Rebuild a group's Contract Registry (cross-repo HTTP contract links); run after `group.yaml` changes or member re-index |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
```jsonc
{
"repositories": [
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
],
"pagination": {
"total": 437,
"limit": 50,
"offset": 0,
"returned": 50,
"hasMore": true,
"nextOffset": 50
}
}
```
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
```text
list_repos {} → repos 150, nextOffset 50, hasMore true
list_repos { offset: 50 } → repos 51100, nextOffset 100, hasMore true
list_repos { offset: 400 } → repos 401437, hasMore false (done)
```
Notes: `offset``total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
### Taint findings (`explain`)
`explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: closure/callback, property/field, and implicit flows are not modeled, and interprocedural findings are function-level `TAINT_PATH` hops rather than statement-level path proof, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
### Control & data dependence (`pdg_query`)
`pdg_query` reads the control/data-dependence layers `gitnexus analyze --pdg` records (CDG + REACHING_DEF, basic-block granular) — the control/data analog of `explain`. It is **always anchored** (a `target` file path or symbol, resolved like `context`) and has two modes:
- `pdg_query { mode: "controls", target: "..." }` — CDG: "under what condition does X run?". Each edge is a controlling predicate block → dependent block with the branch sense (`'T'`/`'F'`) in `reason`; an edge into an early `return`/`throw` is flagged `guard: true` (guard-clause discovery — the sense depends on the predicate, so don't filter guards by a fixed label).
- `pdg_query { mode: "flows", target: "...", variable?: "..." }` — REACHING_DEF def→use edges within the function; pass `variable` to trace one binding.
A repo indexed without `--pdg` returns a "no PDG layer" note (or "status unknown" when the layer can't be confirmed). Intra-procedural only — cross-function flow is taint's domain (`explain`). The raw CDG/REACHING_DEF edges are also queryable via `cypher`. See the `gitnexus-pdg-query` skill for the full query surface.
### Shortest path between two symbols (`trace`)
`trace` answers "how does A reach B?" in one call — the shortest directed path over `CALLS` (plus `HAS_METHOD`, so a class-rooted trace descends into its methods) instead of chaining 38 `context`/`impact` hops by hand.
- `trace { from: "validateUser", to: "executeQuery" }` — shortest path between two symbols.
- Disambiguate common names with `from_uid`/`to_uid` (zero-ambiguity) or `from_file`/`to_file`; an ambiguous name returns ranked candidates.
- `maxDepth` (default 10, max 30) bounds the search; `includeTests` (default false) lets the traversal pass through test-file symbols.
Returns ordered `hops` (each `{ name, filePath, startLine }`) and an aligned `edges[]` of `{ relType, confidence }`, so call hops and containment (`HAS_METHOD`) hops stay distinguishable. When no path exists it reports the **furthest** reachable node (where the chain breaks) and sets `truncated: true` if a traversal cap was hit first. Every result carries a `status`: `ok` / `no_path` / `ambiguous` / `not_found` / `error`.
Cross-repo (experimental): pass `repo: "@groupName"` to trace across a group's member repos — the path may cross **one** `ContractLink` boundary (reported as a `CONTRACT_LINK` hop with the bridged contract in `crossings[]`). Omit `to` entirely to follow `from`'s outgoing HTTP call to whatever provider endpoint it lands on. Groups are configured via `group_list` / `group_sync`.
## Resources Reference
Lightweight reads (~100-500 tokens) for navigation:
| Resource | Content |
| ---------------------------------------------- | ----------------------------------------- |
| `gitnexus://repo/{name}/context` | Stats, staleness check |
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
| `gitnexus://repo/{name}/processes` | All execution flows |
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
## Graph Schema
**Nodes:** File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process, Route, Tool, plus language-specific types (Struct, Enum, Trait, Impl, Namespace, Module, …) and BasicBlock (`--pdg` indexes only). The full node list lives in `gitnexus://repo/{name}/schema`.
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, CONTAINS, MEMBER_OF, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF, WRAPS, QUERIES, INJECTS, plus `--pdg`-only types (CFG, REACHING_DEF, TAINTED, SANITIZES, TAINT_PATH, CDG — zero rows on a default index).
Read `gitnexus://repo/{name}/schema` before writing Cypher — it is the authoritative schema for the indexed repo.
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
RETURN caller.name, caller.filePath
```
@@ -0,0 +1,97 @@
---
name: gitnexus-impact-analysis
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
---
# Impact Analysis with GitNexus
## When to Use
- "Is it safe to change this function?"
- "What will break if I modify X?"
- "Show me the blast radius"
- "Who uses this code?"
- Before making non-trivial code changes
- Before committing — to understand what your changes affect
## Workflow
```
1. impact({target: "X", direction: "upstream"}) → What depends on this
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
3. detect_changes() → Map current git changes to affected flows
4. Assess risk and report to user
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist
```
- [ ] impact({target, direction: "upstream"}) to find dependents
- [ ] Review d=1 items first (these WILL BREAK)
- [ ] Check high-confidence (>0.8) dependencies
- [ ] READ processes to check affected execution flows
- [ ] detect_changes() for pre-commit check
- [ ] Assess risk level and report to user
```
## Understanding Output
| Depth | Risk Level | Meaning |
| ----- | ---------------- | ------------------------ |
| d=1 | **WILL BREAK** | Direct callers/importers |
| d=2 | LIKELY AFFECTED | Indirect dependencies |
| d=3 | MAY NEED TESTING | Transitive effects |
## Risk Assessment
| Affected | Risk |
| ------------------------------ | -------- |
| <5 symbols, few processes | LOW |
| 5-15 symbols, 2-5 processes | MEDIUM |
| >15 symbols or many processes | HIGH |
| Critical path (auth, payments) | CRITICAL |
## Tools
**impact** — the primary tool for symbol blast radius:
```
impact({
target: "validateUser",
direction: "upstream",
minConfidence: 0.8,
maxDepth: 3
})
→ d=1 (WILL BREAK):
- loginHandler (src/auth/login.ts:42) [CALLS, 100%]
- apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%]
→ d=2 (LIKELY AFFECTED):
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
```
**detect_changes** — git-diff based impact analysis:
```
detect_changes({scope: "staged"})
→ Changed: 5 symbols in 3 files
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
→ Risk: MEDIUM
```
## Example: "What breaks if I change validateUser?"
```
1. impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
2. READ gitnexus://repo/my-app/processes
→ LoginFlow and TokenRefresh touch validateUser
3. Risk: 2 direct callers, 2 processes = MEDIUM
```
@@ -0,0 +1,121 @@
---
name: gitnexus-refactoring
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
---
# Refactoring with GitNexus
## When to Use
- "Rename this function safely"
- "Extract this into a module"
- "Split this service"
- "Move this to a new file"
- Any task involving renaming, extracting, splitting, or restructuring code
## Workflow
```
1. impact({target: "X", direction: "upstream"}) → Map all dependents
2. query({search_query: "X"}) → Find execution flows involving X
3. context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklists
### Rename Symbol
```
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] Review graph edits (high confidence) and text_search edits (review carefully)
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
- [ ] detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
```
### Extract Module
```
- [ ] context({name: target}) — see all incoming/outgoing refs
- [ ] impact({target, direction: "upstream"}) — find all external callers
- [ ] Define new module interface
- [ ] Extract code, update imports
- [ ] detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```
### Split Function/Service
```
- [ ] context({name: target}) — understand all callees
- [ ] Group callees by responsibility
- [ ] impact({target, direction: "upstream"}) — map callers to update
- [ ] Create new functions/services
- [ ] Update callers
- [ ] detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```
## Tools
**rename** — automated multi-file rename:
```
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits across 8 files
→ 10 graph edits (high confidence), 2 text_search edits (review)
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
```
**impact** — map all dependents first:
```
impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware, testUtils
→ Affected Processes: LoginFlow, TokenRefresh
```
**detect_changes** — verify your changes after refactoring:
```
detect_changes({scope: "all"})
→ Changed: 8 files, 12 symbols
→ Affected processes: LoginFlow, TokenRefresh
→ Risk: MEDIUM
```
**cypher** — custom reference queries:
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
RETURN caller.name, caller.filePath ORDER BY caller.filePath
```
## Risk Rules
| Risk Factor | Mitigation |
| ------------------- | ----------------------------------------- |
| Many callers (>5) | Use rename for automated updates |
| Cross-area refs | Use detect_changes after to verify scope |
| String/dynamic refs | query to find them |
| External/public API | Version and deprecate properly |
## Example: Rename `validateUser` to `authenticateUser`
```
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits: 10 graph (safe), 2 text_search (review)
→ Files: validator.ts, login.ts, middleware.ts, config.json...
2. Review text_search edits (config.json: dynamic reference!)
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
→ Applied 12 edits across 8 files
4. detect_changes({scope: "all"})
→ Affected: LoginFlow, TokenRefresh
→ Risk: MEDIUM — run tests for these flows
```
+44
View File
@@ -0,0 +1,44 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **sisvietnamvn_01** (8028 symbols, 13666 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
## Never Do
- NEVER edit a function, class, or method without first running `impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `detect_changes()` to check affected scope.
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/sisvietnamvn_01/context` | Codebase overview, check index freshness |
| `gitnexus://repo/sisvietnamvn_01/clusters` | All functional areas |
| `gitnexus://repo/sisvietnamvn_01/processes` | All execution flows |
| `gitnexus://repo/sisvietnamvn_01/process/{name}` | Step-by-step execution trace |
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
+44
View File
@@ -0,0 +1,44 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **sisvietnamvn_01** (8028 symbols, 13666 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
## Never Do
- NEVER edit a function, class, or method without first running `impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `detect_changes()` to check affected scope.
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/sisvietnamvn_01/context` | Codebase overview, check index freshness |
| `gitnexus://repo/sisvietnamvn_01/clusters` | All functional areas |
| `gitnexus://repo/sisvietnamvn_01/processes` | All execution flows |
| `gitnexus://repo/sisvietnamvn_01/process/{name}` | Step-by-step execution trace |
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
+1
View File
@@ -157,3 +157,4 @@ coverage/
######################
src/main/docker/oracle-data/
.gitnexus/
+3409 -51
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -6,6 +6,8 @@
"license": "UNLICENSED",
"type": "module",
"scripts": {
"gitnexus:analyze": "gitnexus analyze",
"gitnexus:setup": "gitnexus setup",
"app:start": "./gradlew",
"app:up": "docker compose -f src/main/docker/app.yml up --wait",
"backend:build-cache": "npm run backend:info && npm run backend:nohttp:test && npm run ci:e2e:package -- -x webapp -x webapp_test",
@@ -42,6 +44,7 @@
},
"devDependencies": {
"generator-jhipster": "9.1.0",
"gitnexus": "^1.6.9",
"prettier": "3.8.3",
"prettier-plugin-java": "2.9.6",
"prettier-plugin-packagejson": "3.0.2"
+112
View File
@@ -0,0 +1,112 @@
import oracledb
import json
import logging
logging.basicConfig(level=logging.INFO)
# Connect to the Oracle database
try:
connection = oracledb.connect(
user="sisvietnam",
password="sisvietnam",
dsn="localhost:1521/sisvietnam"
)
with connection.cursor() as cursor:
# Get the ID of the 'education' tag
sql_tag = "SELECT id, name FROM sis_tag WHERE name LIKE '%education%' FETCH FIRST 1 ROWS ONLY"
cursor.execute(sql_tag)
tag = cursor.fetchone()
if not tag:
logging.error("Tag 'education' not found.")
else:
tag_id = tag[0]
tag_name = tag[1]
logging.info(f"Found tag '{tag_name}' with ID {tag_id}")
# Fetch the latest 10 published posts with this tag
sql_posts = """
SELECT p.title, p.featured_image, p.slug, p.excerpt, p.created_date
FROM sis_post p
JOIN sis_post_tag pt ON p.id = pt.post_id
WHERE pt.tag_id = :tag_id AND p.status = 'PUBLISHED'
ORDER BY p.created_date DESC
FETCH FIRST 10 ROWS ONLY
"""
cursor.execute(sql_posts, [tag_id])
posts = cursor.fetchall()
import random
prices = ["42.400.000 VNĐ", "23.000.000 VNĐ", "32.500.000 VNĐ", "15.000.000 VNĐ", "Liên hệ"]
badges = ["SAT", "SUN", "MON", "TUE", "WED", "THU", "FRI"]
items = []
for post in posts:
img = post[1] if post[1] else ""
desc = post[3] if post[3] else ""
date_val = post[4]
date_str = date_val.strftime("%d/%m/%Y") if hasattr(date_val, 'strftime') else str(date_val) if date_val else "12/09/2026"
items.append({
"title": post[0],
"imageUrl": img,
"linkUrl": f"/dao-tao/{post[2]}",
"description": desc,
"badge": random.choice(badges),
"price": random.choice(prices),
"dateStr": date_str
})
new_group = {
"slug": "chuong-trinh-khac",
"name": "Chương trình khác",
"layoutType": "course-card",
"description": "",
"className": "",
"notes": f"Tự động tạo bằng Python từ thẻ {tag_name}",
"items": items
}
# Get existing settings
sql_setting = "SELECT setting_value FROM sis_setting WHERE setting_key = 'plugin_swiper_slider_data'"
cursor.execute(sql_setting)
setting = cursor.fetchone()
if setting and setting[0]:
try:
# In Oracle CLOB is sometimes returned as LOB object, read it if so
val = setting[0]
if hasattr(val, 'read'):
val = val.read()
groups = json.loads(val)
except Exception as e:
logging.error(f"Error parsing JSON: {e}")
groups = []
else:
groups = []
# Remove existing 'chuong-trinh-khac' if present
groups = [g for g in groups if g.get('slug') != 'chuong-trinh-khac']
# Add the new group
groups.append(new_group)
new_setting_value = json.dumps(groups, ensure_ascii=False)
# Update the database
if setting:
sql_update = "UPDATE sis_setting SET setting_value = :val WHERE setting_key = 'plugin_swiper_slider_data'"
cursor.execute(sql_update, [new_setting_value])
else:
sql_insert = "INSERT INTO sis_setting (setting_key, setting_value) VALUES ('plugin_swiper_slider_data', :val)"
cursor.execute(sql_insert, [new_setting_value])
connection.commit()
logging.info("Successfully updated sis_setting with the new slider group.")
except Exception as e:
logging.error(f"An error occurred: {e}")
finally:
if 'connection' in locals():
connection.close()
@@ -0,0 +1,86 @@
package com.sisvietnamvn.web.config;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.Tag;
import com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderAdminController.SliderGroup;
import com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderAdminController.SliderItem;
import com.sisvietnamvn.web.repository.PostRepository;
import com.sisvietnamvn.web.repository.TagRepository;
import com.sisvietnamvn.web.service.SettingService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Component
public class SliderSeeder implements CommandLineRunner {
private final PostRepository postRepository;
private final TagRepository tagRepository;
private final SettingService settingService;
private final ObjectMapper objectMapper;
public SliderSeeder(PostRepository postRepository, TagRepository tagRepository, SettingService settingService, ObjectMapper objectMapper) {
this.postRepository = postRepository;
this.tagRepository = tagRepository;
this.settingService = settingService;
this.objectMapper = objectMapper;
}
@Override
public void run(String... args) throws Exception {
System.out.println("=== STARTING SWIPER SLIDER SEEDER ===");
Optional<Tag> tagOpt = tagRepository.findByName("Đào tạo");
if (tagOpt.isEmpty()) {
List<Tag> possible = tagRepository.findByNameContainingIgnoreCase("Đào tạo");
if (!possible.isEmpty()) {
tagOpt = Optional.of(possible.get(0));
System.out.println("Using fallback tag: " + tagOpt.get().getName());
}
}
if (tagOpt.isEmpty()) {
System.out.println("Tag 'Đào tạo' not found!");
return;
}
List<Post> posts = postRepository.findByTagId(tagOpt.get().getId(), PageRequest.of(0, 10));
System.out.println("Found " + posts.size() + " posts with tag " + tagOpt.get().getName());
List<SliderItem> items = posts.stream().map(post -> {
String imageUrl = post.getFeaturedImage() != null ? post.getFeaturedImage() : "";
String linkUrl = "/dao-tao/" + post.getSlug();
return new SliderItem(post.getTitle(), imageUrl, linkUrl, "", null, null, null);
}).collect(Collectors.toList());
SliderGroup newGroup = new SliderGroup(
"chuong-trinh-khac",
"Chương trình khác",
"card",
"",
"",
"Tự động tạo từ thẻ " + tagOpt.get().getName(),
items
);
String json = settingService.getValue("plugin_swiper_slider_data", "[]");
List<SliderGroup> groups = new ArrayList<>();
try {
groups = objectMapper.readValue(json, new TypeReference<List<SliderGroup>>() {});
} catch (Exception e) {}
groups.removeIf(g -> "chuong-trinh-khac".equals(g.slug()));
groups.add(newGroup);
String newJson = objectMapper.writeValueAsString(groups);
settingService.setValue("plugin_swiper_slider_data", newJson);
System.out.println("=== SWIPER SLIDER SEEDED SUCCESSFULLY ===");
}
}
@@ -132,6 +132,48 @@ public class PageController {
@GetMapping("/specialty")
public String getSpecialty(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SPECIALTY), model); }
@org.springframework.beans.factory.annotation.Autowired
private com.sisvietnamvn.web.repository.TagRepository tagRepository;
@org.springframework.beans.factory.annotation.Autowired
private com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderPlugin swiperSliderPlugin;
@GetMapping("/run-seeder")
@org.springframework.web.bind.annotation.ResponseBody
public String runSeeder() {
java.util.Optional<com.sisvietnamvn.web.domain.Tag> tagOpt = tagRepository.findByName("Đào tạo");
if (tagOpt.isEmpty()) {
java.util.List<com.sisvietnamvn.web.domain.Tag> possible = tagRepository.findByNameContainingIgnoreCase("Đào tạo");
if (!possible.isEmpty()) {
tagOpt = java.util.Optional.of(possible.get(0));
}
}
if (tagOpt.isEmpty()) return "Tag not found";
java.util.List<com.sisvietnamvn.web.domain.Post> posts = postRepository.findByTagId(tagOpt.get().getId(), org.springframework.data.domain.PageRequest.of(0, 10));
java.util.List<com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderAdminController.SliderItem> items = posts.stream().map(post -> {
String imageUrl = post.getFeaturedImage() != null ? post.getFeaturedImage() : "";
return new com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderAdminController.SliderItem(post.getTitle(), imageUrl, "/dao-tao/" + post.getSlug(), "", null, null, null);
}).collect(java.util.stream.Collectors.toList());
com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderAdminController.SliderGroup newGroup = new com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderAdminController.SliderGroup(
"chuong-trinh-khac", "Chương trình khác", "card", "", "", "Tự động tạo từ thẻ " + tagOpt.get().getName(), items
);
String json = settingService.getValue("plugin_swiper_slider_data", "[]");
java.util.List<com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderAdminController.SliderGroup> groups = new java.util.ArrayList<>();
try {
groups = objectMapper.readValue(json, new com.fasterxml.jackson.core.type.TypeReference<>() {});
} catch (Exception e) {}
groups.removeIf(g -> "chuong-trinh-khac".equals(g.slug()));
groups.add(newGroup);
try {
settingService.setValue("plugin_swiper_slider_data", objectMapper.writeValueAsString(groups));
} catch (Exception e) {}
return "Seeded " + items.size() + " posts!";
}
@GetMapping("/doctor")
@org.springframework.transaction.annotation.Transactional(readOnly = true)
public String getDoctor(Model model) {
@@ -151,11 +151,12 @@ public class SwiperSliderAdminController {
return "redirect:/manage/plugins/swiper-slider";
}
public record SliderGroup(String slug, String name, String layoutType, String outerTemplate, String itemTemplate, List<SliderItem> items) {
public record SliderGroup(String slug, String name, String layoutType, String outerTemplate, String itemTemplate, String note, List<SliderItem> items) {
public String getSlug() { return slug; }
public String getName() { return name; }
public String getLayoutType() { return layoutType; }
public String getNote() { return note; }
public List<SliderItem> getItems() { return items; }
}
public record SliderItem(String title, String imageUrl, String linkUrl, String description) {}
public record SliderItem(String title, String imageUrl, String linkUrl, String description, String badge, String price, String dateStr) {}
}
@@ -134,13 +134,16 @@ public class SwiperSliderPlugin {
Optional<SwiperSliderAdminController.SliderGroup> groupOpt = groups.stream()
.filter(g -> slug.equals(g.slug()))
.findFirst();
if (groupOpt.isEmpty() || groupOpt.get().items() == null || groupOpt.get().items().isEmpty() || json.contains("UMC_files")) {
// If UMC_files is present, we replace all data with defaults (only happens once during migration)
if (json.contains("UMC_files")) {
json = forceSeedAllData();
groups = parseGroups(json);
groupOpt = groups.stream()
.filter(g -> slug.equals(g.slug()))
.findFirst();
}
return groupOpt;
}
@@ -224,6 +227,8 @@ public class SwiperSliderPlugin {
slideTemplate = getSlideTemplateForSlug("chung-chi");
} else if ("large-image".equals(layoutType)) {
slideTemplate = getSlideTemplateForSlug("hinh-anh-thuc-hanh");
} else if ("course-card".equals(layoutType)) {
slideTemplate = getSlideTemplateForSlug("course-card");
} else if ("custom".equals(layoutType) && group.itemTemplate() != null && !group.itemTemplate().trim().isEmpty()) {
slideTemplate = group.itemTemplate();
} else {
@@ -236,7 +241,10 @@ public class SwiperSliderPlugin {
.replace("{{title}}", item.title() != null ? item.title() : "")
.replace("{{imageUrl}}", item.imageUrl() != null ? item.imageUrl() : "")
.replace("{{linkUrl}}", item.linkUrl() != null ? item.linkUrl() : "")
.replace("{{description}}", item.description() != null ? item.description() : "");
.replace("{{description}}", item.description() != null ? item.description() : "")
.replace("{{date}}", item.dateStr() != null ? item.dateStr() : "")
.replace("{{badge}}", item.badge() != null ? item.badge() : "")
.replace("{{price}}", item.price() != null ? item.price() : "");
slidesHtml.append(slide);
}
return slidesHtml.toString();
@@ -252,6 +260,9 @@ public class SwiperSliderPlugin {
if ("hinh-anh-thuc-hanh".equals(slug)) {
return "<div class=\"swiper-slide max-w-[789px] !mr-2\" style=\"width: 789px; max-width: 100%;\"><div class=\"relative rounded-lg aspect-[789/460] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
}
if ("course-card".equals(slug)) {
return "<div class=\"swiper-slide\" style=\"width: 261px; max-width: 100%;\"><article class=\"relative p-3 lg:p-4 bg-white rounded-lg border border-gray-100 group\" style=\"display: flex; flex-direction: column; gap: 14px;\"><a class=\"relative overflow-hidden rounded block\" style=\"aspect-ratio: 9/5;\" href=\"{{linkUrl}}\"><img alt=\"{{title}}\" class=\"object-cover h-full w-full\" src=\"{{imageUrl}}\"/><div class=\"absolute inset-0 w-full h-full bg-black/40 opacity-0 lg:group-hover:opacity-100 duration-300 ease-in-out\"></div></a><div class=\"px-1.5 w-full\" style=\"display: flex; flex-direction: column; gap: 14px;\"><div class=\"w-full space-y-1\"><a class=\"title-1 font-bold line-clamp-2 lg:group-hover:text-primary-600 duration-300 ease-in-out block\" style=\"font-size: 18px; height: 54px; line-height: 1.5;\" href=\"{{linkUrl}}\">{{title}}</a><div class=\"body-3 text-gray-700 line-clamp-3\" style=\"font-size: 14px; height: 64px; line-height: 1.5;\">{{description}}</div></div><div class=\"w-full h-px bg-gray-100\"></div><div class=\"flex justify-between items-center w-full\"><div class=\"flex gap-4 items-center self-stretch my-auto\"><div class=\"flex gap-1.5 items-center self-stretch my-auto\"><svg class=\"lucide lucide-calendar size-3.5\" fill=\"none\" height=\"14\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" viewBox=\"0 0 24 24\" width=\"14\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M8 2v4\"></path><path d=\"M16 2v4\"></path><rect height=\"18\" rx=\"2\" width=\"18\" x=\"3\" y=\"4\"></rect><path d=\"M3 10h18\"></path></svg><time class=\"self-stretch body-3 my-auto\" style=\"font-size: 14px;\">{{date}}</time></div><span class=\"text-primary-600 font-bold border-b-2 border-primary-600\" style=\"font-size: 14px;\">{{badge}}</span></div></div></div><a class=\"p-2 lg:p-3 w-full text-white bg-primary-600 rounded lg:hover:bg-primary-300 duration-300 ease-in-out block mt-2\" href=\"{{linkUrl}}\"><div class=\"flex items-center gap-0.5 justify-center w-full font-bold\" style=\"font-size: 18px;\"><span>{{price}}</span></div></a></article></div>";
}
return "<div class=\"swiper-slide\"><div class=\"relative rounded-lg aspect-[3/2] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
}
@@ -1212,3 +1212,37 @@ figure.table table tr:hover {
}
}
.education {
.flex {
display: flex !important;
}
.flex-col {
flex-direction: column;
}
.rounded-full {
justify-content: center;
background-color: var(--color-white);
border-radius: 32px;
width: 32px;
height: 32px;
align-items: center;
}
.training-sidebar {
.gap-3 {
gap: 0.75rem;
}
.text-white {
margin-bottom: 4px;
}
.btn {
padding: 10px 32px;
background-color: var(--color-background) !important;
border-radius: 0.5rem;
text-align: center;
a {
text-decoration: unset;
}
}
}
}
@@ -1,10 +1,17 @@
<!DOCTYPE html>
<html lang="vi" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{themes/__${activeTheme}__/layout(forceFullWidth=true, bodyClass='education path-frontpage')}">
<!doctype html>
<html
lang="vi"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{themes/__${activeTheme}__/layout(forceFullWidth=true, bodyClass='education path-frontpage')}"
>
<head>
<title th:text="${post.title}">Chi tiết đào tạo</title>
<style>
.overview-wrapper-shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px -1px rgba(0, 0, 0, 0.1);
}
/* Layout */
@@ -138,7 +145,7 @@
</head>
<body>
<div layout:fragment="content">
<section class="bg-gray-50 training-section" style="padding-top: 1.5rem; padding-bottom: 1.5rem;">
<section class="bg-gray-50 training-section" style="padding-top: 1.5rem; padding-bottom: 1.5rem">
<div class="container mx-auto px-4 training-container">
<div class="training-layout relative">
<div class="overview-wrapper-shadow training-main">
@@ -146,12 +153,12 @@
<h1 class="display-7 text-primary-600 xl:mb-4 md:mb-3 mb-2" th:text="${post.title}">Tiêu đề Khóa học</h1>
<!-- Ảnh đại diện -->
<div class="img-container" th:if="${post.featuredImage != null and !#strings.isEmpty(post.featuredImage)}">
<img th:src="${post.featuredImage}" th:alt="${post.title}" loading="lazy" decoding="async">
<img th:src="${post.featuredImage}" th:alt="${post.title}" loading="lazy" decoding="async" />
</div>
<div class="prose prose-blog prose-table prose-content max-w-none" id="postContentParsed">
<!-- Nội dung chi tiết sẽ được render tại đây -->
</div>
<div id="postContentRaw" style="display:none;" th:text="${post.content}"></div>
<div id="postContentRaw" style="display: none" th:text="${post.content}"></div>
</div>
<!-- Sidebar info -->
<div id="info" class="training-sidebar">
@@ -167,136 +174,189 @@
</div>
</div>
</section>
<!-- Swiper CSS (Must be inside layout fragment to not be stripped by Thymeleaf) -->
<link rel="stylesheet" th:href="@{/css/swiper-bundle.min.css}" />
<style>
.swiper-button-lock {
display: none !important;
}
.swiper-button-disabled {
opacity: 0.5;
cursor: auto;
pointer-events: none;
}
[class*='btn-navigation-']:hover:not([disabled]):not(.swiper-button-disabled):not(.swiper-button-lock) {
background-color: var(--color-primary-600);
color: white;
}
</style>
<!-- Related Courses (Chương trình khác) -->
<section class="related-courses-section" style="background-color: #f0f9ff; padding: 3rem 0; margin-top: 2rem;">
<section class="py-6 xl:py-12 md:py-8 bg-white">
<div class="container mx-auto px-4">
<h2 class="text-center mb-8 font-bold" style="font-size: 1.5rem; color: var(--color-primary-600);">Chương trình khác</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem;">
<!-- Card 1 -->
<div style="background-color: white; border-radius: 0.5rem; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border: 1px solid #e5e7eb;">
<div style="aspect-ratio: 16/9; background-color: #e2e8f0;"></div>
<div style="padding: 1rem;">
<h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; line-height: 1.4;">Chương trình thực hành 12 tháng Bác sĩ y khoa</h3>
<p style="color: #6b7280; font-size: 0.875rem; margin-bottom: 1rem;">12/09/2026</p>
<a href="#" style="display: block; width: 100%; text-align: center; background-color: var(--color-primary-600, #a82024); color: white; padding: 0.5rem; border-radius: 0.25rem; font-weight: 600; text-decoration: none;">42.400.000 VNĐ</a>
</div>
</div>
<!-- Card 2 -->
<div style="background-color: white; border-radius: 0.5rem; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border: 1px solid #e5e7eb;">
<div style="aspect-ratio: 16/9; background-color: #e2e8f0;"></div>
<div style="padding: 1rem;">
<h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; line-height: 1.4;">Lớp Nghiệp vụ hộ lý trợ giúp chăm sóc</h3>
<p style="color: #6b7280; font-size: 0.875rem; margin-bottom: 1rem;">01/04/2026</p>
<a href="#" style="display: block; width: 100%; text-align: center; background-color: var(--color-primary-600, #a82024); color: white; padding: 0.5rem; border-radius: 0.25rem; font-weight: 600; text-decoration: none;">23.000.000 VNĐ</a>
</div>
</div>
<!-- Card 3 -->
<div style="background-color: white; border-radius: 0.5rem; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border: 1px solid #e5e7eb;">
<div style="aspect-ratio: 16/9; background-color: #e2e8f0;"></div>
<div style="padding: 1rem;">
<h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; line-height: 1.4;">Thực hành cấp cứu và điều trị đột quỵ</h3>
<p style="color: #6b7280; font-size: 0.875rem; margin-bottom: 1rem;">29/12/2025</p>
<a href="#" style="display: block; width: 100%; text-align: center; background-color: var(--color-primary-600, #a82024); color: white; padding: 0.5rem; border-radius: 0.25rem; font-weight: 600; text-decoration: none;">32.500.000 VNĐ</a>
<h2 class="display-7 text-primary-600 text-center xl:mb-8 md:mb-6 mb-4">Chương trình khác</h2>
<div class="relative md:flex md:items-center">
<button
class="btn-navigation flex-shrink-0 lg:mr-4 mr-2 !relative z-10 md:size-[42px] size-[32px] items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group btn-navigation-chuong-trinh-khac-prev md:!flex hidden"
>
<svg
class="lucide lucide-chevron-up size-4 -rotate-90"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m18 15-6-6-6 6"></path>
</svg>
</button>
<div
class="swiper swiper-chuong-trinh-khac [&>.swiper-pagination]:!static [&>.swiper-pagination]:mt-1 lg:[&>.swiper-pagination]:!hidden w-full"
>
<div class="swiper-wrapper">
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'chuong-trinh-khac')}"></th:block>
</div>
<div class="swiper-pagination"></div>
</div>
<button
class="btn-navigation flex-shrink-0 lg:ml-4 ml-2 !relative z-10 md:size-[42px] size-[32px] items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group btn-navigation-chuong-trinh-khac-next md:!flex hidden"
>
<svg
class="lucide lucide-chevron-down size-4 -rotate-90"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m6 9 6 6 6-6"></path>
</svg>
</button>
</div>
</div>
</section>
<!-- Content rendering and dynamic tabs parsing -->
<script>
document.addEventListener("DOMContentLoaded", function() {
var rawData = document.getElementById("postContentRaw").textContent;
var parsedContainer = document.getElementById("postContentParsed");
// Content is now HTML
parsedContainer.innerHTML = rawData;
// Parse dynamic tabs from HTML (migrated from Editor.js)
var tabStarts = Array.from(parsedContainer.querySelectorAll('h3.dynamic-tab-start'));
if (tabStarts.length > 0) {
var tabsGroup = [];
var wrapper = document.createElement('div');
wrapper.className = 'custom-tabs-wrapper training-course-tabs-wrapper';
var navContainer = document.createElement('div');
navContainer.className = 'custom-tabs-nav-container';
var tabsContainer = document.createElement('div');
tabsContainer.className = 'tabs-container';
navContainer.appendChild(tabsContainer);
wrapper.appendChild(navContainer);
var firstTabStart = tabStarts[0];
var parentNode = firstTabStart.parentNode;
// Collect all elements into tabs
tabStarts.forEach(function(startEl, index) {
var tabTitle = startEl.textContent;
var btn = document.createElement('button');
btn.className = 'dao-tao-tab-btn custom-tab-btn' + (index === 0 ? ' active' : '');
btn.setAttribute('data-tab-idx', index);
btn.textContent = tabTitle;
tabsContainer.appendChild(btn);
var panel = document.createElement('div');
panel.className = 'dao-tao-tab-panel custom-tab-panel';
panel.setAttribute('data-tab-idx', index);
panel.style.display = (index === 0) ? 'block' : 'none';
var currentEl = startEl.nextElementSibling;
while (currentEl && !currentEl.classList.contains('dynamic-tab-start') && !currentEl.classList.contains('dynamic-tab-end')) {
var nextEl = currentEl.nextElementSibling;
panel.appendChild(currentEl);
currentEl = nextEl;
}
// Remove the dynamic-tab-end if present
if (currentEl && currentEl.classList.contains('dynamic-tab-end')) {
currentEl.parentNode.removeChild(currentEl);
}
wrapper.appendChild(panel);
startEl.parentNode.removeChild(startEl);
});
parentNode.appendChild(wrapper);
// Tab click listener
wrapper.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var idx = btn.getAttribute('data-tab-idx');
wrapper.querySelectorAll('.custom-tab-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
wrapper.querySelectorAll('.custom-tab-panel').forEach(function(p) {
p.style.display = 'none';
});
var activePanel = wrapper.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
}
<!-- Add Swiper JS -->
<script th:src="@{/js/swiper-bundle.min.js}"></script>
<script>
(function () {
const el = document.querySelector('.swiper-chuong-trinh-khac');
if (el && typeof Swiper !== 'undefined' && !el.classList.contains('swiper-initialized')) {
new Swiper('.swiper-chuong-trinh-khac', {
slidesPerView: 'auto',
spaceBetween: 16,
navigation: {
nextEl: '.btn-navigation-chuong-trinh-khac-next',
prevEl: '.btn-navigation-chuong-trinh-khac-prev',
},
});
</script>
}
})();
</script>
<!-- Content rendering and dynamic tabs parsing -->
<script>
document.addEventListener('DOMContentLoaded', function () {
var rawData = document.getElementById('postContentRaw').textContent;
var parsedContainer = document.getElementById('postContentParsed');
// Content is now HTML
parsedContainer.innerHTML = rawData;
// Parse dynamic tabs from HTML (migrated from Editor.js)
var tabStarts = Array.from(parsedContainer.querySelectorAll('h3.dynamic-tab-start'));
if (tabStarts.length > 0) {
var tabsGroup = [];
var wrapper = document.createElement('div');
wrapper.className = 'custom-tabs-wrapper training-course-tabs-wrapper';
var navContainer = document.createElement('div');
navContainer.className = 'custom-tabs-nav-container';
var tabsContainer = document.createElement('div');
tabsContainer.className = 'tabs-container';
navContainer.appendChild(tabsContainer);
wrapper.appendChild(navContainer);
var firstTabStart = tabStarts[0];
var parentNode = firstTabStart.parentNode;
// Collect all elements into tabs
tabStarts.forEach(function (startEl, index) {
var tabTitle = startEl.textContent;
var btn = document.createElement('button');
btn.className = 'dao-tao-tab-btn custom-tab-btn' + (index === 0 ? ' active' : '');
btn.setAttribute('data-tab-idx', index);
btn.textContent = tabTitle;
tabsContainer.appendChild(btn);
var panel = document.createElement('div');
panel.className = 'dao-tao-tab-panel custom-tab-panel';
panel.setAttribute('data-tab-idx', index);
panel.style.display = index === 0 ? 'block' : 'none';
var currentEl = startEl.nextElementSibling;
while (currentEl && !currentEl.classList.contains('dynamic-tab-start') && !currentEl.classList.contains('dynamic-tab-end')) {
var nextEl = currentEl.nextElementSibling;
panel.appendChild(currentEl);
currentEl = nextEl;
}
// Remove the dynamic-tab-end if present
if (currentEl && currentEl.classList.contains('dynamic-tab-end')) {
currentEl.parentNode.removeChild(currentEl);
}
wrapper.appendChild(panel);
startEl.parentNode.removeChild(startEl);
});
parentNode.appendChild(wrapper);
// Tab click listener
wrapper.addEventListener('click', function (e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var idx = btn.getAttribute('data-tab-idx');
wrapper.querySelectorAll('.custom-tab-btn').forEach(function (b) {
b.classList.remove('active');
});
btn.classList.add('active');
wrapper.querySelectorAll('.custom-tab-panel').forEach(function (p) {
p.style.display = 'none';
});
var activePanel = wrapper.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
}
});
</script>
</div>
<script>
function switchTrainingTab(btn, tabId) {
var container = btn.closest('.training-course-tabs-wrapper');
if(!container) return;
if (!container) return;
var btns = container.querySelectorAll('.dao-tao-tab-btn');
for(var i = 0; i < btns.length; i++) btns[i].classList.remove('active');
for (var i = 0; i < btns.length; i++) btns[i].classList.remove('active');
btn.classList.add('active');
var panels = container.querySelectorAll('.dao-tao-tab-panel');
for(var j = 0; j < panels.length; j++) panels[j].classList.remove('active');
for (var j = 0; j < panels.length; j++) panels[j].classList.remove('active');
var panel = container.querySelector('.dao-tao-tab-panel[data-tab-content="' + tabId + '"]');
if(panel) panel.classList.add('active');
if (panel) panel.classList.add('active');
}
</script>
</body>
</html>
</body>
</html>
@@ -1,45 +1,98 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>
<!-- Widget hiển thị thông tin động của khóa học (Ngày khai giảng, địa điểm, hình thức...) -->
<div th:fragment="course-info" class="bg-primary-600 rounded-lg p-4 flex flex-col justify-center mb-4">
<div class="xl:space-y-8 md:space-y-6 space-y-4">
<div class="flex items-start gap-3">
<body>
<!-- Widget hiển thị thông tin động của khóa học (Ngày khai giảng, địa điểm, hình thức...) -->
<div th:fragment="course-info" class="bg-primary-600 rounded-lg p-4 flex flex-col justify-center mb-4" style="height: 100%">
<div class="flex flex-col gap-3 xl:space-y-8 md:space-y-6 space-y-4">
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-white rounded-full flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar size-3.5 text-primary-600"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-calendar size-3.5 text-primary-600"
>
<path d="M8 2v4"></path>
<path d="M16 2v4"></path>
<rect width="18" height="18" x="3" y="4" rx="2"></rect>
<path d="M3 10h18"></path>
</svg>
</div>
<div>
<p class="text-white/80 text-sm">Ngày khai giảng</p>
<p class="text-white font-semibold" th:text="${post.eventTime != null ? #temporals.format(post.eventTime, 'dd/MM/yyyy') : 'Đang cập nhật'}">Đang cập nhật</p>
<p class="text-white text-sm">Ngày khai giảng</p>
<p
class="text-white font-semibold"
th:text="${post.eventTime != null ? #temporals.format(post.eventTime, 'dd/MM/yyyy') : 'Đang cập nhật'}"
>
Đang cập nhật
</p>
</div>
</div>
</div>
<div class="flex items-start gap-3">
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-white rounded-full flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin size-3.5 text-primary-600"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-map-pin size-3.5 text-primary-600"
>
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
</div>
<div>
<p class="text-white/80 text-sm">Địa điểm</p>
<p class="text-white font-semibold" th:text="${post.location != null ? post.location : 'SIS Cần Thơ'}">SIS Cần Thơ</p>
<p class="text-white text-sm">Địa điểm</p>
<p class="text-white font-semibold" th:text="${post.location != null ? post.location : 'SIS Cần Thơ'}">SIS Cần Thơ</p>
</div>
</div>
<div class="flex items-start gap-3">
</div>
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-white rounded-full flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-users size-3.5 text-primary-600"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-users size-3.5 text-primary-600"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
</div>
<div>
<p class="text-white/80 text-sm">Hình thức</p>
<p class="text-white font-semibold">Trực tiếp / Tập trung</p>
<p class="text-white text-sm">Hình thức</p>
<p class="text-white font-semibold">Trực tiếp / Tập trung</p>
</div>
</div>
<div class="pt-4 mt-4 border-t border-white/20">
<a href="#" class="block w-full py-3 px-4 bg-white text-primary-600 text-center font-bold rounded-lg hover:bg-gray-100 transition-colors">
Đăng ký ngay
</a>
</div>
<div class="pt-4 mt-4 border-t border-white/20">
<div
class="btn btn-primary bg-white block w-full py-3 px-4 bg-white text-primary-600 text-center font-bold rounded-lg hover:bg-gray-100 transition-colors"
>
<a href="#"> Đăng ký ngay </a>
</div>
</div>
</div>
</div>
</div>
</body>
</body>
</html>
@@ -37,7 +37,11 @@
<label>Slug (Dùng cho Shortcode)</label>
<input type="text" class="form-control" onchange="updateGroup('slug', this.value)" id="groupSlug" placeholder="Ví dụ: chung-chi" required />
</div>
<div class="col-md-12 tab-field mt-3">
<div class="col-md-6 tab-field mt-3">
<label>Ghi chú (Hiển thị ở danh sách)</label>
<input type="text" class="form-control" onchange="updateGroup('note', this.value)" id="groupNote" placeholder="Ví dụ: Dùng cho trang chủ" />
</div>
<div class="col-md-6 tab-field mt-3">
<label>Mẫu Giao Diện (Layout Type)</label>
<select class="form-control" onchange="updateGroup('layoutType', this.value); toggleCustomTemplates(this.value)" id="groupLayoutType">
<option value="">Mặc định (Dựa theo Slug, hỗ trợ tương thích ngược)</option>
@@ -85,6 +89,7 @@
function renderGroup() {
document.getElementById('groupName').value = group.name || '';
document.getElementById('groupSlug').value = group.slug || '';
document.getElementById('groupNote').value = group.note || '';
document.getElementById('groupLayoutType').value = group.layoutType || '';
document.getElementById('groupOuterTemplate').value = group.outerTemplate || '';
document.getElementById('groupItemTemplate').value = group.itemTemplate || '';
@@ -26,6 +26,7 @@
<tr>
<th>Tên Nhóm</th>
<th>Slug (Shortcode)</th>
<th>Ghi chú</th>
<th>Mẫu Giao Diện</th>
<th>Số lượng Slide</th>
<th>Hành động</th>
@@ -35,6 +36,7 @@
<tr th:each="group : ${groups}">
<td th:text="${group.name}"></td>
<td><code th:text="${group.slug}"></code></td>
<td><em th:text="${group.note ?: ''}"></em></td>
<td th:text="${group.layoutType == 'gallery' ? 'Thư viện ảnh' : (group.layoutType == 'card' ? 'Dạng thẻ' : (group.layoutType == 'large-image' ? 'Hình ảnh lớn' : (group.layoutType == 'custom' ? 'Tùy chỉnh' : 'Mặc định')))}"></td>
<td th:text="${group.items != null ? group.items.size() : 0}"></td>
<td>
@@ -46,7 +48,7 @@
</td>
</tr>
<tr th:if="${groups.empty}">
<td colspan="5" class="text-center">Chưa có nhóm slider nào. Hãy tạo mới!</td>
<td colspan="6" class="text-center">Chưa có nhóm slider nào. Hãy tạo mới!</td>
</tr>
</tbody>
</table>
@@ -0,0 +1,72 @@
package com.sisvietnamvn.web;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.sisvietnamvn.web.repository.PostRepository;
import com.sisvietnamvn.web.repository.TagRepository;
import com.sisvietnamvn.web.service.SettingService;
import com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderAdminController.SliderGroup;
import com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderAdminController.SliderItem;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.type.TypeReference;
@SpringBootTest
public class SliderSeederTest {
@Autowired
private PostRepository postRepository;
@Autowired
private TagRepository tagRepository;
@Autowired
private SettingService settingService;
@Autowired
private ObjectMapper objectMapper;
@Test
public void testSeedSlider() throws Exception {
java.util.Optional<com.sisvietnamvn.web.domain.Tag> tagOpt = tagRepository.findByName("Đào tạo");
if (tagOpt.isEmpty()) {
java.util.List<com.sisvietnamvn.web.domain.Tag> possible = tagRepository.findByNameContainingIgnoreCase("Đào tạo");
if (!possible.isEmpty()) {
tagOpt = java.util.Optional.of(possible.get(0));
}
}
if (tagOpt.isEmpty()) {
System.out.println("Tag 'Đào tạo' not found!");
return;
}
java.util.List<com.sisvietnamvn.web.domain.Post> posts = postRepository.findByTagId(tagOpt.get().getId(), org.springframework.data.domain.PageRequest.of(0, 10));
java.util.List<SliderItem> items = posts.stream().map(post -> {
String imageUrl = post.getFeaturedImage() != null ? post.getFeaturedImage() : "";
String linkUrl = "/dao-tao/" + post.getSlug();
return new SliderItem(post.getTitle(), imageUrl, linkUrl, "");
}).collect(java.util.stream.Collectors.toList());
SliderGroup newGroup = new SliderGroup(
"chuong-trinh-khac",
"Chương trình khác",
"card",
"",
"",
"Tự động tạo từ thẻ " + tagOpt.get().getName(),
items
);
String json = settingService.getValue("plugin_swiper_slider_data", "[]");
java.util.List<SliderGroup> groups = new java.util.ArrayList<>();
try {
groups = objectMapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {}
groups.removeIf(g -> "chuong-trinh-khac".equals(g.slug()));
groups.add(newGroup);
String newJson = objectMapper.writeValueAsString(groups);
settingService.setValue("plugin_swiper_slider_data", newJson);
System.out.println("=== SWIPER SLIDER SEEDED SUCCESSFULLY ===");
}
}