From 508018a8a5cabfb71f10068b63baa0087843e71c Mon Sep 17 00:00:00 2001 From: OthmaneZ05 Date: Thu, 23 Jul 2026 00:09:47 -0400 Subject: [PATCH 1/9] ui-v1 --- .../controllers/learningController.test.ts | 25 ++ .../controllers/learningController.ts | 8 + .../modules/learning/routes/learningRoutes.ts | 1 + .../learning/services/progressService.test.ts | 32 ++ .../learning/services/progressService.ts | 24 ++ docs/learning-api.md | 4 + frontend/src/app/App.tsx | 18 +- frontend/src/assets/hero.png | Bin 13057 -> 0 bytes frontend/src/assets/react.svg | 1 - .../learning/components/DifficultyChip.tsx | 41 +++ .../components/LearningPanel.test.tsx | 27 ++ .../learning/components/LearningPanel.tsx | 15 + .../learning/components/RoadmapCatalog.tsx | 17 +- .../useLearningProgressSummaries.test.ts | 79 +++++ .../hooks/useLearningProgressSummaries.ts | 44 +++ .../src/features/learning/roadmapLanguage.ts | 14 + frontend/src/index.css | 142 +++++++- frontend/src/locales/en.json | 62 +++- frontend/src/locales/fr.json | 62 +++- .../src/pages/CanvasPage/CanvasPage.test.tsx | 21 ++ frontend/src/pages/CanvasPage/CanvasPage.tsx | 28 +- .../pages/ProjectsPage/ProjectsPage.test.tsx | 233 +++++++++++++ .../src/pages/ProjectsPage/ProjectsPage.tsx | 323 +++++++++--------- .../ProjectsPage/components/EmptyState.tsx | 39 --- .../ProjectsPage/components/FirstRunHero.tsx | 133 ++++++++ .../ProjectsPage/components/PageHeader.tsx | 119 +++++++ .../ProjectsPage/components/ProjectCard.tsx | 129 +++---- .../components/ProjectCardSkeleton.tsx | 34 ++ .../components/ProjectPickerModal.tsx | 96 ++++++ .../components/ProjectsSection.tsx | 113 ++++++ .../ProjectsPage/components/SideRail.tsx | 75 ++++ .../components/learning/LearningHero.tsx | 98 ++++++ .../components/learning/LearningSection.tsx | 170 +++++++++ .../learning/RoadmapShowcaseCard.tsx | 153 +++++++++ .../components/learning/WhyPanel.tsx | 89 +++++ .../components/learning/roadmapVisual.ts | 23 ++ .../components/learning/sampleReceipt.ts | 16 + frontend/src/shared/components/Button.tsx | 24 ++ .../src/shared/components/ProgressBar.tsx | 39 +++ .../src/shared/components/Receipt.test.tsx | 39 +++ frontend/src/shared/components/Receipt.tsx | 129 +++++++ frontend/src/shared/components/Skeleton.tsx | 16 + frontend/src/shared/types/index.ts | 9 + frontend/src/shared/types/roadmap.ts | 17 +- 44 files changed, 2472 insertions(+), 309 deletions(-) delete mode 100644 frontend/src/assets/hero.png delete mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/features/learning/components/DifficultyChip.tsx create mode 100644 frontend/src/features/learning/hooks/useLearningProgressSummaries.test.ts create mode 100644 frontend/src/features/learning/hooks/useLearningProgressSummaries.ts create mode 100644 frontend/src/features/learning/roadmapLanguage.ts create mode 100644 frontend/src/pages/ProjectsPage/ProjectsPage.test.tsx delete mode 100644 frontend/src/pages/ProjectsPage/components/EmptyState.tsx create mode 100644 frontend/src/pages/ProjectsPage/components/FirstRunHero.tsx create mode 100644 frontend/src/pages/ProjectsPage/components/PageHeader.tsx create mode 100644 frontend/src/pages/ProjectsPage/components/ProjectCardSkeleton.tsx create mode 100644 frontend/src/pages/ProjectsPage/components/ProjectPickerModal.tsx create mode 100644 frontend/src/pages/ProjectsPage/components/ProjectsSection.tsx create mode 100644 frontend/src/pages/ProjectsPage/components/SideRail.tsx create mode 100644 frontend/src/pages/ProjectsPage/components/learning/LearningHero.tsx create mode 100644 frontend/src/pages/ProjectsPage/components/learning/LearningSection.tsx create mode 100644 frontend/src/pages/ProjectsPage/components/learning/RoadmapShowcaseCard.tsx create mode 100644 frontend/src/pages/ProjectsPage/components/learning/WhyPanel.tsx create mode 100644 frontend/src/pages/ProjectsPage/components/learning/roadmapVisual.ts create mode 100644 frontend/src/pages/ProjectsPage/components/learning/sampleReceipt.ts create mode 100644 frontend/src/shared/components/Button.tsx create mode 100644 frontend/src/shared/components/ProgressBar.tsx create mode 100644 frontend/src/shared/components/Receipt.test.tsx create mode 100644 frontend/src/shared/components/Receipt.tsx create mode 100644 frontend/src/shared/components/Skeleton.tsx diff --git a/backend/src/modules/learning/controllers/learningController.test.ts b/backend/src/modules/learning/controllers/learningController.test.ts index 0555b68..4c5a051 100644 --- a/backend/src/modules/learning/controllers/learningController.test.ts +++ b/backend/src/modules/learning/controllers/learningController.test.ts @@ -210,6 +210,31 @@ describe('LearningController', () => { }); }); + describe('GET /api/learning/progress', () => { + it('returns every play-through summary wrapped in entries', async () => { + const summaries = [ + { projectId: 'project-1', roadmapId: 'roadmap-1', updatedAt: '2026-07-16T10:00:00.000Z', completedSteps: 3 }, + ]; + (ProgressService.listProgress as jest.Mock).mockReturnValue(summaries); + + const res = await request(app).get('/api/learning/progress'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ entries: summaries }); + }); + + it('returns 500 when the service throws', async () => { + (ProgressService.listProgress as jest.Mock).mockImplementation(() => { + throw new Error('disk exploded'); + }); + + const res = await request(app).get('/api/learning/progress'); + + expect(res.status).toBe(500); + expect(res.body.error).toBe('disk exploded'); + }); + }); + describe('GET /api/learning/progress/:projectId/:roadmapId', () => { it('returns the stored progress for the pair', async () => { const progress = { diff --git a/backend/src/modules/learning/controllers/learningController.ts b/backend/src/modules/learning/controllers/learningController.ts index f10eec1..7aabf5d 100644 --- a/backend/src/modules/learning/controllers/learningController.ts +++ b/backend/src/modules/learning/controllers/learningController.ts @@ -115,6 +115,14 @@ export class LearningController { } } + public static async listProgress(req: Request, res: Response): Promise { + try { + res.json({ entries: ProgressService.listProgress() }); + } catch (err: unknown) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + } + public static async getProgress(req: Request, res: Response): Promise { try { res.json( diff --git a/backend/src/modules/learning/routes/learningRoutes.ts b/backend/src/modules/learning/routes/learningRoutes.ts index beca60f..fc74066 100644 --- a/backend/src/modules/learning/routes/learningRoutes.ts +++ b/backend/src/modules/learning/routes/learningRoutes.ts @@ -6,6 +6,7 @@ const router = Router(); router.get('/roadmaps', LearningController.listRoadmaps); router.get('/roadmaps/:id', LearningController.getRoadmap); router.post('/validate', LearningController.validate); +router.get('/progress', LearningController.listProgress); router.get('/progress/:projectId/:roadmapId', LearningController.getProgress); router.put('/progress/:projectId/:roadmapId/hints', LearningController.recordRevealedHints); router.delete('/progress/:projectId/:roadmapId', LearningController.resetProgress); diff --git a/backend/src/modules/learning/services/progressService.test.ts b/backend/src/modules/learning/services/progressService.test.ts index ac255e7..f941719 100644 --- a/backend/src/modules/learning/services/progressService.test.ts +++ b/backend/src/modules/learning/services/progressService.test.ts @@ -96,6 +96,38 @@ describe('ProgressService', () => { expect(ProgressService.getProgress('project-2', 'roadmap-1', file).steps).not.toEqual({}); }); + describe('listProgress', () => { + it('returns an empty list when no store exists', () => { + expect(ProgressService.listProgress(file)).toEqual([]); + expect(fs.existsSync(file)).toBe(false); + }); + + it('summarizes every entry with its count of passed steps', () => { + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file); + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-b', false, '2026-07-16T10:01:00.000Z', file); + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-c', true, '2026-07-16T10:02:00.000Z', file); + ProgressService.recordValidation('project-2', 'roadmap-2', 'step-a', false, '2026-07-16T10:03:00.000Z', file); + + const summaries = ProgressService.listProgress(file); + + expect(summaries).toHaveLength(2); + expect(summaries[0]).toMatchObject({ projectId: 'project-1', roadmapId: 'roadmap-1', completedSteps: 2 }); + expect(summaries[0].updatedAt).not.toBe(''); + expect(summaries[1]).toMatchObject({ projectId: 'project-2', roadmapId: 'roadmap-2', completedSteps: 0 }); + }); + + it('does not consume the one-shot storeRecovered flag reserved for getProgress', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + fs.writeFileSync(file, 'not json at all {'); + + expect(ProgressService.listProgress(file)).toEqual([]); + + // The recovery notice must still reach the player's next per-pair read. + expect(ProgressService.getProgress('project-1', 'roadmap-1', file).storeRecovered).toBe(true); + errorSpy.mockRestore(); + }); + }); + describe('unreadable store recovery', () => { let errorSpy: jest.SpyInstance; diff --git a/backend/src/modules/learning/services/progressService.ts b/backend/src/modules/learning/services/progressService.ts index 19ba761..988227f 100644 --- a/backend/src/modules/learning/services/progressService.ts +++ b/backend/src/modules/learning/services/progressService.ts @@ -32,6 +32,15 @@ interface ProgressStore { entries: ProgressEntry[]; } +/** One item of GET /api/learning/progress — documented in docs/learning-api.md. */ +export interface ProgressEntrySummary { + projectId: string; + roadmapId: string; + updatedAt: string; + /** Count of steps whose latest validation passed. */ + completedSteps: number; +} + /** Contract of GET /api/learning/progress/:projectId/:roadmapId — documented in docs/learning-api.md. */ export interface RoadmapProgressResponse { projectId: string; @@ -83,6 +92,21 @@ export class ProgressService { return response; } + /** + * Summarizes every play-through in the store, for surfaces that show + * progress across projects (e.g. the landing page's roadmap cards). + * Deliberately leaves the one-shot `storeRecovered` flag untouched — that + * notice belongs to the player's getProgress, which can act on it. + */ + public static listProgress(filePath: string = PROGRESS_PATH): ProgressEntrySummary[] { + return this.readStore(filePath).entries.map(entry => ({ + projectId: entry.projectId, + roadmapId: entry.roadmapId, + updatedAt: entry.updatedAt, + completedSteps: Object.values(entry.steps).filter(step => step.passed).length, + })); + } + public static recordValidation( projectId: string, roadmapId: string, diff --git a/docs/learning-api.md b/docs/learning-api.md index 48dd18f..80f4ddd 100644 --- a/docs/learning-api.md +++ b/docs/learning-api.md @@ -113,6 +113,10 @@ Roadmap progression is persisted locally in `~/.torollo/progress.json`, next to `passed` is the verdict of the **latest** validation (same semantics as the player's in-session display); `attempts` counts the validation runs that reached evaluation; `revealedHints` is the absolute number of revealed rungs on the step's hint ladder `[...hints, solution?]`. Validator results are deliberately **not** persisted — they describe a past container state; only the verdict survives. The top-level `version` is the migration contract: a reader that finds an unknown version (or an unparseable file) must not guess — the server moves the file aside as `progress.json.corrupt`, starts fresh, and reports it once via `storeRecovered` on the next progress read so the UI can tell the user. Writes are write-then-rename, so a crash mid-write cannot truncate the store. Deleting a project deletes its progress entries. +### `GET /api/learning/progress` + +Returns `{ "entries": [ { "projectId", "roadmapId", "updatedAt", "completedSteps" } ] }` — one summary per `(projectId, roadmapId)` play-through in the store, where `completedSteps` counts the steps whose latest validation passed. Used by surfaces that show progress across projects (e.g. the landing page's roadmap cards, which keep the most recent entry per roadmap). This endpoint never emits `storeRecovered` — that one-shot notice is reserved for the per-pair read below. + ### `GET /api/learning/progress/:projectId/:roadmapId` Returns `{ projectId, roadmapId, steps }` — `steps` is the per-step record above, `{}` when nothing was ever recorded. `storeRecovered: true` is present once after a corrupt/unknown-version store was discarded. The player calls this when opening a roadmap and resumes on the first step whose `passed` is not true. diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 84d16c0..1d1663e 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import CanvasPage from '../pages/CanvasPage/CanvasPage'; import TerminalModal from '../features/terminal/components/TerminalModal'; import ProjectsPage from '../pages/ProjectsPage/ProjectsPage'; -import type { ProjectInfo, TerminalInfo } from '../shared/types'; +import type { LearningIntent, ProjectInfo, TerminalInfo } from '../shared/types'; function App() { const [activeProject, setActiveProject] = useState(() => { @@ -14,6 +14,9 @@ function App() { } }); const [activeTerminal, setActiveTerminal] = useState(null); + // Session-only, deliberately not persisted: a reload must never replay + // an "open the learning panel" intent from a past navigation. + const [learningIntent, setLearningIntent] = useState(null); const handleSelectProject = (project: ProjectInfo | null) => { setActiveProject(project); @@ -27,18 +30,23 @@ function App() { return (
{!activeProject ? ( - handleSelectProject({ id, name })} + { + setLearningIntent(intent ?? null); + handleSelectProject({ id, name }); + }} /> ) : ( - setLearningIntent(null)} onBackToProjects={() => { handleSelectProject(null); setActiveTerminal(null); }} - onTerminalOpen={(id, name) => setActiveTerminal({ id, name })} + onTerminalOpen={(id, name) => setActiveTerminal({ id, name })} /> )} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 zcmV+cGycqpP)V|)f$;Qooc7=_G zlYe)HToTQIc!$)^+J1M1y0*T%w!p~7%ux`!eRhO?c80XDxKQ*R^lUUMnA>6NT^?feoZ8xxvP32D&s-9ow zqjcM}eesrC)NeDmsf)*P7wJ|K!&xP%Zy4iI8lF)Tv2!reW)tCzg_1=PmOwd1SQfxa z8;58t!=z~Ba7CYlNWVG>he8aRPY|+-JmozNhn!#9i#77Aa_Edt$ijyCWL#=~I>~2X zZNrQ8I0=D+NWD4pq=7~(i zhfThMNw|G>g^y9pGzxX7ZSApl@tIxFcs{p#MX{Ax&XZT+cR#U+OWc@S)pkIuI}dzu zH?^Q=<(y&Vq-oxSLfc0Zmq81bjZWf}RnssBaD6}2g-XJHLcN_|*IOu>m|x$nbm(?E zyNy!Zp=RroS;?Vg*kmoJYBi!n5{_^@rA!)=t#a^;N$8GL!*DsQb}`yvEuX!G@||An znOfUZAevPrkV_qjl|<~3QRZzG&h@C9Y5z zqpNH4xqbF_InIPh)kX}Vn^5kyed|mOuq+2>M;v~KO37a#yrEn3XDqtOl=rc6_KZ!; zreo)DFVB4|>1Zd(bvMI%8uM;3!)YMYu&cG?(PE!B~y@3yKBMt|R zAf=I16tFwPsl)!jDqvYkLHaAQ+f@W1m6F5aZvwhm4JL z{_l)@b;)mDSzle2gyFP5-r1x-5X{G}ot%VyWP@vEW80!Q=f%RTfpg>B*TA^pyWYUQ z<=xPtz}WcZ!;rFl4m1D&FFHv?K~#9!?A%+fn=lXt;9!Fc#kQ;zk~gZFsH z8e5iu@c_pzX&qb8&Dum*oXwB+fm6l6gFfC|o*wgEiy6tw~&co z9Vd_4)P%wP-KwQW7|lN-znGK#?N+j24U=$982myIBM+vsiKsc*@4-rwJxuAaHKna6 zT3wi!C~a4ZKH03qU}_1bKyx0&$CaK7_%Z+Kl$)fF5^op zZApQF2TvDav!s|krTjw-8US6ep z%!VmX4luub+fseQz_D9ATJQ?iQQwD}TZz{-yo#l12a%+7bT@E(X-hyaVS-5vuXc#^ zx^w;L21;NphGVoj*{s3f4dme0y2LC=G1-7THd`#z?;tuC{^9k(dM{Rf2GOxg7Jzho z7nSZHl7?M9kdalX`)YgoKEfiae5+;$(OGeN1eqxrv!ZCVKyH>xiyNqfe8xzY8*7)H zQls8KMp)F4D>ED;idMOU^^WhVF@q>ZSmeB0y~qC~|DB648hr%Sh|*T(4q|w2l?m2+ zvBVw3@7+Mz?^Yc#+se6KM;a<=(W-I>k)$-qL2V*t}VaW`;?P4)WqI%maIDq8!oUcSYAD`}wWjkSyAVsnF65#2zQ zZ>(K*TlS(E#4y$4Zq+e^_&}d)q20hCe3!LfLYP%nQpLJ~gM6a1hJlz3)aS<9C9me| zAcmJ#>tOwBy{HoP0Sm1&_(E+S@6 zgBIFUoei8zJmdpiq8q5=OY7t@`)JWxn_&GvKVr=Zdb_pEL_j|=?f;WK^U9Q0efd#K z9q7SfJTl4pmA$jsZ5oK8@O9#!I3Cv-kL)<8SalSsp#dcpvJ}Nz#G6FC0%9|7Fi#8; zGDJXtj!&GljT3*HE@0EE>G8Se&d)*nkqe}-?`3vPl&UqK?xG z!3XJ4M-x`EuQjhBbu?ik-)rmIt=DF_N?TVMP)8Gjn)TZ2V%H|zENbeix}kOxd@0}Q z>)HuH6Ean!uS#~4g2Ne2WsMGel|h%j9*W_quQheG^JqmKhc*RYzp0wKlGjBq2VzY_ zgOv8WC1+%W=W)k)Yp_`8kfE=uiiwOZTXi8Uj9YGr$f@yJcJ;#&-Nq~sJ7anE(@;QN z=~br%7%7`isKStX|7!1?L(apl^QvPKlrHV4S+6tNVQ*R1iGdC~WMNE1$a+=rpQmcB z>wxiLIBvOnm;u*;9Y!kJdy(T4lk|8>JAm(&wEsFIF1$_*{>2ZNd$V6DS=SfrGxAv0 zzKe377JI`&o9Ljr+VnS*EwehA{f&{cKZF(6*MG5!p5MvrFA3ll{fmRG*L@6^cb;o^ z3Wm8c?Sc6$`>~VEWw(c$Y?nRO;2Q$=ulpqPtM^=1IZx;@xK0PgO7rKQ^WHVLwtgUT z%|JF{^f(VH)wLKQ%dYiu2RmchBdxL0-M?wxxul_z*{h6ZZ`>-k(vizs((vW8Lt6Z6 zY;Dt?@JWyN`O`f;&d1Mb?e%9oyRK1ql?EE5XB2(W)|D1~Rx35$H6@6)$F?)7V|zEO zI}fu0-0}8W5=6sg$fPnZ~7=tTudl?Ecb@pxbo)vni%gP-?hL|%*?62C;x6?@E`VRnJv z?fTb;k4x;TS7Cu-z%J}uy}e-pwpLQ17Q@4DC+FCdAmNKklG$`I_pyw7E{fYmw~{Fj zi?6KcVy=Wrel)EB_DWO|0CKmI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8WL0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e+bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKcw$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(%KtuV^zC&Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5Mo-0$pkyV3VV4B@Qms46M zuBxGRV@HxU7Wwx-6CB zaU*HO<_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#<Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XClkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn=Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>`zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1R$C6ja5!^ZGh;YRhhxs58qJWo9@Bceac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=RNC6nE=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-NueqTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<11$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdNK8ZDKZ?QFLU?zh30G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsbob0{xu@0TB_*>G7w0ICn zr#VoBktqHZ~XxhiKD*lcG|b;H*|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn&E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fRZa#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9& zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g(lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!wUA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?uf$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI)-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr>)f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p= z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z>vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=Gp_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQA zvqp;$kmGJY>lLsN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^&#TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpYR}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+PmNABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx|$S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6}_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh+g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt!MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLjlm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?&Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t`F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/features/learning/components/DifficultyChip.tsx b/frontend/src/features/learning/components/DifficultyChip.tsx new file mode 100644 index 0000000..76e59e0 --- /dev/null +++ b/frontend/src/features/learning/components/DifficultyChip.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next'; +import type { RoadmapDifficulty } from '../../../shared/types/roadmap'; + +interface DifficultyChipProps { + difficulty: RoadmapDifficulty; +} + +const DIFFICULTY_COLORS: Record = { + beginner: 'var(--color-success)', + intermediate: 'var(--color-warning)', + advanced: 'var(--color-danger)', +}; + +/** Small colored pill for a roadmap's difficulty — green/amber/red semantics. */ +export default function DifficultyChip({ difficulty }: DifficultyChipProps) { + const { t } = useTranslation(); + const color = DIFFICULTY_COLORS[difficulty]; + return ( + + {t(`learning.catalog.difficulty.${difficulty}`)} + + ); +} + +const styles: Record = { + chip: { + display: 'inline-flex', + alignItems: 'center', + padding: '2px var(--space-2)', + borderRadius: 'var(--radius-sm)', + fontSize: 'var(--text-xs)', + fontWeight: 600, + whiteSpace: 'nowrap', + }, +}; diff --git a/frontend/src/features/learning/components/LearningPanel.test.tsx b/frontend/src/features/learning/components/LearningPanel.test.tsx index d651026..c84bbe0 100644 --- a/frontend/src/features/learning/components/LearningPanel.test.tsx +++ b/frontend/src/features/learning/components/LearningPanel.test.tsx @@ -143,6 +143,33 @@ describe('LearningPanel', () => { expect(screen.getByText('Build a minimal two-tier architecture.')).toBeInTheDocument(); }); + it('opens directly on the roadmap named by initialRoadmap', async () => { + const fetchMock = buildFetchMock({}); + vi.stubGlobal('fetch', fetchMock); + render( + {}} + /> + ); + + await screen.findAllByText('Create the web server'); + const urls = fetchMock.mock.calls.map(call => String(call[0])); + expect(urls.some(url => url.includes(`/api/learning/roadmaps/${roadmap.id}?language=en`))).toBe(true); + expect(urls.some(url => url.includes(`/api/learning/progress/p1/${roadmap.id}`))).toBe(true); + }); + + it('stays on the catalogue when no initialRoadmap is given', async () => { + const fetchMock = buildFetchMock({}); + vi.stubGlobal('fetch', fetchMock); + render( {}} />); + + expect(await screen.findByText('Your first architecture')).toBeInTheDocument(); + const urls = fetchMock.mock.calls.map(call => String(call[0])); + expect(urls.some(url => url.includes('/api/learning/roadmaps/'))).toBe(false); + }); + it('shows a retry path when the catalogue cannot be loaded', async () => { const fetchMock = buildFetchMock({}); fetchMock.mockRejectedValueOnce(new Error('network down')); diff --git a/frontend/src/features/learning/components/LearningPanel.tsx b/frontend/src/features/learning/components/LearningPanel.tsx index 3bbc4fc..cf7aa18 100644 --- a/frontend/src/features/learning/components/LearningPanel.tsx +++ b/frontend/src/features/learning/components/LearningPanel.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { GraduationCap, X } from 'lucide-react'; import { useLearningPlayer } from '../hooks/useLearningPlayer'; @@ -8,6 +9,8 @@ import type { NetworkConfig } from '../../../shared/types/network'; interface LearningPanelProps { projectId: string; + /** Deep-link from the landing page: open directly on this roadmap. One-shot, consumed on mount. */ + initialRoadmap?: { id: string; language: string } | null; onClose: () => void; containers?: ContainerData[]; networkConfig?: NetworkConfig; @@ -20,6 +23,7 @@ interface LearningPanelProps { */ export default function LearningPanel({ projectId, + initialRoadmap, onClose, containers = [], networkConfig = { @@ -32,6 +36,17 @@ export default function LearningPanel({ const { t } = useTranslation(); const player = useLearningPlayer({ projectId }); + // Arrival intent: open the requested roadmap once on mount. openRoadmap + // hydrates persisted progress and resumes at the first incomplete step. + const arrivalRoadmapRef = useRef(initialRoadmap); + const openRoadmapRef = useRef(player.openRoadmap); + openRoadmapRef.current = player.openRoadmap; + useEffect(() => { + if (arrivalRoadmapRef.current) { + openRoadmapRef.current(arrivalRoadmapRef.current); + } + }, []); + return (
diff --git a/frontend/src/features/learning/components/RoadmapCatalog.tsx b/frontend/src/features/learning/components/RoadmapCatalog.tsx index dbe644c..90c834a 100644 --- a/frontend/src/features/learning/components/RoadmapCatalog.tsx +++ b/frontend/src/features/learning/components/RoadmapCatalog.tsx @@ -1,6 +1,8 @@ import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useRoadmaps } from '../hooks/useRoadmaps'; +import { filterByUiLanguage } from '../roadmapLanguage'; +import DifficultyChip from './DifficultyChip'; import type { RoadmapSummary } from '../../../shared/types/roadmap'; interface RoadmapCatalogProps { @@ -15,14 +17,9 @@ export default function RoadmapCatalog({ onOpen }: RoadmapCatalogProps) { fetchRoadmaps(); }, [fetchRoadmaps]); - // Only surface roadmaps authored in the active UI language: an English user - // sees English roadmaps only. Compare on the base subtag so 'en-US' still - // matches an 'en' roadmap. Re-evaluated on every render, so toggling the - // language in the topbar re-filters the catalogue immediately. - const uiLanguage = i18n.language.split('-')[0]; - const visibleSummaries = summaries.filter( - summary => summary.language.split('-')[0] === uiLanguage - ); + // Re-evaluated on every render, so toggling the language in the topbar + // re-filters the catalogue immediately. + const visibleSummaries = filterByUiLanguage(summaries, i18n.language); if (loading) { return
{t('learning.catalog.loading')}
; @@ -53,12 +50,10 @@ export default function RoadmapCatalog({ onOpen }: RoadmapCatalogProps) { >
{summary.title} + {summary.difficulty && }
{summary.description}
- {summary.difficulty && ( - {t(`learning.catalog.difficulty.${summary.difficulty}`)} - )} {t('learning.catalog.steps', { count: summary.stepCount })} {summary.estimatedMinutes != null && ( {t('learning.catalog.minutes', { count: summary.estimatedMinutes })} diff --git a/frontend/src/features/learning/hooks/useLearningProgressSummaries.test.ts b/frontend/src/features/learning/hooks/useLearningProgressSummaries.test.ts new file mode 100644 index 0000000..881179d --- /dev/null +++ b/frontend/src/features/learning/hooks/useLearningProgressSummaries.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useLearningProgressSummaries } from './useLearningProgressSummaries'; +import type { ProgressEntrySummary } from '../../../shared/types/roadmap'; + +function jsonResponse(ok: boolean, body: unknown): Response { + return { ok, json: () => Promise.resolve(body) } as Response; +} + +const entries: ProgressEntrySummary[] = [ + { projectId: 'p1', roadmapId: 'cache-aside-redis', updatedAt: '2026-07-20T10:00:00.000Z', completedSteps: 3 }, + { projectId: 'p2', roadmapId: 'cache-aside-redis', updatedAt: '2026-07-21T10:00:00.000Z', completedSteps: 1 }, + { projectId: 'p1', roadmapId: 'resilient-three-tier', updatedAt: '2026-07-19T10:00:00.000Z', completedSteps: 5 }, +]; + +describe('useLearningProgressSummaries', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('keeps only the most recent entry per roadmap', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(true, { entries })); + + const { result } = renderHook(() => useLearningProgressSummaries()); + await act(async () => { + await result.current.fetchProgress(); + }); + + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/learning/progress')); + expect(result.current.byRoadmapId['cache-aside-redis']).toMatchObject({ + projectId: 'p2', + completedSteps: 1, + }); + expect(result.current.byRoadmapId['resilient-three-tier']).toMatchObject({ + projectId: 'p1', + completedSteps: 5, + }); + expect(result.current.error).toBe(false); + }); + + it('flags an error on an unexpected response shape', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(true, { unexpected: 'shape' })); + + const { result } = renderHook(() => useLearningProgressSummaries()); + await act(async () => { + await result.current.fetchProgress(); + }); + + expect(result.current.byRoadmapId).toEqual({}); + expect(result.current.error).toBe(true); + }); + + it('flags an error when the backend is unreachable, and retry clears it', async () => { + fetchMock + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce(jsonResponse(true, { entries: [] })); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useLearningProgressSummaries()); + await act(async () => { + await result.current.fetchProgress(); + }); + expect(result.current.error).toBe(true); + + await act(async () => { + await result.current.fetchProgress(); + }); + expect(result.current.error).toBe(false); + + errorSpy.mockRestore(); + }); +}); diff --git a/frontend/src/features/learning/hooks/useLearningProgressSummaries.ts b/frontend/src/features/learning/hooks/useLearningProgressSummaries.ts new file mode 100644 index 0000000..e415232 --- /dev/null +++ b/frontend/src/features/learning/hooks/useLearningProgressSummaries.ts @@ -0,0 +1,44 @@ +import { useCallback, useState } from 'react'; +import { API_BASE } from '../../../shared/types'; +import type { ProgressEntrySummary, ProgressListResponse } from '../../../shared/types/roadmap'; + +/** + * Loads every roadmap play-through summary (GET /api/learning/progress) and + * keeps the most recent entry per roadmap — the landing page shows one + * "Continue" per roadmap, pointing at the project it was last played in. + * Progress is a garnish there: on error the cards simply render without it, + * so this hook exposes the flag but callers should not fail the section. + */ +export function useLearningProgressSummaries() { + const [byRoadmapId, setByRoadmapId] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + + const fetchProgress = useCallback(async () => { + try { + setLoading(true); + setError(false); + const res = await fetch(`${API_BASE}/api/learning/progress`); + const data: ProgressListResponse = await res.json(); + if (res.ok && Array.isArray(data?.entries)) { + const latest: Record = {}; + for (const entry of data.entries) { + const current = latest[entry.roadmapId]; + if (!current || entry.updatedAt > current.updatedAt) { + latest[entry.roadmapId] = entry; + } + } + setByRoadmapId(latest); + } else { + setError(true); + } + } catch (err) { + console.error('Failed to fetch learning progress:', err); + setError(true); + } finally { + setLoading(false); + } + }, []); + + return { byRoadmapId, loading, error, fetchProgress }; +} diff --git a/frontend/src/features/learning/roadmapLanguage.ts b/frontend/src/features/learning/roadmapLanguage.ts new file mode 100644 index 0000000..fd8c807 --- /dev/null +++ b/frontend/src/features/learning/roadmapLanguage.ts @@ -0,0 +1,14 @@ +import type { RoadmapSummary } from '../../shared/types/roadmap'; + +/** + * Only surface roadmaps authored in the active UI language: an English user + * sees English roadmaps only. Compare on the base subtag so 'en-US' still + * matches an 'en' roadmap. + */ +export function filterByUiLanguage( + summaries: RoadmapSummary[], + uiLanguage: string +): RoadmapSummary[] { + const base = uiLanguage.split('-')[0]; + return summaries.filter(summary => summary.language.split('-')[0] === base); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index b0322fc..b78ba0d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -22,9 +22,45 @@ --color-warning-glow: rgba(245, 158, 11, 0.1); --color-warning-strong: #92400E; + --bg-subtle: #F9FAFB; + + /* dark navigation rail (home shell chrome) */ + --bg-rail: #0F172A; + --rail-text: #F8FAFC; + --rail-text-muted: rgba(248, 250, 252, 0.55); + + /* overlays & shadows */ + --overlay-scrim: rgba(15, 23, 42, 0.45); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 10px 15px -3px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 24px 48px -12px rgba(0, 0, 0, 0.18); + + /* radius */ + --radius-sm: 6px; /* inputs, small buttons, chips, receipts */ + --radius-md: 10px; /* buttons, cards inside panels */ + --radius-lg: 14px; /* node cards, panels, toasts */ + --radius-xl: 18px; /* modals */ + + /* spacing (4px grid) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + /* type scale */ + --text-xs: 11px; + --text-sm: 12px; + --text-md: 13px; + --text-lg: 15px; + --text-xl: 18px; + --text-2xl: 24px; + --font-sans: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; --font-mono: 'JetBrains Mono', monospace; - + background-color: var(--bg-main); color: var(--color-text-primary); font-family: var(--font-sans); @@ -152,6 +188,110 @@ body { } } +/* Shared Button primitive (shared/components/Button.tsx) — classes so hover + states stay in CSS. Also usable on for link-shaped actions. */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + border-radius: var(--radius-md); + font-family: var(--font-sans); + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: background 0.15s ease, border-color 0.15s ease, filter 0.15s ease; +} +.btn:disabled { + opacity: 0.55; + cursor: default; +} +.btn-md { + height: 34px; + padding: 0 var(--space-3); + font-size: var(--text-md); +} +.btn-lg { + height: 42px; + padding: 0 var(--space-5); + font-size: var(--text-lg); +} +.btn-primary { + background: var(--color-accent); + color: var(--bg-surface-solid); + border: 1px solid transparent; + box-shadow: var(--shadow-sm); +} +.btn-primary:hover:not(:disabled) { + filter: brightness(0.94); +} +.btn-outline { + background: var(--bg-surface-solid); + color: var(--color-text-primary); + border: 1px solid var(--border-color); +} +.btn-outline:hover:not(:disabled) { + border-color: var(--border-color-hover); + background: var(--bg-subtle); +} + +/* Navigation rail buttons (dark chrome — hover/active need CSS) */ +.rail-btn { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + border-radius: var(--radius-md); + color: var(--rail-text-muted); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} +.rail-btn:hover { + color: var(--rail-text); + background: color-mix(in srgb, var(--rail-text) 10%, transparent); +} +.rail-btn.active { + color: var(--rail-text); + background: var(--color-accent); +} + +/* Loading skeleton block */ +@keyframes skeletonPulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.55; + } +} +.skeleton { + background: var(--bg-main); + border-radius: var(--radius-md); + animation: skeletonPulse 1.6s ease-in-out infinite; +} + +/* Receipt fade-in (receipts never animate for attention — entry only) */ +@keyframes receiptFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + } + .receipt-appear { + animation: none !important; + } +} + /* Interactive focus outlines */ input:focus { border-color: var(--color-accent) !important; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 9e3f7d1..1723bf7 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -58,31 +58,58 @@ "resetProgressError": "Could not reset the progress. Nothing was changed — try again.", "progressRecovered": "Your saved progress could not be read and had to be reset. Sorry — this roadmap starts fresh.", "dismissNotice": "Dismiss" + }, + "landing": { + "title": "Learning", + "subtitle": "Guided roadmaps, checked against real containers", + "heroTitle": "Learn system design by running real infrastructure.", + "heroBody": "Follow a roadmap, build the system on the canvas, and validate every step against your actual Docker containers.", + "startRoadmap": "Start a roadmap", + "browseRoadmaps": "Browse all roadmaps", + "roadmapsTitle": "Roadmaps", + "continueLabel": "Continue · step {{current}} of {{total}}", + "completedLabel": "Completed", + "whyTitle": "Why this is different", + "whyContainersTitle": "Real containers", + "whyContainersBody": "Every node you drag is a real Docker container running on your machine.", + "whyCommandsTitle": "Real commands", + "whyCommandsBody": "You work in real terminals — redis-cli, psql, curl — not a simulation.", + "whyReceiptsTitle": "Real validation receipts", + "whyReceiptsBody": "Every check shows the exact command it ran and what it observed.", + "sampleReceiptLabel": "Sample validation receipt", + "pickProjectTitle": "Choose a project", + "pickProjectBody": "\"{{roadmap}}\" runs against one project’s containers. Pick where to work on it.", + "pickProjectNew": "New project" } }, "projects": { "title": "Torollo Projects", "subtitle": "Organize your infrastructure labs", - "newProject": "New Project", - "loading": "Loading projects...", - "createTitle": "Create Project Stack", + "newProject": "New project", + "createTitle": "Create a project", "createLabel": "Give your project a descriptive name to organize your containers.", "createPlaceholder": "e.g. Web App Lab, API Gateway Test", - "createSubmit": "Create Project", - "deleteTitle": "Delete Project", - "deleteConfirm": "Delete Project", + "createSubmit": "Create project", + "deleteTitle": "Delete project", + "deleteConfirm": "Delete project", "deleteMessage": "This will permanently delete \"{{name}}\" and stop/remove all associated Docker containers. This action cannot be undone.", - "noProjects": "No projects found. Create a new one!", - "lastUpdated": "Last updated:", - "open": "Open", - "delete": "Delete", "storeRecovered": "Your projects file could not be read and had to be reset. The unreadable file was kept as projects.json.corrupt in your .torollo folder.", "dismissNotice": "Dismiss notice", - "emptyTitle": "No projects yet", - "emptyDesc": "Click \"New Project\" to create your first infrastructure stack.", "deleting": "Deleting...", "deleteProjectTooltip": "Delete project", - "openStack": "Open Stack" + "openStack": "Open project", + "docsLink": "Docs", + "sectionTitle": "Projects", + "loadError": "Could not load projects. Is the backend running?", + "retry": "Retry", + "loadingAria": "Loading projects", + "firstLabName": "My first lab", + "firstRun": { + "title": "Build real infrastructure on your machine.", + "body": "Drag databases, load balancers and networks onto a canvas — every node is a real Docker container. Learn system design by running it, not by drawing it.", + "startLearning": "Start learning", + "startScratch": "Start from scratch" + } }, "vpc": { "titleInfo": "Project VPC Settings", @@ -539,7 +566,9 @@ "create": "Create", "genericError": "An error occurred", "dockerUnavailable": "Docker daemon unreachable — start Docker and the lab will reconnect automatically.", - "deleteConnection": "Delete Connection" + "deleteConnection": "Delete Connection", + "copy": "Copy", + "copied": "Copied" }, "toasts": { "subnetDeleteBlocked": "Cannot delete subnet: Move or delete all nodes inside the subnet first.", @@ -751,5 +780,10 @@ "autoscalinggroup": "e.g. asg-1" } } + }, + "nav": { + "main": "Main navigation", + "projects": "Projects", + "learning": "Learning" } } diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index a412630..b33bad0 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -58,31 +58,58 @@ "resetProgressError": "Impossible de réinitialiser la progression. Rien n'a été modifié — réessayez.", "progressRecovered": "Votre progression sauvegardée était illisible et a dû être réinitialisée. Désolé — cette roadmap repart de zéro.", "dismissNotice": "Fermer" + }, + "landing": { + "title": "Apprentissage", + "subtitle": "Des parcours guidés, vérifiés sur de vrais conteneurs", + "heroTitle": "Apprenez le system design en exécutant une vraie infrastructure.", + "heroBody": "Suivez un parcours, construisez le système sur le canevas et validez chaque étape sur vos vrais conteneurs Docker.", + "startRoadmap": "Commencer un parcours", + "browseRoadmaps": "Parcourir tous les parcours", + "roadmapsTitle": "Parcours", + "continueLabel": "Continuer · étape {{current}} sur {{total}}", + "completedLabel": "Terminé", + "whyTitle": "Pourquoi c'est différent", + "whyContainersTitle": "De vrais conteneurs", + "whyContainersBody": "Chaque nœud que vous déposez est un vrai conteneur Docker qui tourne sur votre machine.", + "whyCommandsTitle": "De vraies commandes", + "whyCommandsBody": "Vous travaillez dans de vrais terminaux — redis-cli, psql, curl — pas dans une simulation.", + "whyReceiptsTitle": "De vrais reçus de validation", + "whyReceiptsBody": "Chaque vérification montre la commande exacte exécutée et ce qui a été observé.", + "sampleReceiptLabel": "Exemple de reçu de validation", + "pickProjectTitle": "Choisir un projet", + "pickProjectBody": "« {{roadmap}} » s'exécute sur les conteneurs d'un projet. Choisissez où travailler.", + "pickProjectNew": "Nouveau projet" } }, "projects": { "title": "Projets Torollo", "subtitle": "Organisez vos laboratoires d'infrastructure", - "newProject": "Nouveau Projet", - "loading": "Chargement des projets...", - "createTitle": "Créer un Projet", + "newProject": "Nouveau projet", + "createTitle": "Créer un projet", "createLabel": "Donnez un nom descriptif à votre projet pour organiser vos conteneurs.", "createPlaceholder": "ex: Lab App Web, Test API Gateway", - "createSubmit": "Créer le Projet", - "deleteTitle": "Supprimer le Projet", - "deleteConfirm": "Supprimer le Projet", + "createSubmit": "Créer le projet", + "deleteTitle": "Supprimer le projet", + "deleteConfirm": "Supprimer le projet", "deleteMessage": "Cela supprimera définitivement \"{{name}}\" et arrêtera/supprimera tous les conteneurs Docker associés. Cette action est irréversible.", - "noProjects": "Aucun projet trouvé. Créez-en un nouveau !", - "lastUpdated": "Dernière mise à jour :", - "open": "Ouvrir", - "delete": "Supprimer", "storeRecovered": "Votre fichier de projets était illisible et a dû être réinitialisé. Le fichier illisible a été conservé sous projects.json.corrupt dans votre dossier .torollo.", "dismissNotice": "Fermer l'avertissement", - "emptyTitle": "Aucun projet pour l'instant", - "emptyDesc": "Cliquez sur « Nouveau projet » pour créer votre première stack d'infrastructure.", "deleting": "Suppression...", "deleteProjectTooltip": "Supprimer le projet", - "openStack": "Ouvrir la stack" + "openStack": "Ouvrir le projet", + "docsLink": "Docs", + "sectionTitle": "Projets", + "loadError": "Impossible de charger les projets. Le backend est-il démarré ?", + "retry": "Réessayer", + "loadingAria": "Chargement des projets", + "firstLabName": "Mon premier lab", + "firstRun": { + "title": "Construisez une vraie infrastructure sur votre machine.", + "body": "Glissez des bases de données, des load balancers et des réseaux sur un canevas — chaque nœud est un vrai conteneur Docker. Apprenez le system design en l'exécutant, pas en le dessinant.", + "startLearning": "Commencer à apprendre", + "startScratch": "Partir de zéro" + } }, "vpc": { "titleInfo": "Paramètres VPC du Projet", @@ -539,7 +566,9 @@ "create": "Créer", "genericError": "Une erreur s'est produite", "dockerUnavailable": "Démon Docker injoignable — démarrez Docker et le lab se reconnectera automatiquement.", - "deleteConnection": "Supprimer la connexion" + "deleteConnection": "Supprimer la connexion", + "copy": "Copier", + "copied": "Copié" }, "toasts": { "subnetDeleteBlocked": "Impossible de supprimer le sous-réseau : déplacez ou supprimez d'abord tous les nœuds qu'il contient.", @@ -751,5 +780,10 @@ "autoscalinggroup": "ex. asg-1" } } + }, + "nav": { + "main": "Navigation principale", + "projects": "Projets", + "learning": "Apprentissage" } } diff --git a/frontend/src/pages/CanvasPage/CanvasPage.test.tsx b/frontend/src/pages/CanvasPage/CanvasPage.test.tsx index 6cd297b..be11cc0 100644 --- a/frontend/src/pages/CanvasPage/CanvasPage.test.tsx +++ b/frontend/src/pages/CanvasPage/CanvasPage.test.tsx @@ -89,6 +89,8 @@ async function renderCanvasPage(fetchMock: ReturnType, props: Part @@ -117,6 +119,25 @@ describe('CanvasPage', () => { vi.useRealTimers(); }); + it('opens the learning panel on mount when an arrival intent is given, and consumes it once', async () => { + const fetchMock = buildFetchMock({ containers: [], networkConfig: { vpcConfig: validVpcConfig, subnets: [], nodeSubnetMap: {}, nodeSecurityGroups: {}, nodeIpMap: {} } }); + const onLearningIntentConsumed = vi.fn(); + await renderCanvasPage(fetchMock, { initialLearning: {}, onLearningIntentConsumed }); + + // Topbar button + panel header both say "Learning": 2 means the panel is open. + expect(screen.getAllByText('Learning')).toHaveLength(2); + expect(onLearningIntentConsumed).toHaveBeenCalledTimes(1); + }); + + it('keeps the learning panel closed without an arrival intent', async () => { + const fetchMock = buildFetchMock({ containers: [], networkConfig: { vpcConfig: validVpcConfig, subnets: [], nodeSubnetMap: {}, nodeSecurityGroups: {}, nodeIpMap: {} } }); + await renderCanvasPage(fetchMock); + + // The topbar button is labeled via topbar.learning; the panel header ("Learning") + // must not be there. Both resolve to the same string, so count occurrences. + expect(screen.getAllByText('Learning')).toHaveLength(1); + }); + it('fetches containers and network config on mount and renders the project header', async () => { const fetchMock = buildFetchMock({ containers: [], networkConfig: { vpcConfig: validVpcConfig, subnets: [], nodeSubnetMap: {}, nodeSecurityGroups: {}, nodeIpMap: {} } }); await renderCanvasPage(fetchMock); diff --git a/frontend/src/pages/CanvasPage/CanvasPage.tsx b/frontend/src/pages/CanvasPage/CanvasPage.tsx index 56373c9..87fa93c 100644 --- a/frontend/src/pages/CanvasPage/CanvasPage.tsx +++ b/frontend/src/pages/CanvasPage/CanvasPage.tsx @@ -26,6 +26,7 @@ import CanvasModals from './components/CanvasModals'; import type { InspectorState } from './components/CanvasModals'; import ButtonEdge from './components/ButtonEdge'; import { API_BASE } from '../../shared/types'; +import type { LearningIntent } from '../../shared/types'; import { useNetworkConfig } from './hooks/useNetworkConfig'; import { useCanvasDragDrop } from './hooks/useCanvasDragDrop'; import { positionToCell, resolveSubnetChildPosition, subnetSize } from './utils/canvasGeometry'; @@ -41,6 +42,10 @@ import { assignNodeToSubnet, removeNodeFromConfig } from './utils/networkConfigO interface CanvasPageProps { projectId: string; projectName: string; + /** Arrival intent from the landing page: open the learning panel, optionally on one roadmap. */ + initialLearning?: LearningIntent | null; + /** Called once on mount so the owner can clear the one-shot intent. */ + onLearningIntentConsumed?: () => void; onBackToProjects: () => void; onTerminalOpen: (id: string, name: string) => void; } @@ -57,7 +62,14 @@ const INSPECTOR_KIND_BY_NODE_TYPE: Record = { autoscalinggroup: 'asg', }; -export default function CanvasPage({ projectId, projectName, onBackToProjects, onTerminalOpen }: CanvasPageProps) { +export default function CanvasPage({ + projectId, + projectName, + initialLearning, + onLearningIntentConsumed, + onBackToProjects, + onTerminalOpen, +}: CanvasPageProps) { const { t } = useTranslation(); const { toast, showNotification, showToast, dismissToast } = useToast(); @@ -123,7 +135,18 @@ export default function CanvasPage({ projectId, projectName, onBackToProjects, o const [showVpcSettings, setShowVpcSettings] = useState(false); const [showTrafficSimulator, setShowTrafficSimulator] = useState(false); - const [showLearning, setShowLearning] = useState(false); + const [showLearning, setShowLearning] = useState(() => Boolean(initialLearning)); + // One-shot arrival intent (CanvasPage mounts fresh per project): the roadmap + // to auto-open is captured here, and the owner clears its copy right away so + // a later toggle of the panel can never replay it. + const initialRoadmapRef = useRef(initialLearning?.roadmap ?? null); + const intentConsumedRef = useRef(onLearningIntentConsumed); + useEffect(() => { + intentConsumedRef.current?.(); + // Child effects ran first, so the panel (mounted on this same first render + // when an intent exists) has already captured its roadmap prop. + initialRoadmapRef.current = null; + }, []); const nodeTypes = useMemo(() => ({ ubuntu: UbuntuNode, @@ -583,6 +606,7 @@ export default function CanvasPage({ projectId, projectName, onBackToProjects, o {showLearning && ( setShowLearning(false)} containers={containers} networkConfig={networkConfig} diff --git a/frontend/src/pages/ProjectsPage/ProjectsPage.test.tsx b/frontend/src/pages/ProjectsPage/ProjectsPage.test.tsx new file mode 100644 index 0000000..1b80bb4 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/ProjectsPage.test.tsx @@ -0,0 +1,233 @@ +import '../../i18n'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import ProjectsPage from './ProjectsPage'; +import type { Project } from '../../shared/types'; +import type { ProgressEntrySummary, RoadmapSummary } from '../../shared/types/roadmap'; + +const projects: Project[] = [ + { id: 'p1', name: 'Lab one', createdAt: '2026-07-01T10:00:00.000Z' }, + { id: 'p2', name: 'Lab two', createdAt: '2026-07-02T10:00:00.000Z' }, +]; + +const summaries: RoadmapSummary[] = [ + { + id: 'resilient-three-tier', + title: 'Deploy a resilient three-tier app', + description: 'Build a three-tier architecture.', + language: 'en', + difficulty: 'intermediate', + estimatedMinutes: 40, + stepCount: 10, + }, + { + id: 'cache-aside-redis', + title: 'Cache-aside with Redis', + description: 'Add a Redis cache-aside layer.', + language: 'en', + difficulty: 'intermediate', + estimatedMinutes: 30, + stepCount: 8, + }, + { + id: 'cache-aside-redis', + title: 'Cache-aside avec Redis', + description: 'Ajoutez une couche de cache Redis.', + language: 'fr', + difficulty: 'intermediate', + estimatedMinutes: 30, + stepCount: 8, + }, +]; + +const progressEntries: ProgressEntrySummary[] = [ + { projectId: 'p1', roadmapId: 'cache-aside-redis', updatedAt: '2026-07-20T10:00:00.000Z', completedSteps: 3 }, +]; + +function jsonResponse(ok: boolean, body: unknown): Response { + return { ok, json: () => Promise.resolve(body) } as Response; +} + +/** + * The page fires parallel fetches on mount (projects, roadmaps, progress) — + * route by URL, never by call order. + */ +function buildFetchMock(handlers: { + projects?: () => Promise | Response; + createProject?: (body: unknown) => Response; + roadmaps?: () => Response; + progress?: () => Response; +} = {}) { + return vi.fn((url: string, init?: RequestInit) => { + if (url.includes('/api/learning/roadmaps')) { + return Promise.resolve(handlers.roadmaps?.() ?? jsonResponse(true, summaries)); + } + if (url.includes('/api/learning/progress')) { + return Promise.resolve(handlers.progress?.() ?? jsonResponse(true, { entries: progressEntries })); + } + if (url.includes('/api/projects')) { + if (init?.method === 'POST') { + const body = JSON.parse(String(init.body)); + return Promise.resolve( + handlers.createProject?.(body) ?? + jsonResponse(true, { id: 'new1', name: body.name, createdAt: '2026-07-22T10:00:00.000Z' }) + ); + } + return Promise.resolve(handlers.projects?.() ?? jsonResponse(true, { projects })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); +} + +/** The learning view lives behind the side rail's Learning item. */ +function goToLearning() { + fireEvent.click(screen.getByRole('button', { name: 'Learning' })); +} + +describe('ProjectsPage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('shows skeleton cards while projects are loading', async () => { + // A projects request that never settles keeps the section in its loading state. + vi.stubGlobal('fetch', buildFetchMock({ projects: () => new Promise(() => {}) })); + render(); + + const section = screen.getByLabelText('Loading projects'); + expect(section.getAttribute('aria-busy')).toBe('true'); + expect(section.querySelectorAll('.skeleton').length).toBeGreaterThan(0); + }); + + it('shows a visible error block whose Retry refetches the projects', async () => { + let failures = 0; + const fetchMock = buildFetchMock({ + projects: () => { + failures += 1; + return failures === 1 + ? Promise.reject(new Error('network down')) + : jsonResponse(true, { projects }); + }, + }); + vi.stubGlobal('fetch', fetchMock); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render(); + + expect(await screen.findByText('Could not load projects. Is the backend running?')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(await screen.findByText('Lab one')).toBeInTheDocument(); + + errorSpy.mockRestore(); + }); + + it('renders project cards as buttons with a locale-formatted date', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + const onSelectProject = vi.fn(); + render(); + + const card = await screen.findByRole('button', { name: /Lab one/ }); + expect(card.tagName).toBe('BUTTON'); + expect(screen.getByText('Jul 1, 2026')).toBeInTheDocument(); + + fireEvent.click(card); + expect(onSelectProject).toHaveBeenCalledWith('p1', 'Lab one'); + }); + + it('opens on the projects view and switches to learning through the side rail', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + render(); + + expect(await screen.findByText('Lab one')).toBeInTheDocument(); + expect(screen.queryByText('Learn system design by running real infrastructure.')).toBeNull(); + + goToLearning(); + + expect(await screen.findByText('Learn system design by running real infrastructure.')).toBeInTheDocument(); + expect(screen.queryByText('Lab one')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Projects' })); + expect(await screen.findByText('Lab one')).toBeInTheDocument(); + }); + + it('sends the first-run hero "Start learning" to the learning view', async () => { + vi.stubGlobal('fetch', buildFetchMock({ projects: () => jsonResponse(true, { projects: [] }) })); + render(); + + expect(await screen.findByText('Build real infrastructure on your machine.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Start learning' })); + + expect(await screen.findByText('Learn system design by running real infrastructure.')).toBeInTheDocument(); + }); + + it('lists roadmaps of the UI language only, started ones first with a continue label', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + render(); + goToLearning(); + + const started = await screen.findByText('Cache-aside with Redis'); + expect(screen.queryByText('Cache-aside avec Redis')).toBeNull(); + expect(screen.getByText('Continue · step 4 of 8')).toBeInTheDocument(); + + const other = screen.getByText('Deploy a resilient three-tier app'); + // Started roadmap sorts before the untouched one. + expect( + started.compareDocumentPosition(other) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('deep-links a started roadmap straight into the project it was played in', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + const onSelectProject = vi.fn(); + render(); + goToLearning(); + + fireEvent.click(await screen.findByText('Cache-aside with Redis')); + + expect(onSelectProject).toHaveBeenCalledWith('p1', 'Lab one', { + roadmap: { id: 'cache-aside-redis', language: 'en' }, + }); + }); + + it('asks which project to use for an unstarted roadmap when several exist', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + const onSelectProject = vi.fn(); + render(); + goToLearning(); + + fireEvent.click(await screen.findByText('Deploy a resilient three-tier app')); + + const modalTitle = await screen.findByText('Choose a project'); + const modalPanel = modalTitle.parentElement as HTMLElement; + fireEvent.click(within(modalPanel).getByRole('button', { name: /Lab two/ })); + + expect(onSelectProject).toHaveBeenCalledWith('p2', 'Lab two', { + roadmap: { id: 'resilient-three-tier', language: 'en' }, + }); + }); + + it('creates "My first lab" when a roadmap is started with zero projects', async () => { + const fetchMock = buildFetchMock({ + projects: () => jsonResponse(true, { projects: [] }), + progress: () => jsonResponse(true, { entries: [] }), + }); + vi.stubGlobal('fetch', fetchMock); + const onSelectProject = vi.fn(); + render(); + goToLearning(); + + fireEvent.click(await screen.findByText('Cache-aside with Redis')); + + await waitFor(() => { + expect(onSelectProject).toHaveBeenCalledWith('new1', 'My first lab', { + roadmap: { id: 'cache-aside-redis', language: 'en' }, + }); + }); + const createCall = fetchMock.mock.calls.find(call => call[1]?.method === 'POST'); + expect(JSON.parse(String(createCall?.[1]?.body))).toEqual({ name: 'My first lab' }); + }); +}); diff --git a/frontend/src/pages/ProjectsPage/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage/ProjectsPage.tsx index 6a54693..0f49945 100644 --- a/frontend/src/pages/ProjectsPage/ProjectsPage.tsx +++ b/frontend/src/pages/ProjectsPage/ProjectsPage.tsx @@ -1,42 +1,49 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { AlertTriangle, Plus, X } from 'lucide-react'; +import { AlertTriangle, X } from 'lucide-react'; import InputModal from '../../shared/components/InputModal'; import ConfirmModal from '../../shared/components/ConfirmModal'; -import ProjectCard from './components/ProjectCard'; -import EmptyState from './components/EmptyState'; +import PageHeader from './components/PageHeader'; +import SideRail from './components/SideRail'; +import ProjectsSection from './components/ProjectsSection'; +import type { HomeView } from './components/SideRail'; +import ProjectPickerModal from './components/ProjectPickerModal'; +import LearningSection from './components/learning/LearningSection'; import { API_BASE } from '../../shared/types'; -import type { Project } from '../../shared/types'; -import logo from '../../assets/logo.png'; +import type { LearningIntent, Project } from '../../shared/types'; +import type { ProgressEntrySummary, RoadmapSummary } from '../../shared/types/roadmap'; interface ProjectsPageProps { - onSelectProject: (id: string, name: string) => void; + onSelectProject: (id: string, name: string, intent?: LearningIntent) => void; } -declare const __APP_VERSION__: string; - export default function ProjectsPage({ onSelectProject }: ProjectsPageProps) { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); + // Which home view the side rail is on; session-only, Projects first. + const [view, setView] = useState('projects'); const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deletingIds, setDeletingIds] = useState([]); const [storeRecovered, setStoreRecovered] = useState(false); - - const toggleLanguage = () => { - const nextLang = i18n.language === 'fr' ? 'en' : 'fr'; - i18n.changeLanguage(nextLang); - localStorage.setItem('torollo_lang', nextLang); - }; + // Roadmap waiting for a project choice in the picker modal. + const [pickerTarget, setPickerTarget] = useState(null); + // Learning intent to apply to the next project created through the input + // modal (picker → "New project", or a failed auto-create fallback). + const pendingIntentRef = useRef(null); const fetchProjects = async () => { try { setLoading(true); + setLoadError(false); const res = await fetch(`${API_BASE}/api/projects`); const data = await res.json(); - if (Array.isArray(data?.projects)) { + if (res.ok && Array.isArray(data?.projects)) { setProjects(data.projects); + } else { + setLoadError(true); } // One-shot notice from the backend: the projects file was unreadable // and has been moved aside — keep the banner up until dismissed. @@ -44,7 +51,8 @@ export default function ProjectsPage({ onSelectProject }: ProjectsPageProps) { setStoreRecovered(true); } } catch (err) { - console.error(err); + console.error('Failed to fetch projects:', err); + setLoadError(true); } finally { setLoading(false); } @@ -60,13 +68,19 @@ export default function ProjectsPage({ onSelectProject }: ProjectsPageProps) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); - if (res.ok) { - fetchProjects(); - setShowCreateModal(false); - } else { + if (!res.ok) { const errorData = await res.json(); throw new Error(errorData.error || 'Failed to create project'); } + const project: Project = await res.json(); + const intent = pendingIntentRef.current; + pendingIntentRef.current = null; + setShowCreateModal(false); + if (intent) { + onSelectProject(project.id, project.name, intent); + return; + } + fetchProjects(); }; const handleDeleteConfirmed = async () => { @@ -83,74 +97,97 @@ export default function ProjectsPage({ onSelectProject }: ProjectsPageProps) { console.error(err); } finally { await fetchProjects(); - setDeletingIds(prev => prev.filter((x) => x !== id)); + setDeletingIds(prev => prev.filter(x => x !== id)); } }; - const handleDeleteClick = (project: Project, event: React.MouseEvent) => { - event.stopPropagation(); - setDeleteTarget(project); + /** Creates a project without a dialog (DESIGN §4.1's "Start learning" path). */ + const autoCreateAndOpen = async (intent: LearningIntent) => { + try { + const res = await fetch(`${API_BASE}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: t('projects.firstLabName') }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const project: Project = await res.json(); + onSelectProject(project.id, project.name, intent); + } catch (err) { + // Fall back to the standard dialog, which surfaces submit errors itself. + console.error('Failed to auto-create a project:', err); + pendingIntentRef.current = intent; + setShowCreateModal(true); + } + }; + + /** + * A roadmap runs against one project's containers. Resolution order: + * resume where it was last played → the only project → ask → create one. + */ + const startRoadmap = (summary: RoadmapSummary, progress?: ProgressEntrySummary) => { + const intent: LearningIntent = { roadmap: { id: summary.id, language: summary.language } }; + if (progress) { + const played = projects.find(p => p.id === progress.projectId); + if (played) { + onSelectProject(played.id, played.name, intent); + return; + } + } + if (projects.length === 1) { + onSelectProject(projects[0].id, projects[0].name, intent); + return; + } + if (projects.length === 0) { + autoCreateAndOpen(intent); + return; + } + setPickerTarget(summary); }; return ( -
- {/* Page header */} -
-
-
- Logo -
-
-
-

{t('projects.title')}

- v{__APP_VERSION__} -
-

{t('projects.subtitle')}

-
-
-
- - -
-
+
+ - {storeRecovered && ( -
- - {t('projects.storeRecovered')} - -
- )} +
+
+ setShowCreateModal(true)} /> - {loading &&

{t('projects.loading')}

} + {view === 'projects' ? ( + <> + {storeRecovered && ( +
+ + {t('projects.storeRecovered')} + +
+ )} - {/* Project grid */} -
- {projects.map((p) => ( - - ))} - {!loading && projects.length === 0 && } + setDeleteTarget(project)} + deletingIds={deletingIds} + onStartLearning={() => setView('learning')} + onStartScratch={() => setShowCreateModal(true)} + /> + + ) : ( + + )} +
{/* Modals */} @@ -161,111 +198,82 @@ export default function ProjectsPage({ onSelectProject }: ProjectsPageProps) { placeholder={t('projects.createPlaceholder')} submitText={t('projects.createSubmit')} onSubmit={handleCreateProject} - onCancel={() => setShowCreateModal(false)} + onCancel={() => { + pendingIntentRef.current = null; + setShowCreateModal(false); + }} /> )} {deleteTarget && ( setDeleteTarget(null)} /> )} + + {pickerTarget && ( + { + const target = pickerTarget; + setPickerTarget(null); + onSelectProject(project.id, project.name, { + roadmap: { id: target.id, language: target.language }, + }); + }} + onNewProject={() => { + pendingIntentRef.current = { + roadmap: { id: pickerTarget.id, language: pickerTarget.language }, + }; + setPickerTarget(null); + setShowCreateModal(true); + }} + onCancel={() => setPickerTarget(null)} + /> + )}
); } const styles: Record = { + shell: { + display: 'flex', + height: '100%', + minHeight: 0, + }, + content: { + flex: 1, + minWidth: 0, + overflowY: 'auto', + }, container: { - padding: '48px 60px', + padding: 'var(--space-8) 60px', maxWidth: '1200px', margin: '0 auto', width: '100%', boxSizing: 'border-box', - overflowY: 'auto', - height: '100%', - }, - header: { display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: '40px', - }, - logoRow: { - display: 'flex', - alignItems: 'center', - gap: '14px', - }, - iconWrap: { - width: '44px', - height: '44px', - borderRadius: '50%', - background: '#FFFFFF', - border: '1px solid rgba(0, 0, 0, 0.08)', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - title: { - fontSize: '24px', - fontWeight: 700, - color: 'var(--color-text-primary)', - margin: 0, - letterSpacing: '-0.5px', - }, - subtitle: { - fontSize: '13px', - color: 'var(--color-text-muted)', - margin: '2px 0 0 0', - }, - badge: { - fontSize: '11px', - fontWeight: 600, - backgroundColor: 'var(--color-accent-glow)', - color: 'var(--color-accent)', - padding: '2px 10px', - borderRadius: '12px', - border: '1px solid rgba(37, 99, 235, 0.2)', - marginTop: '4px', - }, - createBtn: { - backgroundColor: 'var(--color-accent)', - color: '#FFF', - border: 'none', - borderRadius: '10px', - padding: '0 20px', - height: '42px', - fontSize: '13px', - fontWeight: 600, - fontFamily: 'var(--font-sans)', - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - transition: 'all 0.2s', - boxShadow: '0 1px 3px rgba(37, 99, 235, 0.3)', - }, - loading: { - color: 'var(--color-text-secondary)', - fontSize: '14px', + flexDirection: 'column', + gap: 'var(--space-6)', }, noticeBox: { display: 'flex', alignItems: 'flex-start', - gap: '6px', - padding: '8px 10px', + gap: 'var(--space-2)', + padding: 'var(--space-2) var(--space-3)', border: '1px solid var(--color-warning)', - borderRadius: '6px', + borderRadius: 'var(--radius-sm)', backgroundColor: 'var(--color-warning-glow)', - marginBottom: '20px', }, noticeText: { flex: 1, - fontSize: '12px', + fontSize: 'var(--text-sm)', color: 'var(--color-warning-strong)', lineHeight: 1.5, }, @@ -279,9 +287,4 @@ const styles: Record = { cursor: 'pointer', color: 'var(--color-warning-strong)', }, - grid: { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', - gap: '20px', - }, }; diff --git a/frontend/src/pages/ProjectsPage/components/EmptyState.tsx b/frontend/src/pages/ProjectsPage/components/EmptyState.tsx deleted file mode 100644 index f386407..0000000 --- a/frontend/src/pages/ProjectsPage/components/EmptyState.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Folder } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -export default function EmptyState() { - const { t } = useTranslation(); - return ( -
-
- -
-

{t('projects.emptyTitle')}

-

- {t('projects.emptyDesc')} -

-
- ); -} - -const styles: Record = { - empty: { - gridColumn: '1 / -1', - textAlign: 'center', - padding: '80px 0', - }, - emptyIcon: { - marginBottom: '16px', - }, - emptyTitle: { - fontSize: '16px', - fontWeight: 600, - color: 'var(--color-text-primary)', - margin: '0 0 6px 0', - }, - emptyDesc: { - fontSize: '13px', - color: 'var(--color-text-muted)', - margin: 0, - }, -}; diff --git a/frontend/src/pages/ProjectsPage/components/FirstRunHero.tsx b/frontend/src/pages/ProjectsPage/components/FirstRunHero.tsx new file mode 100644 index 0000000..bcfcd04 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/FirstRunHero.tsx @@ -0,0 +1,133 @@ +import { useTranslation } from 'react-i18next'; +import { Database } from 'lucide-react'; +import Button from '../../../shared/components/Button'; +import Receipt from '../../../shared/components/Receipt'; + +interface FirstRunHeroProps { + onStartLearning: () => void; + onStartScratch: () => void; +} + +/** + * Shown instead of the project grid while no project exists: the page must + * *be* the pitch, not assume the user already knows Torollo (DESIGN §4.1). + */ +export default function FirstRunHero({ onStartLearning, onStartScratch }: FirstRunHeroProps) { + const { t } = useTranslation(); + return ( +
+
+

{t('projects.firstRun.title')}

+

{t('projects.firstRun.body')}

+
+ + +
+
+ {/* Mini-canvas vignette in the product's real visual language: one + node card with a live status dot, plus a receipt. Decorative. */} + +
+ ); +} + +const styles: Record = { + hero: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-8)', + flexWrap: 'wrap', + padding: 'var(--space-8)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + boxShadow: 'var(--shadow-sm)', + }, + copy: { + flex: '2 1 380px', + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-4)', + }, + title: { + fontSize: 'var(--text-2xl)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + letterSpacing: '-0.5px', + }, + body: { + fontSize: 'var(--text-md)', + color: 'var(--color-text-secondary)', + lineHeight: 1.6, + margin: 0, + maxWidth: '520px', + }, + ctaRow: { + display: 'flex', + gap: 'var(--space-3)', + flexWrap: 'wrap', + }, + visual: { + flex: '1 1 260px', + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-3)', + maxWidth: '320px', + }, + nodeCard: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-3)', + padding: 'var(--space-3) var(--space-4)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + boxShadow: 'var(--shadow-md)', + }, + nodeIcon: { + width: '32px', + height: '32px', + borderRadius: 'var(--radius-md)', + background: 'var(--color-danger-glow)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + nodeName: { + fontSize: 'var(--text-md)', + fontWeight: 600, + color: 'var(--color-text-primary)', + }, + nodeSub: { + fontSize: 'var(--text-xs)', + color: 'var(--color-text-muted)', + }, + statusDot: { + width: '8px', + height: '8px', + borderRadius: '50%', + background: 'var(--color-success)', + marginLeft: 'auto', + boxShadow: '0 0 6px color-mix(in srgb, var(--color-success) 60%, transparent)', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/PageHeader.tsx b/frontend/src/pages/ProjectsPage/components/PageHeader.tsx new file mode 100644 index 0000000..59872b9 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/PageHeader.tsx @@ -0,0 +1,119 @@ +import { useTranslation } from 'react-i18next'; +import { BookOpen, Plus } from 'lucide-react'; +import Button from '../../../shared/components/Button'; +import logo from '../../../assets/logo.png'; + +interface PageHeaderProps { + onNewProject: () => void; +} + +declare const __APP_VERSION__: string; + +const DOCS_URL = 'https://github.com/Derssa/torollo#readme'; + +export default function PageHeader({ onNewProject }: PageHeaderProps) { + const { t, i18n } = useTranslation(); + + const toggleLanguage = () => { + const nextLang = i18n.language === 'fr' ? 'en' : 'fr'; + i18n.changeLanguage(nextLang); + localStorage.setItem('torollo_lang', nextLang); + }; + + return ( +
+ ); +} + +const styles: Record = { + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: 'var(--space-4)', + flexWrap: 'wrap', + }, + logoRow: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-4)', + }, + iconWrap: { + width: '44px', + height: '44px', + borderRadius: '50%', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + boxShadow: 'var(--shadow-sm)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + logoImg: { + width: '28px', + height: '28px', + objectFit: 'contain', + }, + titleRow: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-3)', + }, + title: { + fontSize: 'var(--text-2xl)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + letterSpacing: '-0.5px', + }, + subtitle: { + fontSize: 'var(--text-md)', + color: 'var(--color-text-muted)', + margin: '2px 0 0 0', + }, + badge: { + fontSize: 'var(--text-xs)', + fontWeight: 600, + backgroundColor: 'var(--color-accent-glow)', + color: 'var(--color-accent)', + padding: '2px var(--space-2)', + borderRadius: 'var(--radius-sm)', + border: '1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)', + }, + actions: { + display: 'flex', + gap: 'var(--space-3)', + alignItems: 'center', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/ProjectCard.tsx b/frontend/src/pages/ProjectsPage/components/ProjectCard.tsx index 515f3b3..2237818 100644 --- a/frontend/src/pages/ProjectsPage/components/ProjectCard.tsx +++ b/frontend/src/pages/ProjectsPage/components/ProjectCard.tsx @@ -5,96 +5,100 @@ import type { Project } from '../../../shared/types'; interface ProjectCardProps { project: Project; onSelect: (id: string, name: string) => void; - onDelete: (project: Project, event: React.MouseEvent) => void; + onDelete: (project: Project) => void; isDeleting?: boolean; } export default function ProjectCard({ project, onSelect, onDelete, isDeleting }: ProjectCardProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); return ( -
!isDeleting && onSelect(project.id, project.name)} - style={{ - ...styles.card, - position: 'relative', - opacity: isDeleting ? 0.7 : 1, - pointerEvents: isDeleting ? 'none' : 'auto', - }} - id={`project-card-${project.id}`} - > + // Wrapper so the delete control can be a *sibling* of the card button — + // a button nested inside a button is invalid HTML and breaks focus order. +
+ + {isDeleting && (
- {t('projects.deleting')} + {t('projects.deleting')}
)} -
-
- -
- -
-
-

{project.name}

-

- {new Date(project.createdAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} -

-
-
- {t('projects.openStack')} - -
); } const styles: Record = { + wrapper: { + position: 'relative', + }, card: { - padding: '24px', - borderRadius: '16px', + width: '100%', + padding: 'var(--space-6)', + borderRadius: 'var(--radius-lg)', cursor: 'pointer', transition: 'transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s', display: 'flex', flexDirection: 'column', + alignItems: 'stretch', + textAlign: 'left', height: '190px', justifyContent: 'space-between', backgroundColor: 'var(--bg-surface-solid)', border: '1px solid var(--border-color)', - boxShadow: '0 1px 3px rgba(0, 0, 0, 0.04)', - }, - cardHeader: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'flex-start', + boxShadow: 'var(--shadow-sm)', + fontFamily: 'var(--font-sans)', }, cardIcon: { width: '40px', height: '40px', - borderRadius: '10px', + borderRadius: 'var(--radius-md)', background: 'var(--color-accent-glow)', display: 'flex', alignItems: 'center', justifyContent: 'center', }, deleteBtn: { + position: 'absolute', + top: 'var(--space-5)', + right: 'var(--space-5)', background: 'none', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', - padding: '6px', - borderRadius: '6px', + padding: 'var(--space-2)', + borderRadius: 'var(--radius-sm)', display: 'flex', alignItems: 'center', transition: 'color 0.2s, background 0.2s', @@ -109,11 +113,11 @@ const styles: Record = { fontSize: '16px', fontWeight: 600, color: 'var(--color-text-primary)', - margin: '0 0 4px 0', + margin: '0 0 var(--space-1) 0', letterSpacing: '-0.2px', }, projectMeta: { - fontSize: '12px', + fontSize: 'var(--text-sm)', color: 'var(--color-text-muted)', margin: 0, }, @@ -121,21 +125,24 @@ const styles: Record = { display: 'flex', alignItems: 'center', color: 'var(--color-accent)', - fontSize: '13px', + fontSize: 'var(--text-md)', fontWeight: 500, }, loadingOverlay: { position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(255, 255, 255, 0.75)', + inset: 0, + backgroundColor: 'var(--bg-surface)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', - borderRadius: '16px', + borderRadius: 'var(--radius-lg)', zIndex: 10, }, + deletingLabel: { + fontSize: 'var(--text-xs)', + marginTop: 'var(--space-2)', + color: 'var(--color-text-secondary)', + fontWeight: 600, + }, }; diff --git a/frontend/src/pages/ProjectsPage/components/ProjectCardSkeleton.tsx b/frontend/src/pages/ProjectsPage/components/ProjectCardSkeleton.tsx new file mode 100644 index 0000000..0949b2e --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/ProjectCardSkeleton.tsx @@ -0,0 +1,34 @@ +import Skeleton from '../../../shared/components/Skeleton'; + +/** Same 190px frame as ProjectCard, sketched with placeholder blocks. */ +export default function ProjectCardSkeleton() { + return ( + + ); +} + +const styles: Record = { + card: { + padding: 'var(--space-6)', + borderRadius: 'var(--radius-lg)', + display: 'flex', + flexDirection: 'column', + height: '190px', + justifyContent: 'space-between', + backgroundColor: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + boxSizing: 'border-box', + }, + body: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-2)', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/ProjectPickerModal.tsx b/frontend/src/pages/ProjectsPage/components/ProjectPickerModal.tsx new file mode 100644 index 0000000..9cfdc80 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/ProjectPickerModal.tsx @@ -0,0 +1,96 @@ +import { useTranslation } from 'react-i18next'; +import { ArrowRight, Folder, Plus } from 'lucide-react'; +import Modal from '../../../shared/components/Modal'; +import Button from '../../../shared/components/Button'; +import type { Project } from '../../../shared/types'; + +interface ProjectPickerModalProps { + projects: Project[]; + roadmapTitle: string; + onPick: (project: Project) => void; + onNewProject: () => void; + onCancel: () => void; +} + +/** A roadmap validates against one project's containers — ask which one. */ +export default function ProjectPickerModal({ + projects, + roadmapTitle, + onPick, + onNewProject, + onCancel, +}: ProjectPickerModalProps) { + const { t } = useTranslation(); + return ( + +

{t('learning.landing.pickProjectTitle')}

+

{t('learning.landing.pickProjectBody', { roadmap: roadmapTitle })}

+
+ {projects.map(project => ( + + ))} +
+
+ + +
+
+ ); +} + +const styles: Record = { + title: { + fontSize: 'var(--text-xl)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: '0 0 var(--space-2) 0', + }, + body: { + fontSize: 'var(--text-md)', + color: 'var(--color-text-secondary)', + lineHeight: 1.5, + margin: '0 0 var(--space-4) 0', + }, + list: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-2)', + maxHeight: '260px', + overflowY: 'auto', + marginBottom: 'var(--space-4)', + }, + row: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-3)', + padding: 'var(--space-3) var(--space-4)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-md)', + cursor: 'pointer', + textAlign: 'left', + fontFamily: 'var(--font-sans)', + transition: 'border-color 0.15s ease', + }, + rowName: { + flex: 1, + fontSize: 'var(--text-md)', + fontWeight: 600, + color: 'var(--color-text-primary)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + footer: { + display: 'flex', + justifyContent: 'space-between', + gap: 'var(--space-3)', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/ProjectsSection.tsx b/frontend/src/pages/ProjectsPage/components/ProjectsSection.tsx new file mode 100644 index 0000000..4d681d7 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/ProjectsSection.tsx @@ -0,0 +1,113 @@ +import { useTranslation } from 'react-i18next'; +import Button from '../../../shared/components/Button'; +import ProjectCard from './ProjectCard'; +import ProjectCardSkeleton from './ProjectCardSkeleton'; +import FirstRunHero from './FirstRunHero'; +import type { Project } from '../../../shared/types'; + +interface ProjectsSectionProps { + projects: Project[]; + loading: boolean; + error: boolean; + onRetry: () => void; + onSelect: (id: string, name: string) => void; + onDelete: (project: Project) => void; + deletingIds: string[]; + onStartLearning: () => void; + onStartScratch: () => void; +} + +export default function ProjectsSection({ + projects, + loading, + error, + onRetry, + onSelect, + onDelete, + deletingIds, + onStartLearning, + onStartScratch, +}: ProjectsSectionProps) { + const { t } = useTranslation(); + + if (loading) { + return ( +
+ {t('projects.sectionTitle')} +
+ + + +
+
+ ); + } + + if (error) { + return ( +
+ {t('projects.sectionTitle')} +
+ {t('projects.loadError')} + +
+
+ ); + } + + if (projects.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {t('projects.sectionTitle')} +
+ {projects.map(p => ( + + ))} +
+
+ ); +} + +const styles: Record = { + eyebrow: { + display: 'block', + fontSize: 'var(--text-xs)', + fontWeight: 500, + textTransform: 'uppercase', + letterSpacing: '0.5px', + color: 'var(--color-text-muted)', + marginBottom: 'var(--space-3)', + }, + grid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', + gap: 'var(--space-5)', + }, + errorBox: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-4)', + padding: 'var(--space-4) var(--space-5)', + border: '1px solid color-mix(in srgb, var(--color-danger) 30%, transparent)', + background: 'var(--color-danger-glow)', + borderRadius: 'var(--radius-md)', + }, + errorText: { + flex: 1, + fontSize: 'var(--text-md)', + color: 'var(--color-text-primary)', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/SideRail.tsx b/frontend/src/pages/ProjectsPage/components/SideRail.tsx new file mode 100644 index 0000000..d9cd548 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/SideRail.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from 'react-i18next'; +import { LayoutGrid, GraduationCap } from 'lucide-react'; +import logo from '../../../assets/logo.png'; + +export type HomeView = 'projects' | 'learning'; + +interface SideRailProps { + view: HomeView; + onNavigate: (view: HomeView) => void; +} + +/** Dark icon rail on the home shell — switches between the two home views. */ +export default function SideRail({ view, onNavigate }: SideRailProps) { + const { t } = useTranslation(); + + const items = [ + { key: 'projects', label: t('nav.projects'), Icon: LayoutGrid }, + { key: 'learning', label: t('nav.learning'), Icon: GraduationCap }, + ] as const; + + return ( + + ); +} + +const styles: Record = { + rail: { + width: '56px', + flexShrink: 0, + background: 'var(--bg-rail)', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: 'var(--space-4) 0', + gap: 'var(--space-6)', + }, + logoTile: { + width: '36px', + height: '36px', + borderRadius: 'var(--radius-md)', + background: 'var(--bg-surface-solid)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + logoImg: { + width: '24px', + height: '24px', + objectFit: 'contain', + }, + items: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 'var(--space-2)', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/LearningHero.tsx b/frontend/src/pages/ProjectsPage/components/learning/LearningHero.tsx new file mode 100644 index 0000000..530ce0f --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/LearningHero.tsx @@ -0,0 +1,98 @@ +import { useTranslation } from 'react-i18next'; +import { ArrowRight, GraduationCap } from 'lucide-react'; +import Button from '../../../../shared/components/Button'; +import Receipt from '../../../../shared/components/Receipt'; +import { HERO_RECEIPT_LINES } from './sampleReceipt'; + +interface LearningHeroProps { + onStart: () => void; + onBrowse: () => void; +} + +export default function LearningHero({ onStart, onBrowse }: LearningHeroProps) { + const { t } = useTranslation(); + return ( +
+
+

{t('learning.landing.heroTitle')}

+

{t('learning.landing.heroBody')}

+
+ + +
+
+
+ +
+
+ ); +} + +const styles: Record = { + hero: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-6)', + flexWrap: 'wrap', + padding: 'var(--space-6)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + boxShadow: 'var(--shadow-sm)', + }, + copy: { + flex: '3 1 320px', + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-3)', + }, + title: { + fontSize: 'var(--text-2xl)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + letterSpacing: '-0.5px', + }, + body: { + fontSize: 'var(--text-md)', + color: 'var(--color-text-secondary)', + lineHeight: 1.6, + margin: 0, + maxWidth: '460px', + }, + ctaRow: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-4)', + flexWrap: 'wrap', + marginTop: 'var(--space-1)', + }, + browseLink: { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--space-1)', + background: 'none', + border: 'none', + padding: 'var(--space-2)', + color: 'var(--color-accent)', + fontSize: 'var(--text-md)', + fontWeight: 600, + fontFamily: 'var(--font-sans)', + cursor: 'pointer', + borderRadius: 'var(--radius-sm)', + }, + receiptWrap: { + flex: '2 1 260px', + maxWidth: '360px', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/LearningSection.tsx b/frontend/src/pages/ProjectsPage/components/learning/LearningSection.tsx new file mode 100644 index 0000000..180d772 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/LearningSection.tsx @@ -0,0 +1,170 @@ +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { BookOpen, GraduationCap } from 'lucide-react'; +import Button from '../../../../shared/components/Button'; +import { useRoadmaps } from '../../../../features/learning/hooks/useRoadmaps'; +import { useLearningProgressSummaries } from '../../../../features/learning/hooks/useLearningProgressSummaries'; +import { filterByUiLanguage } from '../../../../features/learning/roadmapLanguage'; +import LearningHero from './LearningHero'; +import RoadmapShowcaseCard from './RoadmapShowcaseCard'; +import WhyPanel from './WhyPanel'; +import Skeleton from '../../../../shared/components/Skeleton'; +import type { ProgressEntrySummary, RoadmapSummary } from '../../../../shared/types/roadmap'; + +interface LearningSectionProps { + onStartRoadmap: (summary: RoadmapSummary, progress?: ProgressEntrySummary) => void; +} + +export default function LearningSection({ onStartRoadmap }: LearningSectionProps) { + const { t, i18n } = useTranslation(); + const { summaries, loading, error, fetchRoadmaps } = useRoadmaps(); + const { byRoadmapId, fetchProgress } = useLearningProgressSummaries(); + const roadmapsPanelRef = useRef(null); + + useEffect(() => { + fetchRoadmaps(); + fetchProgress(); + }, [fetchRoadmaps, fetchProgress]); + + const visible = filterByUiLanguage(summaries, i18n.language); + // Started roadmaps first, most recently played on top; the rest keep + // catalogue order (DESIGN §4.2). + const sorted = [...visible].sort((a, b) => { + const pa = byRoadmapId[a.id]?.updatedAt ?? ''; + const pb = byRoadmapId[b.id]?.updatedAt ?? ''; + if (pa !== pb) return pb.localeCompare(pa); + return 0; + }); + + const heroTarget = sorted[0]; + + const browseToRoadmaps = () => { + roadmapsPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + roadmapsPanelRef.current?.focus({ preventScroll: true }); + }; + + return ( +
+
+ +

{t('learning.landing.title')}

+
+

{t('learning.landing.subtitle')}

+ +
+
+ {heroTarget && ( + onStartRoadmap(heroTarget, byRoadmapId[heroTarget.id])} + onBrowse={browseToRoadmaps} + /> + )} + +
+
+ +

{t('learning.landing.roadmapsTitle')}

+
+ {loading ? ( +
+ + +
+ ) : error ? ( +
+ {t('learning.catalog.error')} + +
+ ) : sorted.length === 0 ? ( +
{t('learning.catalog.empty')}
+ ) : ( +
+ {sorted.map(summary => ( + onStartRoadmap(summary, byRoadmapId[summary.id])} + /> + ))} +
+ )} +
+
+ +
+ +
+
+
+ ); +} + +const styles: Record = { + titleRow: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-2)', + }, + title: { + fontSize: 'var(--text-xl)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + letterSpacing: '-0.3px', + }, + subtitle: { + fontSize: 'var(--text-md)', + color: 'var(--color-text-secondary)', + margin: 'var(--space-1) 0 var(--space-4) 0', + }, + columns: { + display: 'flex', + gap: 'var(--space-5)', + alignItems: 'flex-start', + flexWrap: 'wrap', + }, + main: { + flex: '2 1 480px', + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-5)', + minWidth: 0, + }, + side: { + flex: '1 1 280px', + minWidth: 0, + }, + // The roadmap cards sit directly on the page background (mockup look) — + // no wrapping panel chrome, just the header row and the grid. + roadmapsPanel: { + outline: 'none', + }, + roadmapsTitleRow: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-2)', + margin: '0 0 var(--space-4) 0', + }, + roadmapsTitle: { + fontSize: 'var(--text-lg)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + }, + cardList: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', + gap: 'var(--space-4)', + }, + status: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 'var(--space-3)', + padding: 'var(--space-6) var(--space-4)', + fontSize: 'var(--text-sm)', + color: 'var(--color-text-muted)', + textAlign: 'center', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/RoadmapShowcaseCard.tsx b/frontend/src/pages/ProjectsPage/components/learning/RoadmapShowcaseCard.tsx new file mode 100644 index 0000000..6e3bca7 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/RoadmapShowcaseCard.tsx @@ -0,0 +1,153 @@ +import { useTranslation } from 'react-i18next'; +import { CheckCircle2, ClipboardList, Clock } from 'lucide-react'; +import ProgressBar from '../../../../shared/components/ProgressBar'; +import DifficultyChip from '../../../../features/learning/components/DifficultyChip'; +import { roadmapVisual } from './roadmapVisual'; +import type { ProgressEntrySummary, RoadmapSummary } from '../../../../shared/types/roadmap'; + +interface RoadmapShowcaseCardProps { + summary: RoadmapSummary; + /** Most recent play-through of this roadmap across all projects, if any. */ + progress?: ProgressEntrySummary; + onOpen: () => void; +} + +export default function RoadmapShowcaseCard({ summary, progress, onOpen }: RoadmapShowcaseCardProps) { + const { t } = useTranslation(); + const started = (progress?.completedSteps ?? 0) > 0; + const completed = started && (progress as ProgressEntrySummary).completedSteps >= summary.stepCount; + const { Icon, color } = roadmapVisual(summary.id); + + return ( + + ); +} + +const styles: Record = { + card: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-3)', + padding: 'var(--space-4)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + background: 'var(--bg-surface-solid)', + boxShadow: 'var(--shadow-sm)', + cursor: 'pointer', + textAlign: 'left', + fontFamily: 'var(--font-sans)', + transition: 'border-color 0.15s ease, box-shadow 0.15s ease', + }, + header: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-3)', + }, + iconTile: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 40, + height: 40, + flexShrink: 0, + borderRadius: 'var(--radius-md)', + }, + title: { + fontSize: 'var(--text-md)', + fontWeight: 600, + color: 'var(--color-text-primary)', + lineHeight: 1.3, + }, + description: { + fontSize: 'var(--text-sm)', + color: 'var(--color-text-secondary)', + lineHeight: 1.5, + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + }, + meta: { + display: 'flex', + gap: 'var(--space-4)', + fontSize: 'var(--text-xs)', + color: 'var(--color-text-muted)', + // Pushes the meta/progress footer to the card bottom so rows align + // across cards with different description lengths. + marginTop: 'auto', + paddingTop: 'var(--space-2)', + borderTop: '1px solid var(--border-color)', + }, + metaItem: { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--space-1)', + }, + progressBlock: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-2)', + }, + progressLabel: { + fontSize: 'var(--text-xs)', + fontWeight: 600, + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/WhyPanel.tsx b/frontend/src/pages/ProjectsPage/components/learning/WhyPanel.tsx new file mode 100644 index 0000000..a7ba41f --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/WhyPanel.tsx @@ -0,0 +1,89 @@ +import { useTranslation } from 'react-i18next'; +import { Box, ReceiptText, Terminal } from 'lucide-react'; +import Receipt from '../../../../shared/components/Receipt'; +import { WHY_RECEIPT_LINES } from './sampleReceipt'; + +const POINTS = [ + { icon: Box, titleKey: 'learning.landing.whyContainersTitle', bodyKey: 'learning.landing.whyContainersBody' }, + { icon: Terminal, titleKey: 'learning.landing.whyCommandsTitle', bodyKey: 'learning.landing.whyCommandsBody' }, + { icon: ReceiptText, titleKey: 'learning.landing.whyReceiptsTitle', bodyKey: 'learning.landing.whyReceiptsBody' }, +] as const; + +export default function WhyPanel() { + const { t } = useTranslation(); + return ( +
+

