diff --git a/src/lib/api/attestation.ts b/src/lib/api/attestation.ts new file mode 100644 index 0000000..18097d9 --- /dev/null +++ b/src/lib/api/attestation.ts @@ -0,0 +1,55 @@ +import { createApiClient } from './client'; + +// --- Types (P26 v2 attestation publique) --- + +export interface AttestationValid { + valid: true; + challenger: { + username: string; + display_name: string; + avatar_url: string | null; + }; + validator: { + username: string; + display_name: string; + avatar_url: string | null; + }; + pr_url: string; + repo: string; + domain: string; + difficulty: number; + validated_at: string; + merged_upstream: boolean; +} + +export interface AttestationInvalid { + valid: false; + reason: 'malformed attestation hash' | 'unknown attestation hash' | string; +} + +export type AttestationResponse = AttestationValid | AttestationInvalid; + +// SKI-115 endpoint hors /api (public verify) +const publicApi = createApiClient(fetch, ''); + +export const attestationApi = { + // GET /verify/{hash} — retourne le JSON attestation + verify(hash: string) { + return publicApi.get(`/verify/${encodeURIComponent(hash)}`); + }, + + // URL directe du PDF (deep link, pas de fetch cote front) + pdfUrl(hash: string): string { + return `/verify/${encodeURIComponent(hash)}.pdf`; + }, + + // SKI-116 badge user SVG (URL directe) + badgeUserUrl(username: string): string { + return `/badge/user/${encodeURIComponent(username)}/validated.svg`; + }, + + // SKI-117 badge repo SVG (URL directe) + badgeRepoUrl(owner: string, name: string): string { + return `/badge/repo/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/validated.svg`; + } +}; diff --git a/src/lib/api/maintainerDigest.ts b/src/lib/api/maintainerDigest.ts new file mode 100644 index 0000000..2d843e3 --- /dev/null +++ b/src/lib/api/maintainerDigest.ts @@ -0,0 +1,51 @@ +import type { ApiResponse } from '$lib/types'; +import { createApiClient } from './client'; + +// SKI-120 endpoints hors /api (opt-in public) +const publicApi = createApiClient(fetch, ''); + +// --- Types --- + +export interface SubscribePayload { + github_login: string; + email: string; + repos: string[]; +} + +export interface SubscribeResponse { + pending: boolean; + message: string; +} + +export interface ConfirmResponse { + confirmed: boolean; + email: string; +} + +export interface UnsubscribeResponse { + unsubscribed: boolean; + email: string; +} + +// --- API --- + +export const maintainerDigestApi = { + subscribe(payload: SubscribePayload) { + return publicApi.post>( + '/maintainer-digest/subscribe', + payload + ); + }, + + confirm(token: string) { + return publicApi.get>( + `/maintainer-digest/confirm/${encodeURIComponent(token)}` + ); + }, + + unsubscribe(token: string) { + return publicApi.get>( + `/maintainer-digest/unsubscribe/${encodeURIComponent(token)}` + ); + } +}; diff --git a/src/lib/api/slices.ts b/src/lib/api/slices.ts new file mode 100644 index 0000000..2c8d87c --- /dev/null +++ b/src/lib/api/slices.ts @@ -0,0 +1,152 @@ +import type { ApiResponse } from '$lib/types'; +import { createApiClient } from './client'; + +const api = createApiClient(); + +// --- Types (P26 v2 workflow challenge) --- + +export type SliceStatus = + | 'open' + | 'claimed' + | 'in_progress' + | 'submitted' + | 'ci_green' + | 'pending_validation' + | 'validated' + | 'merged' + | 'closed' + | 'expired'; + +export interface SliceExternalMetadata { + issue_url?: string; + issue_number?: number; + repo_owner?: string; + repo_name?: string; +} + +export interface Slice { + id: string; + title: string; + description: string; + acceptance_criteria: string[]; + labels: string[]; + difficulty: number; + status: SliceStatus; + min_rank: string | null; + required_orientation_slugs: string[] | null; + external_metadata: SliceExternalMetadata | null; + fork_repo_url: string | null; + submitted_pr_url: string | null; + attestation_hash: string | null; + announced_at: string | null; + validation_reject_reason: string | null; + claimed_by_user_id: string | null; + claim_expires_at: string | null; + validator_user_id: string | null; + project_id: string; + project_slug: string; + created_at: string; + updated_at: string; +} + +export interface ActiveSkilluver { + user_id: string; + username: string; + display_name: string; + avatar_url: string | null; +} + +export interface ActiveSkilluversResponse { + count: number; + users: ActiveSkilluver[]; +} + +export interface DiaryEntry { + id: string; + slice_id: string; + author_user_id: string; + author_username: string; + author_display_name: string; + author_avatar_url: string | null; + body_markdown: string; + is_public: boolean; + created_at: string; +} + +export interface DiaryPostPayload { + body_markdown: string; + is_public: boolean; +} + +export interface SubmitPrPayload { + pr_url: string; + announce_publicly: boolean; +} + +export interface SlicesListParams { + status?: SliceStatus; + project_id?: string; + page?: number; + per_page?: number; +} + +// --- API --- + +export const slicesApi = { + list(params?: SlicesListParams) { + return api.get>( + '/slices', + params as Record + ); + }, + + get(id: string) { + return api.get>(`/slices/${id}`); + }, + + claim(id: string) { + return api.post>( + `/slices/${id}/claim` + ); + }, + + unclaim(id: string) { + return api.post>(`/slices/${id}/unclaim`); + }, + + submitPr(id: string, payload: SubmitPrPayload) { + return api.post>(`/slices/${id}/submit-pr`, payload); + }, + + // SKI-122 widget "Active Skilluvers on this repo" + activeSkilluvers(projectSlug: string, days = 30) { + return api.get>( + `/projects/${encodeURIComponent(projectSlug)}/active-skilluvers`, + { days } + ); + }, + + // SKI-123 challenger diary + diary(sliceId: string) { + return api.get>(`/slices/${sliceId}/diary`); + }, + + postDiaryEntry(sliceId: string, payload: DiaryPostPayload) { + return api.post>(`/slices/${sliceId}/diary`, payload); + }, + + // SKI-121 feed reco challenges + feedRecommended(limit = 20) { + return api.get< + ApiResponse<{ slices: Slice[]; meta?: { user_rank_ord?: number; median_difficulty?: number } }> + >('/me/feed/challenges', { limit }); + }, + + // Mes challenges + mySlices(params?: { status?: SliceStatus; page?: number; per_page?: number }) { + return api.get>( + '/users/me/slices', + params as Record + ); + } +}; diff --git a/src/lib/api/validation.ts b/src/lib/api/validation.ts new file mode 100644 index 0000000..37877b9 --- /dev/null +++ b/src/lib/api/validation.ts @@ -0,0 +1,59 @@ +import type { ApiResponse } from '$lib/types'; +import { createApiClient } from './client'; +import type { Slice } from './slices'; + +const api = createApiClient(); + +// --- Types (P26 v2 validator workflow) --- + +export interface ValidationQueueItem { + slice: Slice; + repo_url: string; + pr_url: string; + claimer_username: string; + claimer_display_name: string; + claimer_avatar_url: string | null; + picked_up_by_me: boolean; + picked_up_at: string | null; +} + +export interface ValidationApproveResponse { + attestation_hash: string; + pdf_url: string; + fragments_credited: number; +} + +export interface ValidationRejectPayload { + reason: string; +} + +// --- API --- + +export const validationApi = { + // SKI-86 filter queue par caps validator du user + queue() { + return api.get>('/me/validation/queue'); + }, + + // SKI-83 premier volontaire gagne + pickup(sliceId: string) { + return api.post>( + `/slices/${sliceId}/validation/pickup` + ); + }, + + // SKI-84 approve -> attestation + fragments + approve(sliceId: string) { + return api.post>( + `/slices/${sliceId}/validation/approve` + ); + }, + + // SKI-85 reject avec raison + reject(sliceId: string, payload: ValidationRejectPayload) { + return api.post>( + `/slices/${sliceId}/validation/reject`, + payload + ); + } +}; diff --git a/src/lib/api/validatorApplications.ts b/src/lib/api/validatorApplications.ts new file mode 100644 index 0000000..996c376 --- /dev/null +++ b/src/lib/api/validatorApplications.ts @@ -0,0 +1,82 @@ +import type { ApiResponse } from '$lib/types'; +import { createApiClient } from './client'; + +const api = createApiClient(); + +// --- Types (P26 v2 candidature validateur) --- + +export type ValidatorDomain = + | 'code' + | 'design' + | 'game' + | 'security' + | 'ops' + | 'ai' + | 'soft_skills'; + +export type ValidatorApplicationStatus = + | 'pending' + | 'accepted' + | 'rejected' + | 'withdrawn'; + +export type ValidatorApplicationOrigin = 'user_apply' | 'admin_invite'; + +export interface ValidatorApplication { + id: string; + domain: ValidatorDomain; + origin: ValidatorApplicationOrigin; + status: ValidatorApplicationStatus; + motivation: string | null; + admin_notes: string | null; + reviewer_admin_id: string | null; + created_at: string; + updated_at: string; +} + +// Renseignes par le back via GET /users/me/stats pour la preview seuils. +export interface ValidatorEligibilityStats { + rank: string; + merged_prs_by_domain: Record; + repos_covered_by_domain: Record; + tenure_days: number; +} + +// Seuils exposes par SKI-81 pour affichage cote front. +export const VALIDATOR_MIN_RANK = 'artisan'; +export const VALIDATOR_MIN_MERGED_PRS = 10; +export const VALIDATOR_MIN_REPOS_COVERED = 3; +export const VALIDATOR_MIN_TENURE_DAYS = 90; + +// --- API --- + +export const validatorApplicationsApi = { + // SKI-81 candidature user + apply(payload: { domain: ValidatorDomain; motivation?: string }) { + return api.post>( + '/me/apply-as-validator', + payload + ); + }, + + // SKI-81 liste des candidatures/invitations du user + list() { + return api.get>( + '/me/validator-applications' + ); + }, + + // SKI-82 invitee-side accept + accept(applicationId: string) { + return api.post>( + `/validator-applications/${applicationId}/accept` + ); + }, + + // SKI-82 withdraw / decline + withdraw(applicationId: string) { + return api.post>( + `/validator-applications/${applicationId}/withdraw` + ); + } +}; diff --git a/src/lib/components/profile/BadgesSection.svelte b/src/lib/components/profile/BadgesSection.svelte new file mode 100644 index 0000000..c49599d --- /dev/null +++ b/src/lib/components/profile/BadgesSection.svelte @@ -0,0 +1,185 @@ + + +
+
+ Badges Skilluv +
+ +
+ +
+

