Skip to content

Commit

Permalink
dashboard client: campaign sidebar, detail, steps, solving
Browse files Browse the repository at this point in the history
  • Loading branch information
HolecekM committed Dec 13, 2024
1 parent 0fb52e4 commit 35c7aee
Show file tree
Hide file tree
Showing 11 changed files with 194 additions and 9 deletions.
5 changes: 3 additions & 2 deletions dashboard/client/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
import SSH from './Ssh.svelte';
import { onMount } from 'svelte';
import ClassDoc from './ClassDoc.svelte';
import { challenges, classes } from './stores';
import { challenges, classes, campaigns } from './stores';
import { navigate, chosenClass } from './routing';
import { fetchChallenges, fetchClasses } from './fetch';
import { fetchChallenges, fetchClasses, fetchCampaigns } from './fetch';
let showSSH = false;
let sshInitialised = false;
Expand All @@ -23,6 +23,7 @@
onMount(async () => {
fetchChallenges().then((loadedChallenges) => ($challenges = loadedChallenges));
fetchClasses().then((loadedClasses) => ($classes = loadedClasses));
fetchCampaigns().then((loadedCampaigns) => ($campaigns = loadedCampaigns));
});
let resizeTerminalContentFunc;
Expand Down
7 changes: 5 additions & 2 deletions dashboard/client/src/ChallengeDetail.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script>
import { isLoading } from './stores';
import { isLoading, loadSingleCampaign, setChallengeRunning } from './stores';
export let challenge;
Expand Down Expand Up @@ -31,6 +31,9 @@
challenge.tasks = challenge.tasks; // Reassign to trigger reactivity
}
alert(data);
if (task.solved && challenge.campaignId) {
loadSingleCampaign(challenge.campaignId).then();
}
} catch (err) {
console.error(err);
alert(err instanceof Error ? err.message : err);
Expand All @@ -55,7 +58,7 @@
throw new Error(`Error: request failed with HTTP status ${res.status}: ${await res.text()}`);
}
challenge.running = action === 'start';
setChallengeRunning(challenge.id, challenge.campaignId, action === 'start');
} catch (err) {
console.error(err);
alert(err instanceof Error ? err.message : err);
Expand Down
9 changes: 7 additions & 2 deletions dashboard/client/src/Dashboard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import ClassDetail from './ClassDetail.svelte';
import Introduction from './Introduction.svelte';
import SideNavBar from './SideNavBar.svelte';
import { chosenChallenge, chosenClass } from './routing';
import { chosenChallenge, chosenClass, chosenCampaignDetail, chosenCampaignStep } from './routing';
import CampaignStep from './campaigns/CampaignStep.svelte';
import CampaignDetail from './campaigns/CampaignDetail.svelte';
</script>

<style>
Expand All @@ -17,8 +19,11 @@
{#if $chosenChallenge}
<ChallengeDetail challenge={$chosenChallenge} />
{:else if $chosenClass}
<ClassDetail />
<ClassDetail curClass={$chosenClass} />
{:else if $chosenCampaignDetail}
<CampaignDetail campaign={$chosenCampaignDetail} />
{:else if $chosenCampaignStep}
<CampaignStep step={$chosenCampaignStep} />
{:else}
<Introduction />
{/if}
Expand Down
23 changes: 21 additions & 2 deletions dashboard/client/src/SideNavBar.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script>
import { slide } from 'svelte/transition';
import CollapsibleSection from './components/CollapsibleSection.svelte';
import { challenges, classes } from './stores';
import { chooseChallenge, chooseClass, chosenClass, chosenChallenge } from './routing';
import { challenges, classes, campaigns } from './stores';
import { chooseChallenge, chooseClass, chosenClass, chosenChallenge, chooseCampaignDetail } from './routing';
import CampaignStepList from './campaigns/CampaignStepList.svelte';
let visible = true;
Expand Down Expand Up @@ -83,6 +84,24 @@
</li>
{/each}
</CollapsibleSection>

<CollapsibleSection id="campaignList" label="Campaigns">
{#each $campaigns as campaign}
<CollapsibleSection id={campaign.id} label={campaign.name} level={2}>
<span slot="labelExtra">
<span
role="button"
tabindex="0"
class="badge bg-black"
on:keypress={() => chooseCampaignDetail(campaign.id)}
on:click={() => chooseCampaignDetail(campaign.id)}
title="Show campaign details">i</span
>
</span>
<CampaignStepList id={campaign.id} />
</CollapsibleSection>
{/each}
</CollapsibleSection>
</ul>
{/if}
</div>
Expand Down
11 changes: 11 additions & 0 deletions dashboard/client/src/campaigns/CampaignDetail.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script>
import { marked } from 'marked';
export let campaign;
</script>

<div>
<h2>{campaign.name}</h2>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html marked.parse(campaign.description)}
</div>
13 changes: 13 additions & 0 deletions dashboard/client/src/campaigns/CampaignStep.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script>
import ChallengeDetail from '../ChallengeDetail.svelte';
export let step;
</script>

<div>
{#if step.type === 'page'}
{@html step.content}
{:else}
<ChallengeDetail challenge={step} />
{/if}
</div>
46 changes: 46 additions & 0 deletions dashboard/client/src/campaigns/CampaignStepList.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script>
import { onMount } from 'svelte';
import { campaigns, loadSingleCampaign } from '../stores';
import { chooseCampaignStep, chosenCampaignStep } from '../routing';
import { derived } from 'svelte/store';
export let id;
let root;
let campaign = derived([campaigns], ([campaigns]) => campaigns.find((camp) => camp.id === id));
const load = () => loadSingleCampaign(id).then();
onMount(() => {
const parentCollapse = root.parentElement.parentElement;
// lazy-load on expand
parentCollapse.addEventListener('shown.bs.collapse', load);
// if already expanded (e.g. from localstorage)
if (parentCollapse.classList.contains('show')) {
load();
}
return () => parentCollapse.removeEventListener('shown.bs.collapse', load);
});
</script>

<div bind:this={root}>
{#if $campaign && $campaign.steps}
{#each $campaign.steps as step}
<li class="mb-1">
<button
on:click={() => chooseCampaignStep($campaign.id, step.id)}
type="button"
class="list-group-item list-group-item-action d-flex justify-content-between {$chosenCampaignStep?.campaignId ===
$campaign.id && $chosenCampaignStep?.id === step.id
? 'fw-bold'
: ''}"
>
{step.name}
</button>
</li>
{/each}
{:else}Loading...{/if}
</div>
1 change: 1 addition & 0 deletions dashboard/client/src/components/CollapsibleSection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
>
{label ?? ''}
</button>
<slot name="labelExtra" />
</div>
<div class="collapse {expanded ? 'show' : ''}" aria-labelledby="{id}-header" id="{id}-collapse">
<ul class="list-unstyled ms-3">
Expand Down
30 changes: 30 additions & 0 deletions dashboard/client/src/fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,33 @@ export async function fetchClasses() {
alert(err instanceof Error ? err.message : err);
}
}

export async function fetchCampaigns() {
try {
const res = await fetch(`/api/campaigns`);
const campaigns = await res.json();

if (res.status !== 200) {
throw new Error(`Error: request failed with HTTP status ${res.status}: ${await res.text()}`);
}
return campaigns;
} catch (err) {
console.error(err);
alert(err instanceof Error ? err.message : err);
}
}

export async function fetchSingleCampaign(campaignId) {
try {
const res = await fetch(`/api/campaigns/${campaignId}`);

if (res.status !== 200) {
throw new Error(`Error: request failed with HTTP status ${res.status}: ${await res.text()}`);
}

return res.json();
} catch (err) {
console.error(err);
alert(err instanceof Error ? err.message : err);
}
}
28 changes: 27 additions & 1 deletion dashboard/client/src/routing.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { derived, readable } from 'svelte/store';
import { challenges, classes } from './stores';
import { campaigns, challenges, classes } from './stores';

export const navigate = (path) => {
location.hash = path;
Expand All @@ -9,6 +9,10 @@ export const chooseChallenge = (id) => navigate(`challenge/${id}`);

export const chooseClass = (id) => navigate(`class/${id}`);

export const chooseCampaignStep = (campaignId, stepId) => navigate(`campaign/${campaignId}/${stepId}`);

export const chooseCampaignDetail = (campaignId) => navigate(`campaign/${campaignId}`);

export const path = readable(window.location.hash, (set) => {
const update = () => set(window.location.hash);
window.addEventListener('hashchange', update);
Expand All @@ -28,3 +32,25 @@ export const chosenClass = derived([classes, path], ([classes, path]) => {

return classes.find((cls) => cls.id === match[1]) ?? null;
});

export const chosenCampaignStep = derived([campaigns, path], ([campaigns, path]) => {
const match = path.match(/^#campaign\/(.+)\/(.+)$/);
if (!match) return null;

const [, campaignId, stepId] = match;

const step = campaigns.find((camp) => camp.id === campaignId)?.steps?.find((step) => step.id === stepId) ?? null;
return step === null
? null
: {
...step,
campaignId,
};
});

export const chosenCampaignDetail = derived([campaigns, path], ([campaigns, path]) => {
const match = path.match(/^#campaign\/(.+)$/);
if (!match) return null;

return campaigns.find((camp) => camp.id === match[1]) ?? null;
});
30 changes: 30 additions & 0 deletions dashboard/client/src/stores.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { get, writable } from 'svelte/store';
import { fetchSingleCampaign } from './fetch';

export const isLoading = writable(false);

export const classes = writable([]);
export const challenges = writable([]);
export const campaigns = writable([]);

export const storageBackedWritable = (key, defaultData) => {
const store = writable(localStorage.getItem(key) ?? defaultData);
Expand All @@ -20,3 +22,31 @@ export const storageBackedWritable = (key, defaultData) => {
update: (setter) => set(setter(get(store))),
};
};

export const loadSingleCampaign = async (id) => {
const campaign = await fetchSingleCampaign(id);
campaigns.update((old) => {
const neu = [...old];
const index = neu.findIndex((camp) => camp.id === campaign.id);
neu[index] = {
...neu[index],
...campaign,
};
return neu;
});
};

export const setChallengeRunning = (challengeId, campaignId, running) => {
if (campaignId) {
campaigns.update((campaigns) => {
campaigns.find((camp) => camp.id === campaignId).steps.find((chall) => chall.id === challengeId).running =
running;
return campaigns;
});
} else {
challenges.update((challs) => {
challs.find((chall) => chall.id === challengeId).running = running;
return challs;
});
}
};

0 comments on commit 35c7aee

Please sign in to comment.