feat: implement automated Swiper slider seeding and integrate slider component into training details page
This commit is contained in:
@@ -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 1–50, nextOffset 50, hasMore true
|
||||
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
|
||||
…
|
||||
list_repos { offset: 400 } → repos 401–437, 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 3–8 `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
|
||||
```
|
||||
@@ -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 -->
|
||||
+5971
-24
File diff suppressed because one or more lines are too long
@@ -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 -->
|
||||
@@ -157,3 +157,4 @@ coverage/
|
||||
######################
|
||||
src/main/docker/oracle-data/
|
||||
|
||||
.gitnexus/
|
||||
|
||||
Generated
+3409
-51
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+3
-2
@@ -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) {}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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>
|
||||
|
||||
+6
-1
@@ -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 || '';
|
||||
|
||||
+3
-1
@@ -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 ===");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user