Badge Skilluv

+

+ Colle ce badge dans ton profil GitHub, ton CV ou LinkedIn pour montrer ta communaute Skilluv. +

+ +
+ {#if badgeUserFailed} + Badge pas encore genere + {:else} + Skilluv badge {username} (badgeUserFailed = true)} + /> + {/if} +
+ + +
+
+ Markdown + +
+
{userMarkdown}
+
+ + +
+
+ HTML + +
+
{userHtml}
+
+
+ + + {#if isOwner && ownedProjects && ownedProjects.length > 0} +
+

Badges Skilluv pour tes repos

+

+ Ajoute ces badges au README de tes repos pour montrer la communaute Skilluv active. +

+ +
+ {#each ownedProjects as project (project.slug)} + {@const md = repoMarkdown(project.github_repo_owner, project.github_repo_name)} + {@const html = repoHtml(project.github_repo_owner, project.github_repo_name)} +
+

+ {project.name} + + — {project.github_repo_owner}/{project.github_repo_name} + +

+ +
+ Skilluv badge {project.github_repo_owner}/{project.github_repo_name} +
+ +
+
+ Markdown + +
+
{md}
+
+ +
+
+ HTML + +
+
{html}
+
+
+ {/each} +
+
+ {/if} +
+
diff --git a/src/lib/components/slice/ActiveSkilluversWidget.svelte b/src/lib/components/slice/ActiveSkilluversWidget.svelte new file mode 100644 index 0000000..47df6ec --- /dev/null +++ b/src/lib/components/slice/ActiveSkilluversWidget.svelte @@ -0,0 +1,78 @@ + + +
+

Actifs cette semaine

+ {#if loading} + + {:else if error} +

{error}

+ {:else if count === 0} +

Aucun Skilluver actif recemment.

+ {:else} +

+ {count} Skilluver{count > 1 ? 's' : ''} actif{count > 1 ? 's' : ''} +

+
+ {#each users.slice(0, 5) as u (u.user_id)} + + {#if u.avatar_url} + {u.display_name} + {:else} + {initials(u.display_name || u.username)} + {/if} + + {/each} + {#if count > 5} + + +{count - 5} + + {/if} +
+ {/if} +
diff --git a/src/lib/components/slice/DiaryWidget.svelte b/src/lib/components/slice/DiaryWidget.svelte new file mode 100644 index 0000000..63560dd --- /dev/null +++ b/src/lib/components/slice/DiaryWidget.svelte @@ -0,0 +1,150 @@ + + +
+

Carnet de bord

+ + {#if canPost} +
+ +
+ + +
+
+ {/if} + + {#if loading} +
+ + +
+ {:else if error} +

{error}

+ {:else if entries.length === 0} +

Aucune entree pour l'instant.

+ {:else} +
    + {#each entries as e (e.id)} +
  • +
    +
    + + {#if e.author_avatar_url} + {e.author_display_name} + {:else} + {initials(e.author_display_name || e.author_username)} + {/if} + + + {e.author_display_name || e.author_username} + + {fmtDate(e.created_at)} +
    + {#if e.is_public} + Public + {:else} + Prive + {/if} +
    +
    + {e.body_markdown} +
    +
  • + {/each} +
+ {/if} +
diff --git a/src/lib/components/ui/Button.svelte b/src/lib/components/ui/Button.svelte index c55e245..62337de 100644 --- a/src/lib/components/ui/Button.svelte +++ b/src/lib/components/ui/Button.svelte @@ -7,6 +7,10 @@ size?: 'sm' | 'md' | 'lg'; loading?: boolean; href?: string; + /** Anchor-only pass-through attributes (active when `href` is set) */ + target?: HTMLAnchorAttributes['target']; + rel?: HTMLAnchorAttributes['rel']; + download?: HTMLAnchorAttributes['download']; children: Snippet; } diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 0ed6876..152e535 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -53,7 +53,24 @@ export type NotificationType = | 'rank_promotion' | 'badge_earned' | 'team_slot_match' - | 'payout_status_change'; + | 'payout_status_change' + // SKI-97 — evenements workflow challenge (16 nouveaux types). + | 'slice_claimed' + | 'slice_fork_created' + | 'slice_pr_submitted' + | 'slice_pr_submitted_announced' + | 'slice_ci_green' + | 'validation_picked_up_by_you' + | 'validation_picked_up_by_other' + | 'slice_validated' + | 'slice_rejected' + | 'slice_merged_upstream' + | 'slice_pr_rejected_upstream' + | 'validator_application_status_changed' + | 'validator_invitation_received' + | 'slice_upstream_closed' + | 'maintainer_digest_confirmation_sent' + | 'maintainer_digest_subscribed'; /** Capabilities P18.4 — sources de permissions user (mentor, curator, etc.). */ export type Capability = diff --git a/src/routes/dashboard/slices/+page.svelte b/src/routes/dashboard/slices/+page.svelte new file mode 100644 index 0000000..c21e7c4 --- /dev/null +++ b/src/routes/dashboard/slices/+page.svelte @@ -0,0 +1,235 @@ + + + + Mes challenges — Skilluv + + +
+ +
+
+
+

+ Mes challenges +

+

+ Tes contributions en cours, terminees ou archivees. +

+
+ +
+ + {#if data.mineError} +
+

{data.mineError}

+
+ {:else if filtered.length === 0} + {#if tab === 'active'} + + {#snippet action()} + + {/snippet} + + {:else if tab === 'done'} + + {:else} + + {/if} + {:else} +
+ {#each pageItems as s (s.id)} +
+
+ {STATUS_LABEL[s.status]} + difficulte {s.difficulty}/5 + {#if s.claim_expires_at && (s.status === 'claimed' || s.status === 'in_progress')} + {@const dl = daysLeft(s.claim_expires_at)} + {#if dl}{dl}{/if} + {/if} +
+

+ {s.title} +

+

{s.description}

+
+ +
+
+ {/each} +
+ + (page = p)} /> + {/if} +
+ + +
+
+

+ Recommandes pour toi +

+ {#if data.recoMeta} +

+ Base sur ton rank + {#if data.recoMeta.user_rank_ord != null} + ({data.recoMeta.user_rank_ord}) + {/if} + {#if data.recoMeta.median_difficulty != null} + et tes derniers challenges (difficulty mediane : + {data.recoMeta.median_difficulty}/5) + {/if} +

+ {/if} +
+ + {#if data.recoError} +
+

{data.recoError}

+
+ {:else if data.reco.length === 0} + + {:else} +
+ {#each data.reco as s (s.id)} +
+
+ {STATUS_LABEL[s.status]} + difficulte {s.difficulty}/5 + {#if s.min_rank}rank {s.min_rank}{/if} +
+

+ {s.title} +

+

{s.description}

+
+ + +
+
+ {/each} +
+ {/if} +
+
diff --git a/src/routes/dashboard/slices/+page.ts b/src/routes/dashboard/slices/+page.ts new file mode 100644 index 0000000..d2aad84 --- /dev/null +++ b/src/routes/dashboard/slices/+page.ts @@ -0,0 +1,23 @@ +import { slicesApi, type Slice } from '$api/slices'; +import { SkilluError } from '$api/client'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async () => { + const [mineRes, recoRes] = await Promise.allSettled([ + slicesApi.mySlices({ per_page: 100 }), + slicesApi.feedRecommended(20) + ]); + + const mine: Slice[] = mineRes.status === 'fulfilled' ? mineRes.value.data.slices : []; + const mineError = mineRes.status === 'rejected' + ? mineRes.reason instanceof SkilluError ? mineRes.reason.message : 'Erreur de chargement' + : null; + + const reco: Slice[] = recoRes.status === 'fulfilled' ? recoRes.value.data.slices : []; + const recoMeta = recoRes.status === 'fulfilled' ? recoRes.value.data.meta ?? null : null; + const recoError = recoRes.status === 'rejected' + ? recoRes.reason instanceof SkilluError ? recoRes.reason.message : 'Erreur de chargement' + : null; + + return { mine, mineError, reco, recoMeta, recoError }; +}; diff --git a/src/routes/for-maintainers/+page.svelte b/src/routes/for-maintainers/+page.svelte new file mode 100644 index 0000000..c5d6d3b --- /dev/null +++ b/src/routes/for-maintainers/+page.svelte @@ -0,0 +1,318 @@ + + + + Skilluv — Digest hebdo pour maintainers OSS + + + + + + + +
+ +
+

+ Vos contributeurs Skilluv, resumes une fois par semaine +

+

+ Un digest hebdo, zero spam, unsubscribe en un clic. +

+
+ + +
+

Ce que fait Skilluv

+
    +
  • + + + Notre communaute (afro-francophone, autodidactes, reconvertis) contribue a des OSS + externes. + +
  • +
  • + + + Sur les issues que vous labellisez skilluv-challenge (ou celles publiques comme good first issue). + +
  • +
  • + + + On valide leur travail avant merge — la validation Skilluv est un pre-filtre qualite. + +
  • +
+
+ + +
+

Ce que vous recevez

+
    +
  • + + + Digest hebdomadaire des PRs Skilluv sur vos repos (nb claims, PRs submit, PRs + validated). + +
  • +
  • + + + Zero spam : un email/semaine, avec unsubscribe en 1 clic. + +
  • +
  • + + + Confidentialite : votre email n’est jamais partage. + +
  • +
+
+ + +
+
+

Notre badge Skilluv

+ nouveau +
+

+ Ajoutez ce badge a votre README pour signaler que votre projet accueille les contributions + Skilluv. +

+
+
+ Badge Skilluv validated +
+
+
{badgeMarkdown}
+ +
+
+
+ + +
+

FAQ

+
+ {#each faqs as faq, i} +
+ + {#if openFaq === i} +
{faq.a}
+ {/if} +
+ {/each} +
+
+ + +
+

S’abonner

+ {#if submitSuccess} +
+
+ + Confirmation demandee +
+

+ Email de confirmation envoye a {submitSuccess.email}. + Cliquez sur le lien dans l’email pour activer votre abonnement. +

+
+ {:else} +
+ + + + + {#if submitError} + + {/if} + +
+ {/if} +
+
diff --git a/src/routes/maintainer-digest/confirm/[token]/+page.svelte b/src/routes/maintainer-digest/confirm/[token]/+page.svelte new file mode 100644 index 0000000..27927b1 --- /dev/null +++ b/src/routes/maintainer-digest/confirm/[token]/+page.svelte @@ -0,0 +1,64 @@ + + + + Confirmation abonnement — Skilluv + + + +
+
+ {#if state.status === 'loading'} + +

Confirmation en cours...

+ {:else if state.status === 'success'} + +

+ Abonnement confirme pour {state.email}. Merci ! +

+

+ Vous recevrez votre premier digest hebdomadaire prochainement. +

+ + {:else} + +

+ Ce lien de confirmation est invalide ou expire. +

+

{state.message}

+ + {/if} +
+
diff --git a/src/routes/maintainer-digest/unsubscribe/[token]/+page.svelte b/src/routes/maintainer-digest/unsubscribe/[token]/+page.svelte new file mode 100644 index 0000000..33cb172 --- /dev/null +++ b/src/routes/maintainer-digest/unsubscribe/[token]/+page.svelte @@ -0,0 +1,61 @@ + + + + Desabonnement — Skilluv + + + +
+
+ {#if state.status === 'loading'} + +

Desabonnement en cours...

+ {:else if state.status === 'success'} + +

+ Desabonne. Nous ne vous enverrons plus de digest. +

+

+ Vous ne recevrez plus d’emails du digest hebdomadaire ({state.email}). +

+ + {:else} + +

+ Ce lien est invalide. +

+

{state.message}

+ + {/if} +
+
diff --git a/src/routes/notifications/+page.svelte b/src/routes/notifications/+page.svelte index a97aaa6..cecf465 100644 --- a/src/routes/notifications/+page.svelte +++ b/src/routes/notifications/+page.svelte @@ -1,12 +1,105 @@ + + + Mes candidatures validateur — Skilluv + + +
+
+

+ Mes candidatures validateur +

+ +
+ + {#if view.status === 'ready' && view.apps.length > 0} +
+ +
+ {/if} + + {#if view.status === 'loading'} +
+ {#each Array(3) as _} + + {/each} +
+ {:else if view.status === 'error'} +
+ {view.message} +
+ +
+
+ {:else if view.apps.length === 0} + + {#snippet action()} + + {/snippet} + + {:else if visibleApps.length === 0} + + {:else} +
+ {#each visibleApps as app (app.id)} +
+
+ {app.domain} + + {app.origin === 'admin_invite' ? 'Invitation admin' : 'Candidature'} + + {STATUS_LABEL[app.status]} +
+ + {#if app.motivation} +

{app.motivation}

+ {/if} + + {#if app.admin_notes} +
+

Note admin

+

{app.admin_notes}

+
+ {/if} + +
+ Cree le {formatDate(app.created_at)} + {#if app.updated_at && app.updated_at !== app.created_at} + — maj {formatDate(app.updated_at)} + {/if} +
+ + {#if app.status === 'pending'} +
+ {#if app.origin === 'admin_invite'} + + + {/if} + +
+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/src/routes/settings/validator-application/new/+page.svelte b/src/routes/settings/validator-application/new/+page.svelte new file mode 100644 index 0000000..b292389 --- /dev/null +++ b/src/routes/settings/validator-application/new/+page.svelte @@ -0,0 +1,247 @@ + + + + Devenir validateur — Skilluv + + +
+ + +

+ Devenir validateur Skilluv +

+

+ Les validateurs verifient les PRs Skilluv avant qu'elles soient marquees comme validees. Chaque + validation te credite en fragments et augmente ta reputation. +

+ +
+ + +

{motivation.length}/500

+
+ + +
diff --git a/src/routes/settings/validator-invitations/[id]/+page.svelte b/src/routes/settings/validator-invitations/[id]/+page.svelte new file mode 100644 index 0000000..67ebeb5 --- /dev/null +++ b/src/routes/settings/validator-invitations/[id]/+page.svelte @@ -0,0 +1,174 @@ + + + + Invitation validateur — Skilluv + + +
+ + + {#if view.status === 'loading'} + + + {:else if view.status === 'not-found'} +
+

Introuvable

+

+ Cette invitation n'existe pas ou n'est plus disponible. +

+
+ {:else if view.status === 'error'} +
+ {view.message} +
+ +
+
+ {:else} + {@const app = view.app} +

+ Invitation admin — Devenir validateur {app.domain} +

+ +
+
+ Invitation admin + En attente +
+ +

+ Un admin Skilluv t'invite a rejoindre l'equipe de validateurs {app.domain}. +

+ +
+

Raison de l'invitation

+ {#if app.admin_notes} +
{app.admin_notes}
+ {:else} +

L'admin n'a pas laisse de note.

+ {/if} +
+ +

Recu le {formatDate(app.created_at)}

+
+ +
+ + +
+ {/if} +
diff --git a/src/routes/settings/validator-invitations/[id]/+page.ts b/src/routes/settings/validator-invitations/[id]/+page.ts new file mode 100644 index 0000000..6564fd6 --- /dev/null +++ b/src/routes/settings/validator-invitations/[id]/+page.ts @@ -0,0 +1,7 @@ +import type { PageLoad } from './$types'; + +export const ssr = false; + +export const load: PageLoad = ({ params }) => { + return { id: params.id }; +}; diff --git a/src/routes/slices/[id]/+page.svelte b/src/routes/slices/[id]/+page.svelte new file mode 100644 index 0000000..5a38b43 --- /dev/null +++ b/src/routes/slices/[id]/+page.svelte @@ -0,0 +1,400 @@ + + + + {slice.title} — Skilluv + + +
+
+ +
+ +
+
+ {STATUS_LABEL[slice.status]} + {#if slice.labels[0]} + {slice.labels[0]} + {/if} + {#if slice.min_rank} + rank ≥ {slice.min_rank} + {/if} + {#if slice.required_orientation_slugs} + {#each slice.required_orientation_slugs as o (o)} + {o} + {/each} + {/if} + difficulte {slice.difficulty}/5 + {#if slice.claim_expires_at && (slice.status === 'claimed' || slice.status === 'in_progress')} + {@const dl = fmtDaysLeft(slice.claim_expires_at)} + {#if dl} + {dl} + {/if} + {/if} +
+

+ {slice.title} +

+
+ {#if slice.external_metadata?.issue_url} + + + Issue GitHub + + {/if} + {#if slice.fork_repo_url} + + + Ton fork + + {/if} + {#if slice.submitted_pr_url} + + + Voir la PR + + {/if} +
+
+ + +
+
+ {slice.description} +
+
+ + + {#if slice.acceptance_criteria?.length} +
+

+ Criteres d'acceptation +

+
    + {#each slice.acceptance_criteria as c, i (i)} +
  • + + {c} +
  • + {/each} +
+
+ {/if} + + +
+

+ Workflow +

+
    + {#each STATUS_ORDER as st, i (st)} + {@const reached = i <= currentIdx && slice.status !== 'closed' && slice.status !== 'expired'} + {@const current = i === currentIdx} +
  1. + + {#if reached} + + {/if} + + + {STATUS_LABEL[st]} + +
  2. + {/each} +
+ {#if slice.status === 'closed' || slice.status === 'expired'} +

Workflow interrompu ({STATUS_LABEL[slice.status]}).

+ {/if} +
+ + + {#if slice.validation_reject_reason} +
+

PR non validee

+

{slice.validation_reject_reason}

+
+ {/if} + + +
+ {#if canClaim} + {#if claimGateBlocked} +

+ Ton rank ou orientation ne correspond pas encore a cette slice. +

+ {/if} + + {:else if !auth.isAuthenticated && slice.status === 'open'} + + {/if} + + {#if canSubmitPr} +
+

Soumettre ta PR

+
+
+ + +
+ +
+ + +
+
+
+ {/if} + + {#if isMine && (slice.status === 'submitted' || slice.status === 'ci_green')} +
+ {#if slice.submitted_pr_url} + + {/if} + + {slice.status === 'ci_green' ? 'CI verte, en attente validation' : 'En attente CI'} + +
+ {/if} + + {#if slice.status === 'pending_validation'} +

+ En cours de review + + {#if slice.validator_user_id}par un valideur Skilluv.{/if} +

+ {/if} + + {#if (slice.status === 'validated' || slice.status === 'merged') && slice.attestation_hash} +
+
+ Attestation generee + {#if slice.status === 'merged'} + Merge upstream + {/if} +
+
+ + +
+
+ {/if} +
+ + + {#if slice.attestation_hash} +

+ Attestation ID : + {truncHash(slice.attestation_hash)} +

+ {/if} + + + +
+ + + +
+
diff --git a/src/routes/slices/[id]/+page.ts b/src/routes/slices/[id]/+page.ts new file mode 100644 index 0000000..4e29635 --- /dev/null +++ b/src/routes/slices/[id]/+page.ts @@ -0,0 +1,16 @@ +import { error } from '@sveltejs/kit'; +import { slicesApi } from '$api/slices'; +import { SkilluError } from '$api/client'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params }) => { + try { + const res = await slicesApi.get(params.id); + return { slice: res.data }; + } catch (err) { + if (err instanceof SkilluError && err.status === 404) { + error(404, 'Slice introuvable'); + } + throw err; + } +}; diff --git a/src/routes/validations/[slice_id]/review/+page.svelte b/src/routes/validations/[slice_id]/review/+page.svelte new file mode 100644 index 0000000..76a08fa --- /dev/null +++ b/src/routes/validations/[slice_id]/review/+page.svelte @@ -0,0 +1,265 @@ + + + + Reviewer une PR — Skilluv + + +
+ + + {#if view.status === 'loading'} + + + + + {:else if view.status === 'not-found'} +
+

Introuvable

+

+ Ce challenge n'existe pas dans ta file de validation. Il a peut-etre deja ete traite. +

+
+ {:else if view.status === 'error'} +
+ {view.message} +
+ +
+
+ {:else} + {@const item = view.item} + {@const domain = domainFromLabels(item)} + +
+

+ {item.slice.title} +

+
+ {domain} + difficulte {item.slice.difficulty} + status {item.slice.status} +
+
+ + {#if approveResult} +
+

Validation approuvee

+

+ {approveResult.fragments_credited} fragments credites. L'attestation est publique. +

+

+ Attestation ID : {approveResult.attestation_hash} +

+
+ + +
+
+ {:else} +
+ En approvant, tu genereras une attestation qui sera publique via /verify/{'{hash}'} et + telechargeable en PDF. Les fragments seront credites au challenger et a toi. +
+ +
+

Reviewer la PR

+

+ L'iframe est bloque par GitHub — utilise ce bouton pour ouvrir la PR dans un nouvel onglet. +

+ + +
+ {#if item.claimer_avatar_url} + + {:else} +
+ {/if} +
+

{item.claimer_display_name}

+ + @{item.claimer_username} + +
+
+
+ +
+

Ton verdict

+ + + +

{feedback.length}/2000

+ + {#if submitError} + + {/if} + +
+ + +
+
+ {/if} + {/if} +
diff --git a/src/routes/validations/[slice_id]/review/+page.ts b/src/routes/validations/[slice_id]/review/+page.ts new file mode 100644 index 0000000..91d71c0 --- /dev/null +++ b/src/routes/validations/[slice_id]/review/+page.ts @@ -0,0 +1,8 @@ +import type { PageLoad } from './$types'; + +// Cookies de session -> hydratation client-only. +export const ssr = false; + +export const load: PageLoad = ({ params }) => { + return { sliceId: params.slice_id }; +}; diff --git a/src/routes/validations/queue/+page.svelte b/src/routes/validations/queue/+page.svelte new file mode 100644 index 0000000..2b4cdb5 --- /dev/null +++ b/src/routes/validations/queue/+page.svelte @@ -0,0 +1,227 @@ + + + + File de validation — Skilluv + + +
+
+
+

+ File de validation +

+

+ Les PRs en attente de validation dans tes domaines. +

+
+ + {#if view.status === 'ready' && view.items.length > 0} + + {/if} +
+ + {#if view.status === 'loading'} +
+ {#each Array(4) as _} + + {/each} +
+ {:else if view.status === 'no-caps'} + + {#snippet action()} + + {/snippet} + + {:else if view.status === 'error'} +
+ {view.message} +
+ +
+
+ {:else if visibleItems.length === 0} + + {:else} +
+ {#each visibleItems as item (item.slice.id)} + {@const domain = domainFromLabels(item)} +
+ + +
+ {shortRepo(item.repo_url)} + {domain} + difficulte {item.slice.difficulty} +
+ +
+ {#if item.claimer_avatar_url} + + {:else} +
+ {/if} +
+

{item.claimer_display_name}

+

@{item.claimer_username}

+
+
+ +
+ + + {#if item.picked_up_by_me} + + {:else} + + {/if} +
+
+ {/each} +
+ {/if} +
diff --git a/src/routes/validations/queue/+page.ts b/src/routes/validations/queue/+page.ts new file mode 100644 index 0000000..71467e4 --- /dev/null +++ b/src/routes/validations/queue/+page.ts @@ -0,0 +1,5 @@ +// La queue de validation utilise les cookies de session ; on force +// l'hydratation client-only pour eviter de rejouer la requete cote SSR +// sans credentials (et pour aligner sur le pattern des autres pages +// P26 validator). +export const ssr = false; diff --git a/src/routes/verify/[hash]/+page.svelte b/src/routes/verify/[hash]/+page.svelte new file mode 100644 index 0000000..1b8813d --- /dev/null +++ b/src/routes/verify/[hash]/+page.svelte @@ -0,0 +1,269 @@ + + + + {seoTitle} + + + + + + + + + +
+ {#if state.status === 'loading'} +
+ + +
+ +
+ + +
+
+ +
+ {:else if state.status === 'error'} +
+
+ +

+ Erreur de chargement +

+
+

{state.message}

+ +
+ {:else if invalid} +
+
+

+ Attestation introuvable +

+ + + invalide + +
+

+ {invalid.reason === 'malformed attestation hash' + ? 'Ce lien ne correspond pas au format d’une attestation Skilluv.' + : invalid.reason === 'unknown attestation hash' + ? 'Aucune attestation ne correspond a ce hash. Elle a peut-etre ete revoquee ou n’a jamais existe.' + : invalid.reason} +

+ +
+ {:else if valid} +
+
+
+

+ Attestation Skilluv verifiee +

+ + + verified + +
+

+ Delivree le {formatDate(valid.validated_at)} +

+
+ +
+

+ Contributeur +

+ + {#if valid.challenger.avatar_url} + {valid.challenger.display_name} + {:else} +
+ {valid.challenger.display_name.slice(0, 1).toUpperCase()} +
+ {/if} +
+
{valid.challenger.display_name}
+
@{valid.challenger.username}
+
+
+
+ +
+

+ Validee par +

+ + {#if valid.validator.avatar_url} + {valid.validator.display_name} + {:else} +
+ {valid.validator.display_name.slice(0, 1).toUpperCase()} +
+ {/if} +
+
{valid.validator.display_name}
+
@{valid.validator.username}
+
+
+
+ +
+

+ Contribution +

+
+ {valid.domain} + difficulte {valid.difficulty}/5 + {#if valid.merged_upstream} + Merge upstream + {/if} +
+

+ Repo : {valid.repo} +

+ +
+ +
+ + +
+ + +
+ {/if} +
diff --git a/src/routes/verify/[hash]/+page.ts b/src/routes/verify/[hash]/+page.ts new file mode 100644 index 0000000..218dbec --- /dev/null +++ b/src/routes/verify/[hash]/+page.ts @@ -0,0 +1,12 @@ +import type { PageLoad } from './$types'; + +// L'endpoint public `/verify/{hash}` est servi par le backend derriere +// le meme host (Caddy). Pour eviter que le SvelteKit SSR ne se resolve +// lui-meme sur cette route (collision avec le +page.svelte), on force +// l'hydratation client-only. La page reste indexable via les meta OG +// injectees a partir du state client. +export const ssr = false; + +export const load: PageLoad = ({ params }) => { + return { hash: params.hash }; +}; diff --git a/tests/e2e/parcours/dashboard-slices.spec.ts b/tests/e2e/parcours/dashboard-slices.spec.ts new file mode 100644 index 0000000..2022841 --- /dev/null +++ b/tests/e2e/parcours/dashboard-slices.spec.ts @@ -0,0 +1,17 @@ +/** + * P26 v2 SKI-94 — dashboard "mes challenges" + reco feed. + * Skip permanent : back P26 non deploye sur staging. + */ +import { test, expect } from '@playwright/test'; + +const HAS_BACK = Boolean(process.env.PUBLIC_API_BASE_URL); + +test.describe('@parcours dashboard-slices', () => { + test.skip(!HAS_BACK, 'requires PUBLIC_API_BASE_URL'); + + test('dashboard slices accessible', async ({ page }) => { + test.skip(true, 'P26 v2 back not deployed: /api/me/feed/challenges (SKI-121) + /api/users/me/slices en Todo'); + await page.goto('/dashboard/slices'); + await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/tests/e2e/parcours/for-maintainers.spec.ts b/tests/e2e/parcours/for-maintainers.spec.ts new file mode 100644 index 0000000..12c2f1d --- /dev/null +++ b/tests/e2e/parcours/for-maintainers.spec.ts @@ -0,0 +1,16 @@ +/** + * P26 v2 SKI-105 — landing publique /for-maintainers + digest opt-in. + */ +import { test, expect } from '@playwright/test'; + +const HAS_BACK = Boolean(process.env.PUBLIC_API_BASE_URL); + +test.describe('@parcours for-maintainers', () => { + test.skip(!HAS_BACK, 'requires PUBLIC_API_BASE_URL'); + + test('landing for-maintainers accessible + form subscribe visible', async ({ page }) => { + await page.goto('/for-maintainers'); + await page.waitForLoadState('domcontentloaded'); + await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/tests/e2e/parcours/slice-detail.spec.ts b/tests/e2e/parcours/slice-detail.spec.ts new file mode 100644 index 0000000..a187200 --- /dev/null +++ b/tests/e2e/parcours/slice-detail.spec.ts @@ -0,0 +1,18 @@ +/** + * P26 v2 SKI-93 — page /slices/[id] avec workflow claim/submit-pr. + * + * Skip tant que /api/slices/{id} n'est pas expose (SKI-72..91 en Todo). + */ +import { test, expect } from '@playwright/test'; + +const HAS_BACK = Boolean(process.env.PUBLIC_API_BASE_URL); + +test.describe('@parcours slice-detail', () => { + test.skip(!HAS_BACK, 'requires PUBLIC_API_BASE_URL'); + + test('page slice detail rendue', async ({ page }) => { + test.skip(true, 'P26 v2 back not deployed: /api/slices/{id} SKI-72..91 en Todo'); + await page.goto('/slices/00000000-0000-0000-0000-000000000000'); + await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/tests/e2e/parcours/validations-queue.spec.ts b/tests/e2e/parcours/validations-queue.spec.ts new file mode 100644 index 0000000..eb18453 --- /dev/null +++ b/tests/e2e/parcours/validations-queue.spec.ts @@ -0,0 +1,17 @@ +/** + * P26 v2 SKI-95 — validator queue + review pages. + * Skip permanent : back P26 non deploye sur staging. + */ +import { test, expect } from '@playwright/test'; + +const HAS_BACK = Boolean(process.env.PUBLIC_API_BASE_URL); + +test.describe('@parcours validations-queue', () => { + test.skip(!HAS_BACK, 'requires PUBLIC_API_BASE_URL'); + + test('page validations/queue accessible', async ({ page }) => { + test.skip(true, 'P26 v2 back not deployed: /api/me/validation/queue SKI-86 en Todo'); + await page.goto('/validations/queue'); + await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/tests/e2e/parcours/validator-application.spec.ts b/tests/e2e/parcours/validator-application.spec.ts new file mode 100644 index 0000000..e71e5d0 --- /dev/null +++ b/tests/e2e/parcours/validator-application.spec.ts @@ -0,0 +1,23 @@ +/** + * P26 v2 SKI-96 — flow candidature validateur. + * Skip permanent : back P26 non deploye sur staging. + */ +import { test, expect } from '@playwright/test'; + +const HAS_BACK = Boolean(process.env.PUBLIC_API_BASE_URL); + +test.describe('@parcours validator-application', () => { + test.skip(!HAS_BACK, 'requires PUBLIC_API_BASE_URL'); + + test('page candidature validateur accessible', async ({ page }) => { + test.skip(true, 'P26 v2 back not deployed: /api/me/apply-as-validator SKI-81 en Todo'); + await page.goto('/settings/validator-application/new'); + await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); + }); + + test('page my-validator-applications accessible', async ({ page }) => { + test.skip(true, 'P26 v2 back not deployed: /api/me/validator-applications SKI-81 en Todo'); + await page.goto('/settings/my-validator-applications'); + await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/tests/e2e/parcours/verify-hash.spec.ts b/tests/e2e/parcours/verify-hash.spec.ts new file mode 100644 index 0000000..6eeb1e2 --- /dev/null +++ b/tests/e2e/parcours/verify-hash.spec.ts @@ -0,0 +1,19 @@ +/** + * P26 v2 SKI-103 — page publique /verify/[hash]. + * + * Skip permanent tant que le back n'expose pas /verify/{hash} (SKI-115 en Todo). + * A reactiver quand le back staging repond a l'endpoint. + */ +import { test, expect } from '@playwright/test'; + +const HAS_BACK = Boolean(process.env.PUBLIC_API_BASE_URL); + +test.describe('@parcours verify-hash', () => { + test.skip(!HAS_BACK, 'requires PUBLIC_API_BASE_URL'); + + test('page verify accessible + rend etat invalid pour hash bidon', async ({ page }) => { + test.skip(true, 'P26 v2 back not deployed: /verify/{hash} endpoint SKI-115 en Todo'); + await page.goto('/verify/deadbeef'); + await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); + }); +});