Compare commits
11 Commits
batch-pull
...
tl/tutoria
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd1bedf69e | ||
|
|
048368b1f8 | ||
|
|
c92ab37930 | ||
|
|
0bcacc1f10 | ||
|
|
635bb207af | ||
|
|
b5029f9a61 | ||
|
|
2033ded4b4 | ||
|
|
05099b891a | ||
|
|
8ab0ffe2d1 | ||
|
|
3383eaf060 | ||
|
|
4be122c6fc |
@@ -5,26 +5,95 @@
|
|||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { sendUserToast, type ToastAction } from '$lib/toast'
|
import { sendUserToast, type ToastAction } from '$lib/toast'
|
||||||
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||||
import { skipAllTodos, syncTutorialsTodos } from '$lib/tutorialUtils'
|
import {
|
||||||
|
skipAllTodos,
|
||||||
|
syncTutorialsTodos,
|
||||||
|
TUTORIAL_BANNER_DISMISSED_KEY
|
||||||
|
} from '$lib/tutorialUtils'
|
||||||
|
import { tutorialsToDo, userStore, skippedAll } from '$lib/stores'
|
||||||
|
import { TUTORIALS_CONFIG } from '$lib/tutorials/config'
|
||||||
|
import { hasRoleAccess } from '$lib/tutorials/roleUtils'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
const DISMISSED_KEY = 'tutorial_banner_dismissed'
|
|
||||||
let isDismissed = $state(false)
|
let isDismissed = $state(false)
|
||||||
|
let hasCompletedAny = $state(false)
|
||||||
|
|
||||||
onMount(() => {
|
/**
|
||||||
// Check if banner has been dismissed
|
* Get all tutorial indexes that are accessible to the current user based on their role.
|
||||||
isDismissed = getLocalSetting(DISMISSED_KEY) === 'true'
|
* Automatically recomputes when $userStore changes.
|
||||||
|
*/
|
||||||
|
const accessibleTutorialIndexes = $derived.by(() => {
|
||||||
|
const indexes = new Set<number>()
|
||||||
|
const user = $userStore
|
||||||
|
|
||||||
|
for (const tab of Object.values(TUTORIALS_CONFIG)) {
|
||||||
|
// Check if user has access to this tab category
|
||||||
|
if (!hasRoleAccess(user, tab.roles)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tutorial of tab.tutorials) {
|
||||||
|
// Check if tutorial has an index and user has access to it
|
||||||
|
if (tutorial.index !== undefined && hasRoleAccess(user, tutorial.roles)) {
|
||||||
|
indexes.add(tutorial.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return indexes
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
// Sync tutorial progress from backend first
|
||||||
|
await syncTutorialsTodos()
|
||||||
|
|
||||||
|
// Check if banner has been manually dismissed via X button (soft dismiss, per-device)
|
||||||
|
const manuallyDismissed = getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true'
|
||||||
|
if (manuallyDismissed) {
|
||||||
|
isDismissed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user deliberately skipped all tutorials (permanent dismiss, from backend)
|
||||||
|
if ($skippedAll) {
|
||||||
|
isDismissed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe to check tutorialsToDo here since we awaited syncTutorialsTodos() above
|
||||||
|
// Filter tutorialsToDo to only include tutorials accessible to the user's role
|
||||||
|
const remainingAccessibleTutorials = $tutorialsToDo.filter((index) =>
|
||||||
|
accessibleTutorialIndexes.has(index)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate if user has completed at least one tutorial (for banner wording)
|
||||||
|
hasCompletedAny = remainingAccessibleTutorials.length < accessibleTutorialIndexes.size
|
||||||
|
|
||||||
|
// Hide banner if all accessible tutorials are completed (but can reappear with new tutorials)
|
||||||
|
if (remainingAccessibleTutorials.length === 0) {
|
||||||
|
isDismissed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show banner - user has accessible tutorials to complete
|
||||||
|
isDismissed = false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync tutorial progress:', error)
|
||||||
|
// Fallback to manual dismissal check only if API call fails
|
||||||
|
isDismissed = getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleSkipAllTutorials() {
|
async function handleSkipAllTutorials() {
|
||||||
|
// Skip all tutorials and set skipped_all flag in backend (permanent)
|
||||||
await skipAllTodos()
|
await skipAllTodos()
|
||||||
await syncTutorialsTodos()
|
await syncTutorialsTodos()
|
||||||
storeLocalSetting(DISMISSED_KEY, 'true')
|
// No need to set localStorage - backend skipped_all flag is the source of truth
|
||||||
isDismissed = true
|
isDismissed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissBanner() {
|
function dismissBanner() {
|
||||||
storeLocalSetting(DISMISSED_KEY, 'true')
|
storeLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY, 'true')
|
||||||
isDismissed = true
|
isDismissed = true
|
||||||
|
|
||||||
const actions: ToastAction[] = [
|
const actions: ToastAction[] = [
|
||||||
@@ -57,16 +126,24 @@
|
|||||||
<GraduationCap size={20} class="text-accent-primary flex-shrink-0" />
|
<GraduationCap size={20} class="text-accent-primary flex-shrink-0" />
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
|
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
|
||||||
Learn with interactive tutorials
|
{#if hasCompletedAny}
|
||||||
|
New tutorial available!
|
||||||
|
{:else}
|
||||||
|
Learn with interactive tutorials
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-hint text-3xs truncate text-left font-normal">
|
<div class="text-hint text-3xs truncate text-left font-normal">
|
||||||
Get started quickly with step-by-step guides on building flows, scripts, and more.
|
{#if hasCompletedAny}
|
||||||
|
Continue your learning journey and master new Windmill skills.
|
||||||
|
{:else}
|
||||||
|
Get started quickly with step-by-step guides on building flows, scripts, and more.
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<Button size="xs" variant="accent" onclick={goToTutorials} startIcon={{ icon: GraduationCap }}>
|
<Button size="xs" variant="accent" onclick={goToTutorials} startIcon={{ icon: GraduationCap }}>
|
||||||
View tutorials
|
View tutorials
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
onclick={dismissBanner}
|
onclick={dismissBanner}
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { tutorialsToDo, skippedAll } from './stores'
|
|||||||
import { UserService } from './gen'
|
import { UserService } from './gen'
|
||||||
import { TUTORIALS_CONFIG } from './tutorials/config'
|
import { TUTORIALS_CONFIG } from './tutorials/config'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalStorage key for tracking if the tutorial banner has been dismissed.
|
||||||
|
* Shared between tutorialUtils and TutorialBanner component.
|
||||||
|
*/
|
||||||
|
export const TUTORIAL_BANNER_DISMISSED_KEY: string = 'tutorial_banner_dismissed'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the maximum tutorial index from the config.
|
* Get the maximum tutorial index from the config.
|
||||||
* This ensures we don't hardcode the max ID and it automatically updates when tutorials are added.
|
* This ensures we don't hardcode the max ID and it automatically updates when tutorials are added.
|
||||||
@@ -64,6 +70,8 @@ export async function skipAllTodos() {
|
|||||||
}
|
}
|
||||||
tutorialsToDo.set([])
|
tutorialsToDo.set([])
|
||||||
skippedAll.set(true)
|
skippedAll.set(true)
|
||||||
|
|
||||||
|
// Set skipped_all flag in backend - this is the permanent source of truth
|
||||||
await UserService.updateTutorialProgress({ requestBody: { progress: bits, skipped_all: true } })
|
await UserService.updateTutorialProgress({ requestBody: { progress: bits, skipped_all: true } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -274,9 +274,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if !$userStore?.operator}
|
<TutorialBanner />
|
||||||
<TutorialBanner />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !$userStore?.operator}
|
{#if !$userStore?.operator}
|
||||||
<div class="w-full overflow-auto scrollbar-hidden pb-2">
|
<div class="w-full overflow-auto scrollbar-hidden pb-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user