{t('learning.landing.whyTitle')}

+
+ {POINTS.map(({ icon: Icon, titleKey, bodyKey }) => ( +
+
+ +
+
+
{t(titleKey)}
+
{t(bodyKey)}
+
+
+ ))} +
+ +
+ ); +} + +const styles: Record = { + panel: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-4)', + padding: 'var(--space-5)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + boxShadow: 'var(--shadow-sm)', + alignSelf: 'flex-start', + }, + title: { + fontSize: 'var(--text-lg)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + }, + points: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-4)', + }, + point: { + display: 'flex', + gap: 'var(--space-3)', + alignItems: 'flex-start', + }, + pointIcon: { + width: '26px', + height: '26px', + borderRadius: 'var(--radius-sm)', + background: 'var(--color-accent-glow)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + pointTitle: { + fontSize: 'var(--text-md)', + fontWeight: 600, + color: 'var(--color-text-primary)', + }, + pointBody: { + fontSize: 'var(--text-sm)', + color: 'var(--color-text-secondary)', + lineHeight: 1.5, + marginTop: '2px', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/roadmapVisual.ts b/frontend/src/pages/ProjectsPage/components/learning/roadmapVisual.ts new file mode 100644 index 0000000..b70b29b --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/roadmapVisual.ts @@ -0,0 +1,23 @@ +import { Database, Map, Network } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +export interface RoadmapVisual { + Icon: LucideIcon; + /** Color token driving the icon tile tint — decorative, not semantic. */ + color: string; +} + +/** + * Icon + accent per known roadmap for the showcase card's icon tile. + * Community roadmaps (unknown ids) fall back to a generic visual. + */ +const VISUALS: Record = { + 'cache-aside-redis': { Icon: Database, color: 'var(--color-accent)' }, + 'resilient-three-tier': { Icon: Network, color: 'var(--color-warning)' }, +}; + +const FALLBACK: RoadmapVisual = { Icon: Map, color: 'var(--color-accent)' }; + +export function roadmapVisual(roadmapId: string): RoadmapVisual { + return VISUALS[roadmapId] ?? FALLBACK; +} diff --git a/frontend/src/pages/ProjectsPage/components/learning/sampleReceipt.ts b/frontend/src/pages/ProjectsPage/components/learning/sampleReceipt.ts new file mode 100644 index 0000000..dcff846 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/sampleReceipt.ts @@ -0,0 +1,16 @@ +/** + * The sample receipts shown on the learning page. Always presented under a + * "Sample validation receipt" label — they illustrate what a real validation + * receipt looks like (style drawn from the cache-aside-redis roadmap) and + * must never masquerade as a live result. Hero and why-panel show different + * checks so the same block never appears twice on one screen. + */ +export const HERO_RECEIPT_LINES = [ + 'checked: redis-cli GET book:42:title', + '→ "Clean Architecture"', +]; + +export const WHY_RECEIPT_LINES = [ + 'checked: container "redis-cache" running', + '→ up, port 6379 → localhost:56379', +]; diff --git a/frontend/src/shared/components/Button.tsx b/frontend/src/shared/components/Button.tsx new file mode 100644 index 0000000..f35467f --- /dev/null +++ b/frontend/src/shared/components/Button.tsx @@ -0,0 +1,24 @@ +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'outline'; + size?: 'md' | 'lg'; +} + +/** + * The one button treatment of the app (DESIGN §4.4). Visuals live in the + * `.btn*` classes in index.css so hover states stay in CSS; link-shaped + * actions can reuse those classes directly on an . + */ +export default function Button({ + variant = 'outline', + size = 'md', + className, + children, + ...rest +}: ButtonProps) { + const classes = ['btn', `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(' '); + return ( + + ); +} diff --git a/frontend/src/shared/components/ProgressBar.tsx b/frontend/src/shared/components/ProgressBar.tsx new file mode 100644 index 0000000..505ac76 --- /dev/null +++ b/frontend/src/shared/components/ProgressBar.tsx @@ -0,0 +1,39 @@ +interface ProgressBarProps { + value: number; + max: number; + tone?: 'accent' | 'success'; + ariaLabel: string; +} + +/** Thin determinate progress bar (DESIGN §4.4) — 4px track, semantic fill. */ +export default function ProgressBar({ value, max, tone = 'accent', ariaLabel }: ProgressBarProps) { + const safeMax = Math.max(1, max); + const clamped = Math.min(Math.max(0, value), safeMax); + const color = tone === 'success' ? 'var(--color-success)' : 'var(--color-accent)'; + return ( +
+
+
+ ); +} + +const styles: Record = { + track: { + height: '4px', + borderRadius: 'var(--radius-sm)', + background: 'var(--bg-main)', + overflow: 'hidden', + }, + fill: { + height: '100%', + borderRadius: 'var(--radius-sm)', + transition: 'width 0.2s ease-out', + }, +}; diff --git a/frontend/src/shared/components/Receipt.test.tsx b/frontend/src/shared/components/Receipt.test.tsx new file mode 100644 index 0000000..8a4329d --- /dev/null +++ b/frontend/src/shared/components/Receipt.test.tsx @@ -0,0 +1,39 @@ +import '../../i18n'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import Receipt from './Receipt'; + +describe('Receipt', () => { + it('renders every line and the label eyebrow', () => { + render( + + ); + + expect(screen.getByText('Sample validation receipt')).toBeTruthy(); + expect(screen.getByText('checked: redis-cli GET session:demo')).toBeTruthy(); + expect(screen.getByText('→ "ok"')).toBeTruthy(); + }); + + it('shows no copy button without copyText', () => { + render(); + + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('copies the given text to the clipboard and confirms', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Copy' })); + + expect(writeText).toHaveBeenCalledWith('checked: something'); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/shared/components/Receipt.tsx b/frontend/src/shared/components/Receipt.tsx new file mode 100644 index 0000000..10c30c2 --- /dev/null +++ b/frontend/src/shared/components/Receipt.tsx @@ -0,0 +1,129 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Check, Copy } from 'lucide-react'; + +interface ReceiptProps { + /** Rendered as-is, one per row — always the real command/value, never a paraphrase. */ + lines: string[]; + /** Color of the 2px left border. */ + tone?: 'success' | 'accent' | 'warning' | 'danger' | 'neutral'; + /** Small sans eyebrow above the block (e.g. "Sample validation receipt"). */ + label?: string; + /** Shows a copy button when set. */ + copyText?: string; +} + +const TONE_COLORS: Record, string> = { + success: 'var(--color-success)', + accent: 'var(--color-accent)', + warning: 'var(--color-warning)', + danger: 'var(--color-danger)', + neutral: 'var(--color-text-muted)', +}; + +/** + * The signature evidence block (DESIGN §2): a quiet monospace transcript of + * the real thing that happened under the hood. Fades in once, never animates + * for attention. + */ +export default function Receipt({ lines, tone = 'neutral', label, copyText }: ReceiptProps) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const resetTimer = useRef(undefined); + + useEffect(() => () => window.clearTimeout(resetTimer.current), []); + + const handleCopy = async () => { + if (!copyText) return; + try { + await navigator.clipboard.writeText(copyText); + setCopied(true); + window.clearTimeout(resetTimer.current); + resetTimer.current = window.setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard access denied — the button simply stays in its idle state. + } + }; + + return ( +
+ {label && {label}} +
+
+ {lines.map((line, i) => ( + + {line} + + ))} +
+ {copyText && ( + + )} +
+
+ ); +} + +const styles: Record = { + label: { + display: 'block', + fontSize: 'var(--text-xs)', + fontWeight: 500, + textTransform: 'uppercase', + letterSpacing: '0.5px', + color: 'var(--color-text-muted)', + marginBottom: 'var(--space-2)', + }, + block: { + display: 'flex', + alignItems: 'flex-start', + gap: 'var(--space-2)', + background: 'var(--bg-subtle)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-sm)', + padding: 'var(--space-3)', + animation: 'receiptFadeIn 120ms ease-out', + }, + lines: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-1)', + minWidth: 0, + flex: 1, + }, + line: { + fontFamily: 'var(--font-mono)', + fontSize: 'var(--text-xs)', + color: 'var(--color-text-secondary)', + lineHeight: 1.6, + overflowWrap: 'anywhere', + }, + copyBtn: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '24px', + height: '24px', + flexShrink: 0, + background: 'transparent', + border: 'none', + borderRadius: 'var(--radius-sm)', + cursor: 'pointer', + padding: 0, + }, +}; diff --git a/frontend/src/shared/components/Skeleton.tsx b/frontend/src/shared/components/Skeleton.tsx new file mode 100644 index 0000000..c4e154a --- /dev/null +++ b/frontend/src/shared/components/Skeleton.tsx @@ -0,0 +1,16 @@ +interface SkeletonProps { + width?: string; + height?: string; + radius?: string; +} + +/** One loading placeholder block — compose several to sketch a card's layout. */ +export default function Skeleton({ width = '100%', height = '14px', radius }: SkeletonProps) { + return ( +