feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
@@ -0,0 +1,109 @@
<script lang="ts">
interface IconProps {
color?: string;
size?: number;
strokeWidth?: number;
isHovered?: boolean;
class?: string;
}
let {
color = 'currentColor',
size = 24,
strokeWidth = 2,
isHovered: isParentHovered = false,
class: className = ''
}: IconProps = $props();
let localMotion = $state(false);
const showMotion = $derived(isParentHovered || localMotion);
function handleMouseEnter() {
if (isParentHovered || localMotion) return;
localMotion = true;
setTimeout(() => {
localMotion = false;
}, 650);
}
</script>
<div class={className} aria-label="braces" role="img" onmouseenter={handleMouseEnter}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class="braces-icon"
class:motion-active={showMotion}
>
<g class="brace-left-wrap">
<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2v5a2 2 0 0 0 2 2h1" />
</g>
<g class="brace-right-wrap">
<path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2v5a2 2 0 0 1-2 2h-1" />
</g>
</svg>
</div>
<style>
div {
display: inline-block;
}
.braces-icon {
overflow: visible;
}
.brace-left-wrap,
.brace-right-wrap {
transform-box: fill-box;
transform-origin: center;
}
.braces-icon.motion-active .brace-left-wrap {
animation: braceLeft 0.58s cubic-bezier(0.34, 1.15, 0.64, 1) forwards;
}
.braces-icon.motion-active .brace-right-wrap {
animation: braceRight 0.58s cubic-bezier(0.34, 1.15, 0.64, 1) 0.07s forwards;
}
@keyframes braceLeft {
0%,
100% {
transform: translateX(0);
}
18% {
transform: translateX(-2.5px);
}
42% {
transform: translateX(0.6px);
}
65% {
transform: translateX(-0.35px);
}
}
@keyframes braceRight {
0%,
100% {
transform: translateX(0);
}
18% {
transform: translateX(2.5px);
}
42% {
transform: translateX(-0.6px);
}
65% {
transform: translateX(0.35px);
}
}
</style>
@@ -1,26 +1,36 @@
<script>
/**
* @typedef {Object} Props
* @property {string} [color]
* @property {number} [size]
* @property {number} [strokeWidth]
* @property {boolean} [isHovered]
* @property {string} [class]
*/
<script lang="ts">
interface IconProps {
color?: string;
size?: number;
strokeWidth?: number;
isHovered?: boolean;
class?: string;
}
/** @type {Props} */
let { color = 'currentColor', size = 24, strokeWidth = 2, isHovered = false, class: className = '' } = $props();
let {
color = 'currentColor',
size = 24,
strokeWidth = 2,
isHovered: isParentHovered = false,
class: className = ''
}: IconProps = $props();
/** Local pulse when the icon itself is moused; row hover uses the `isHovered` prop from `HoverableItem`. */
let localMotion = $state(false);
const showMotion = $derived(isParentHovered || localMotion);
function handleMouseEnter() {
isHovered = true;
if (isParentHovered || localMotion) return;
localMotion = true;
setTimeout(() => {
isHovered = false;
localMotion = false;
}, 650);
}
</script>
<div class={className} aria-label="layout-dashboard" role="img" onmouseenter={handleMouseEnter}>
<div class={className} aria-label="layout-grid" role="img" onmouseenter={handleMouseEnter}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
@@ -31,13 +41,13 @@
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class="layout-dashboard-icon"
class:animate={isHovered}
class="layout-grid-icon"
class:motion-active={showMotion}
>
<rect width="7" height="9" x="3" y="3" rx="1" class="ui:top-left-panel" />
<rect width="7" height="5" x="14" y="3" rx="1" class="ui:top-right-panel" />
<rect width="7" height="9" x="14" y="12" rx="1" class="ui:bottom-right-panel" />
<rect width="7" height="5" x="3" y="16" rx="1" class="ui:bottom-left-panel" />
<rect width="7" height="7" x="3" y="3" rx="1" class="top-left-panel" />
<rect width="7" height="7" x="14" y="3" rx="1" class="top-right-panel" />
<rect width="7" height="7" x="14" y="14" rx="1" class="bottom-right-panel" />
<rect width="7" height="7" x="3" y="14" rx="1" class="bottom-left-panel" />
</svg>
</div>
@@ -45,28 +55,29 @@
div {
display: inline-block;
}
.layout-dashboard-icon {
.layout-grid-icon {
overflow: visible;
}
.layout-dashboard-icon.animate .top-left-panel {
.layout-grid-icon.motion-active .top-right-panel {
opacity: 0;
animation: fadeInLeft 0.35s ease-out forwards;
animation: fadeInTop 0.35s ease-out forwards;
}
.layout-dashboard-icon.animate .bottom-right-panel {
.layout-grid-icon.motion-active .bottom-right-panel {
opacity: 0;
animation: fadeInRight 0.35s ease-out 0.1s forwards;
}
.layout-dashboard-icon.animate .top-right-panel {
.layout-grid-icon.motion-active .bottom-left-panel {
opacity: 0;
animation: fadeInTop 0.35s ease-out 0.2s forwards;
animation: fadeInBottom 0.35s ease-out 0.2s forwards;
}
.layout-dashboard-icon.animate .bottom-left-panel {
.layout-grid-icon.motion-active .top-left-panel {
opacity: 0;
animation: fadeInBottom 0.35s ease-out 0.3s forwards;
animation: fadeInLeft 0.35s ease-out 0.3s forwards;
}
@keyframes fadeInLeft {
@@ -0,0 +1,81 @@
<script>
/**
* @typedef {Object} Props
* @property {boolean} [isHovered] - from parent HoverableItem; combined with internal mouseenter pulse
* @property {string} [color]
* @property {number} [size]
* @property {number} [strokeWidth]
* @property {boolean} [ariaHidden] - when true, icon is treated as decorative (e.g. next to visible text)
* @property {string} [class]
*/
/** @type {Props} */
let {
isHovered = false,
color = 'currentColor',
size = 24,
strokeWidth = 2,
ariaHidden = false,
class: className = ''
} = $props();
let pulseFromMouseEnter = $state(false);
function handleMouseEnter() {
if (pulseFromMouseEnter) return;
pulseFromMouseEnter = true;
setTimeout(() => {
pulseFromMouseEnter = false;
}, 500);
}
const arrowAnimates = $derived(isHovered || pulseFromMouseEnter);
</script>
<div
class={className}
aria-hidden={ariaHidden ? true : undefined}
aria-label={ariaHidden ? undefined : 'square-arrow-out-up-right'}
role={ariaHidden ? undefined : 'img'}
onmouseenter={handleMouseEnter}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6" />
<g class="arrow" class:animate={arrowAnimates}>
<path d="m21 3-9 9" />
<path d="M15 3h6v6" />
</g>
</svg>
</div>
<style>
div {
display: inline-block;
}
.arrow path {
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.animate {
animation: moveUpRight 0.5s;
}
@keyframes moveUpRight {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(-2px, 2px);
}
}
</style>
@@ -1,25 +1,45 @@
<script>
/**
* @typedef {Object} Props
* @property {boolean} [isHovered] - from parent HoverableItem; combined with internal mouseenter pulse
* @property {string} [color]
* @property {number} [size]
* @property {number} [strokeWidth]
* @property {boolean} [isHovered]
* @property {boolean} [ariaHidden] - when true, icon is treated as decorative (e.g. next to visible text)
* @property {string} [class]
*/
/** @type {Props} */
let { color = 'currentColor', size = 24, strokeWidth = 2, isHovered = false, class: className = '' } = $props();
let {
isHovered = false,
color = 'currentColor',
size = 24,
strokeWidth = 2,
ariaHidden = false,
class: className = ''
} = $props();
let pulseFromMouseEnter = $state(false);
function handleMouseEnter() {
isHovered = true;
if (pulseFromMouseEnter) return;
pulseFromMouseEnter = true;
setTimeout(() => {
isHovered = false;
pulseFromMouseEnter = false;
}, 800);
}
const iconAnimates = $derived(isHovered || pulseFromMouseEnter);
</script>
<div class={className} aria-label="house" role="img" onmouseenter={handleMouseEnter}>
<div
class={className}
aria-hidden={ariaHidden ? true : undefined}
aria-label={ariaHidden ? undefined : 'house'}
role={ariaHidden ? undefined : 'img'}
onmouseenter={handleMouseEnter}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
@@ -31,7 +51,7 @@
stroke-linecap="round"
stroke-linejoin="round"
class="house-icon"
class:animate={isHovered}
class:animate={iconAnimates}
>
<path
d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
@@ -1,4 +1,5 @@
export { default as AnalyticsIcon } from './analytics.svelte';
export { default as ApiIcon } from './api.svelte';
export { default as AttendanceIcon } from './attendance.svelte';
export { default as AttachmentIcon } from './attachment.svelte';
export { default as CertificateIcon } from './certificate.svelte';
@@ -9,6 +10,7 @@ export { default as CourseIcon } from './course.svelte';
export { default as DashboardIcon } from './dashboard.svelte';
export { default as ExerciseIcon } from './exercise.svelte';
export { default as ExploreIcon } from './explore.svelte';
export { default as ExternalLinkIcon } from './external-link.svelte';
export { default as GoalIcon } from './goal.svelte';
export { default as HeaderIcon } from './header.svelte';
export { default as HomeIcon } from './home.svelte';
@@ -26,4 +28,6 @@ export { default as ReviewIcon } from './review.svelte';
export { default as SettingsIcon } from './settings.svelte';
export { default as SetupIcon } from './setup.svelte';
export { default as SubmissionIcon } from './submission.svelte';
export { default as TagIcon } from './tag.svelte';
export { default as UploadIcon } from './upload.svelte';
export { default as ZapIcon } from './zap.svelte';
@@ -0,0 +1,77 @@
<script lang="ts">
interface IconProps {
color?: string;
size?: number;
strokeWidth?: number;
isHovered?: boolean;
class?: string;
}
let {
color = 'currentColor',
size = 24,
strokeWidth = 2,
isHovered = false,
class: className = ''
}: IconProps = $props();
function handleMouseEnter() {
if (isHovered) return;
isHovered = true;
setTimeout(() => {
isHovered = false;
}, 500);
}
</script>
<div class={className} aria-label="tag" role="img" onmouseenter={handleMouseEnter}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class="tag-icon"
class:animate={isHovered}
>
<path
d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"
/>
<circle cx="7.5" cy="7.5" r=".5" fill={color} />
</svg>
</div>
<style>
div {
display: inline-block;
}
.tag-icon {
overflow: visible;
}
.tag-icon.animate {
transform-origin: top left;
animation: tagRotate 0.5s ease-in-out;
}
@keyframes tagRotate {
0% {
transform: rotate(0deg);
}
40% {
transform: rotate(10deg);
}
80% {
transform: rotate(-5deg);
}
100% {
transform: rotate(0deg);
}
}
</style>
@@ -0,0 +1,87 @@
<script lang="ts">
interface IconProps {
color?: string;
size?: number;
strokeWidth?: number;
isHovered?: boolean;
class?: string;
}
let {
color = 'currentColor',
size = 24,
strokeWidth = 2,
isHovered: isParentHovered = false,
class: className = ''
}: IconProps = $props();
let localMotion = $state(false);
const showMotion = $derived(isParentHovered || localMotion);
function handleMouseEnter() {
if (isParentHovered || localMotion) return;
localMotion = true;
setTimeout(() => {
localMotion = false;
}, 650);
}
</script>
<div class={className} aria-label="zap" role="img" onmouseenter={handleMouseEnter}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class="zap-icon"
class:motion-active={showMotion}
>
<g class="zap-wrap">
<path
d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"
/>
</g>
</svg>
</div>
<style>
div {
display: inline-block;
}
.zap-icon {
overflow: visible;
}
.zap-wrap {
transform-box: fill-box;
transform-origin: center;
}
.zap-icon.motion-active .zap-wrap {
animation: zapJolt 0.58s cubic-bezier(0.34, 1.15, 0.64, 1) forwards;
}
@keyframes zapJolt {
0%,
100% {
transform: translate(0, 0) rotate(0deg) scale(1);
}
18% {
transform: translate(0.5px, -2px) rotate(-9deg) scale(1.07);
}
40% {
transform: translate(-0.5px, 1px) rotate(7deg) scale(1.03);
}
62% {
transform: translate(0.25px, -0.25px) rotate(-3deg) scale(1.01);
}
}
</style>