Finishes progress component, improves styling for homepage, adds theme support, adds ignore option for table, starts working on filters for table, and more

This commit is contained in:
Alicia Sykes 2024-02-08 18:07:29 +00:00
parent fd639567dd
commit 5d665df3d4
11 changed files with 319 additions and 104 deletions

View File

@ -35,6 +35,7 @@
},
"dependencies": {
"@builder.io/qwik": "^1.1.4",
"chart.js": "^4.4.1",
"marked": "^12.0.0",
"progressbar.js": "^1.1.1",
"sharp": "^0.33.2"

View File

@ -109,6 +109,11 @@ const getSvgPath = (icon: string) => {
vb: "0 0 512 512",
path: "M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z",
};
case 'filters':
return {
vb: "0 0 512 512",
path: "M3.9 54.9C10.5 40.9 24.5 32 40 32H472c15.5 0 29.5 8.9 36.1 22.9s4.6 30.5-5.2 42.5L320 320.9V448c0 12.1-6.8 23.2-17.7 28.6s-23.8 4.3-33.5-3l-64-48c-8.1-6-12.8-15.5-12.8-25.6V320.9L9 97.3C-.7 85.4-2.8 68.8 3.9 54.9z",
};
default:
return { vb: "", path: "" }; // Default path or a placeholder icon
}

View File

@ -4,7 +4,7 @@ import Icon from "../core/icon";
export default component$(() => {
return (
<div class="hero bg-base-200 bg-opacity-25 mb-16">
<div class="hero bg-front mb-16 shadow-sm">
<div class="hero-content text-center">
<div class="max-w-2xl flex flex-col place-items-center">
<p>The Ultimate</p>

View File

@ -1,13 +1,14 @@
import { component$ } from "@builder.io/qwik";
import Icon from "~/components/core/icon";
import type { Priority, Section } from '../../types/PSC';
import { marked } from "marked";
import { useLocalStorage } from "~/hooks/useLocalStorage";
export default component$((props: { section: Section }) => {
const STORAGE_KEY = 'PSC_PROGRESS';
const [value, setValue] = useLocalStorage(STORAGE_KEY, {});
const [completed, setCompleted] = useLocalStorage('PSC_PROGRESS', {});
const [ignored, setIgnored] = useLocalStorage('PSC_IGNORED', {});
const getBadgeClass = (priority: Priority, precedeClass: string = '') => {
switch (priority.toLocaleLowerCase()) {
@ -30,12 +31,62 @@ export default component$((props: { section: Section }) => {
return marked.parse(text || '', { async: false }) as string || '';
};
const isChecked = (point: string) => {
const pointId = generateId(point);
return value.value[pointId] || false;
const isIgnored = (pointId: string) => {
return ignored.value[pointId] || false;
};
const isChecked = (pointId: string) => {
if (isIgnored(pointId)) return false;
return completed.value[pointId] || false;
};
return (
<>
<div class="collapse rounded-none">
<input type="checkbox" />
<div class="collapse-title flex justify-end font-bold">
<button class="btn btn-sm hover:bg-primary"><Icon width={16} height={16} icon="filters"/>Filters</button>
</div>
<div class="collapse-content flex flex-wrap justify-between bg-base-100 rounded px-4 pt-1 !pb-1">
{/* Filter by completion */}
<div class="flex justify-end items-center gap-1">
<p class="font-bold text-sm">Show</p>
<label class="p-2 rounded hover:bg-front transition-all cursor-pointer flex gap-2">
<span class="text-sm">All</span>
<input type="radio" name="show-all" class="radio radio-sm checked:radio-info" checked />
</label>
<label class="p-2 rounded hover:bg-front transition-all cursor-pointer flex gap-2">
<span class="text-sm">Remaining</span>
<input type="radio" name="show-remaining" class="radio radio-sm checked:radio-error" />
</label>
<label class="p-2 rounded hover:bg-front transition-all cursor-pointer flex gap-2">
<span class="text-sm">Completed</span>
<input type="radio" name="show-completed" class="radio radio-sm checked:radio-success" />
</label>
</div>
{/* Filter by level */}
<div class="flex justify-end items-center gap-1">
<p class="font-bold text-sm">Filter</p>
<label class="p-2 rounded hover:bg-front transition-all cursor-pointer flex gap-2">
<span class="text-sm">Basic</span>
<input type="checkbox" checked class="checkbox checkbox-sm checked:checkbox-success" />
</label>
<label class="p-2 rounded hover:bg-front transition-all cursor-pointer flex gap-2">
<span class="text-sm">Optional</span>
<input type="checkbox" checked class="checkbox checkbox-sm checked:checkbox-warning" />
</label>
<label class="p-2 rounded hover:bg-front transition-all cursor-pointer flex gap-2">
<span class="text-sm">Advanced</span>
<input type="checkbox" checked class="checkbox checkbox-sm checked:checkbox-error" />
</label>
</div>
</div>
</div>
<table class="table">
<thead>
<tr>
@ -46,36 +97,66 @@ export default component$((props: { section: Section }) => {
</tr>
</thead>
<tbody>
{props.section?.checklist.map((item, index) => (
<tr key={index} class={`rounded-sm hover:bg-opacity-5 hover:bg-${getBadgeClass(item.priority)}`}>
<td>
<input
type="checkbox"
class="checkbox"
id={generateId(item.point)}
checked={isChecked(item.point)}
onClick$={() => {
const id = item.point.toLowerCase().replace(/ /g, '-');
const data = value.value;
data[id] = !data[id];
setValue(data);
}}
/>
</td>
<td>
<label class="text-base font-bold" for={generateId(item.point)}>
{item.point}
</label>
</td>
<td>
<div class={`badge gap-2 ${getBadgeClass(item.priority, 'badge-')}`}>
{item.priority}
</div>
</td>
<td dangerouslySetInnerHTML={parseMarkdown(item.details)}></td>
</tr>
))}
{props.section.checklist.map((item, index) => {
const badgeColor = getBadgeClass(item.priority);
const itemId = generateId(item.point);
const isItemCompleted = isChecked(itemId);
const isItemIgnored = isIgnored(itemId);
return (
<tr key={index} class={[
'rounded-sm transition-all',
isItemCompleted ? `bg-${badgeColor} bg-opacity-10` : '',
isItemIgnored? 'bg-neutral bg-opacity-15' : '',
!isItemIgnored && !isItemCompleted ? `hover:bg-opacity-5 hover:bg-${badgeColor}` : '',
]}>
<td class="text-center">
<input
type="checkbox"
class={`checkbox checked:checkbox-${badgeColor} hover:checkbox-${badgeColor}`}
id={`done-${itemId}`}
checked={isChecked(itemId)}
disabled={isIgnored(itemId)}
onClick$={() => {
const data = completed.value;
data[itemId] = !data[itemId];
setCompleted(data);
}}
/>
<label for={`ignore-${itemId}`} class="text-small block opacity-50 mt-2">Ignore</label>
<input
type="checkbox"
id={`ignore-${itemId}`}
class={`toggle toggle-xs toggle-${badgeColor}`}
checked={isIgnored(itemId)}
onClick$={() => {
const ignoredData = ignored.value;
ignoredData[itemId] = !ignoredData[itemId];
setIgnored(ignoredData);
const completedData = completed.value;
completedData[itemId] = false;
setCompleted(completedData);
}}
/>
</td>
<td>
<label
for={`done-${itemId}`}
class={`text-base font-bold ${isIgnored(itemId) ? 'line-through' : 'cursor-pointer'}`}>
{item.point}
</label>
</td>
<td>
<div class={`badge gap-2 badge-${badgeColor}`}>
{item.priority}
</div>
</td>
<td dangerouslySetInnerHTML={parseMarkdown(item.details)}></td>
</tr>
)}
)}
</tbody>
</table>
</>
);
});

View File

@ -1,4 +1,5 @@
import { $, component$, useSignal, useOnWindow, useContext } from "@builder.io/qwik";
import { Chart, registerables } from 'chart.js';
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { ChecklistContext } from "~/store/checklist-context";
@ -18,6 +19,8 @@ export default component$(() => {
const [checkedItems] = useLocalStorage('PSC_PROGRESS', {});
// Store to hold calculated progress results
const totalProgress = useSignal({ completed: 0, outOf: 0 });
// Ref to the radar chart canvas
const radarChart = useSignal<HTMLCanvasElement>();
/**
* Calculates the users progress over specified sections.
@ -142,6 +145,111 @@ export default component$(() => {
makeDataAndDrawChart('advanced', 'hsl(var(--er, 0 91% 71%))');
}));
interface RadarChartData {
labels: string[];
datasets: {
label: string;
data: number[];
[key: string]: any; // Anything else goes!
}[];
}
/**
* Builds the multi-dimensional data used for the radar chart
* based on each section, each level of priority, and the progress
* @param sections - The sections to build data from
*/
const makeRadarData = $((sections: Sections): Promise<RadarChartData> => {
// The labels for the corners of the chart, based on sections
const labels = sections.map((section: Section) => section.title);
// Items applied to every dataset
const datasetTemplate = {
borderWidth: 1,
};
// Helper function to asynchronously calculate percentage
const calculatePercentage = async (section: Section, priority: Priority) => {
const filteredSections = await filterByPriority([section], priority);
const progress = await calculateProgress(filteredSections);
return progress.outOf > 0 ? (progress.completed / progress.outOf) * 100 : 0;
};
// Asynchronously build data for each priority level
const buildDataForPriority = (priority: Priority, color: string) => {
return Promise.all(sections.map(section => calculatePercentage(section, priority)))
.then(data => ({
...datasetTemplate,
label: priority.charAt(0).toUpperCase() + priority.slice(1),
data: data,
backgroundColor: color,
}));
};
// Wait on each set to resolve, and return the final data object
return Promise.all([
buildDataForPriority('recommended', 'hsl(158 64% 52%/75%)'),
buildDataForPriority('optional', 'hsl(43 96% 56%/75%)'),
buildDataForPriority('advanced', 'hsl(0 91% 71%/75%)'),
]).then(datasets => ({
labels,
datasets,
}));
});
useOnWindow('load', $(() => {
Chart.register(...registerables);
makeRadarData(checklists.value).then((data) => {
if (radarChart.value) {
new Chart(radarChart.value, {
type: 'radar',
data,
options: {
responsive: true,
scales: {
r: {
angleLines: {
display: true,
color: '#7d7d7da1',
},
suggestedMin: 0,
suggestedMax: 100,
ticks: {
stepSize: 25,
callback: (value) => `${value}%`,
color: '#ffffffbf',
backdropColor: '#ffffff3b',
},
grid: {
display: true,
color: '#7d7d7dd4',
},
},
},
plugins: {
legend: {
position: 'bottom',
labels: {
font: {
size: 10,
},
},
},
tooltip: {
callbacks: {
label: (ctx) => `Completed ${ctx.parsed.r}% of ${ctx.dataset.label || ''} items`,
}
}
},
}
});
}
});
}));
const items = [
{ id: 'recommended-container', label: 'Essential' },
{ id: 'optional-container', label: 'Optional' },
@ -150,9 +258,11 @@ export default component$(() => {
// Beware, some god-awful markup ahead (thank Tailwind for that!)
return (
<div class="flex justify-center flex-col w-full items-center">
<div class="mb-4">
<div class="rounded-box bg-neutral-content bg-opacity-5 w-96 p-4">
<div class="flex justify-center flex-wrap items-stretch gap-6 mb-4">
<div class="flex justify-center flex-col items-center gap-6">
{/* Progress Percent */}
<div class="rounded-box bg-front shadow-md w-96 p-4">
<h3 class="text-primary text-2xl">Your Progress</h3>
<p class="text-lg">
You've completed <b>{totalProgress.value.completed} out of {totalProgress.value.outOf}</b> items
@ -163,17 +273,35 @@ export default component$(() => {
max={totalProgress.value.outOf}>
</progress>
</div>
</div>
<div class="carousel rounded-box mb-8">
{items.map((item) => (
<div
key={item.id}
class="flex flex-col justify-items-center carousel-item w-20 p-4
bg-neutral-content bg-opacity-5 mx-2.5 rounded-box">
<div class="relative" id={item.id}></div>
<p class="text-center">{item.label}</p>
{/* Completion per level */}
<div class="carousel rounded-box">
{items.map((item) => (
<div
key={item.id}
class="flex flex-col justify-items-center carousel-item w-20 p-4
bg-front shadow-md mx-2.5 rounded-box">
<div class="relative" id={item.id}></div>
<p class="text-center">{item.label}</p>
</div>
))}
</div>
{/* Something ??? */}
<div class="p-4 rounded-box bg-front shadow-md w-96 flex-grow">
<p>Something else will go here....</p>
</div>
</div>
{/* Radar Chart showing total progress per category and level */}
<div class="rounded-box bg-front shadow-md w-96 p-4">
<canvas ref={radarChart} id="myChart"></canvas>
</div>
<div class="justify-center flex-col items-center gap-6 hidden xl:flex">
{/* Remaining Tasks */}
<div class="p-4 rounded-box bg-front shadow-md w-96 flex-grow">
<p>Something else will go here....</p>
</div>
))}
</div>
</div>
);

View File

@ -1,6 +1,5 @@
.container {
display: grid;
gap: 2rem;
/* I couldn't figure out how to do this with Tailwind.... */
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
}

View File

@ -7,13 +7,15 @@ import styles from './psc.module.css';
export default component$((props: { sections: Section[] }) => {
return (
<div class={[styles.container, 'transition-all', 'px-4', 'mx-auto', 'max-w-6xl', 'w-full']}>
<div class={[styles.container, 'grid',
'mx-auto mt-8 px-4 gap-7', 'xl:px-10 xl:max-w-7xl',
'transition-all', 'max-w-6xl w-full']}>
{props.sections.map((section: Section) => (
<a key={section.slug}
href={`/checklist/${section.slug}`}
class={['card card-side bg-base-200 bg-opacity-25 shadow-xl transition-all px-2',
`outline outline-10 outline-offset-2 outline-${section.color}-400`,
`hover:outline-offset-4 hover:bg-opacity-15 hover:bg-${section.color}-600`]}
class={['card card-side bg-front bg-opacity-25 shadow-md transition-all px-2',
`outline-offset-2 outline-${section.color}-400`,
`hover:outline hover:outline-10 hover:outline-offset-4 hover:bg-opacity-15 hover:bg-${section.color}-600`]}
>
<div class="flex-shrink-0 flex flex-col py-4 h-auto items-stretch justify-evenly">
<Icon icon={section.icon || 'star'} color={section.color} />

View File

@ -5,11 +5,11 @@ import type { DocumentHead } from "@builder.io/qwik-city";
export default component$(() => {
return (
<div class="m-4 md:mx-16">
<article class="bg-base-200 bg-opacity-25 p-8 mx-auto max-w-[1200px] m-8 rounded-lg shadow-lg">
<article class="bg-back p-8 mx-auto max-w-[1200px] m-8 rounded-lg shadow-md">
<h2 class="text-2xl">About the Security Checklist</h2>
</article>
<div class="divider"></div>
<article class="bg-base-200 bg-opacity-25 p-8 mx-auto max-w-[1200px] m-8 rounded-lg shadow-lg">
<article class="bg-back p-8 mx-auto max-w-[1200px] m-8 rounded-lg shadow-md">
<h2 class="text-2xl">About the Author</h2>
<p>
This project was originally started by me, Alicia Sykes- with a lot of help from the community.

View File

@ -22,7 +22,7 @@ export default component$(() => {
return (
<div class="md:my-8 md:px-16 sm:px-2 rounded-md">
<article class="bg-base-200 bg-opacity-25 p-8 mx-auto w-full max-w-[1200px] rounded-lg shadow-lg">
<article class="bg-back p-8 mx-auto w-full max-w-[1200px] rounded-lg shadow-md">
<h1 class={['gap-2 text-5xl font-bold capitalize flex']}>
<Icon height={36} width={36} icon={section?.icon || 'star'} />
{section?.title}

View File

@ -1,41 +1,41 @@
const applyCustomColors = (theme, front, back) => {
return {
...require("daisyui/src/theming/themes")[`[data-theme=${theme}]`],
"--front":front,
"--back": back || `${front} /0.75`,
};
};
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
plugins: [require('daisyui')],
theme: {
extend: {
colors: {
"front": "hsl(var(--front, 0deg 0% 60% / 10%))",
"back": "hsl(var(--back, 212 14% 10% / 1))",
},
},
},
daisyui: {
themes: [
"light",
"dark",
"cupcake",
"bumblebee",
"emerald",
"corporate",
"synthwave",
"retro",
"cyberpunk",
"valentine",
"halloween",
"garden",
"forest",
"aqua",
"lofi",
"pastel",
"fantasy",
"wireframe",
"black",
"luxury",
"dracula",
"cmyk",
"autumn",
"business",
"acid",
"lemonade",
"night",
"coffee",
"winter",
"dim",
"nord",
"sunset",
{ light: applyCustomColors("light", "237 9% 86% / 0.75", "237 9% 86% / 1") },
{ dark: applyCustomColors("dark", "217 14% 17%", "212 14% 10%") },
{ night: applyCustomColors("night", "220deg 44.68% 9.22%", "219.2, 38.2%, 13.3%") },
{ cupcake: applyCustomColors("cupcake", "297deg 77% 90%", "303.33deg 60% 94.12%") },
{ bumblebee: applyCustomColors("bumblebee", "75.5deg 40% 87%", "60deg 23.08% 92.35%") },
{ corporate: applyCustomColors("corporate", "211.67deg 43.9% 83.92%", "212.3, 25.5%, 90%") },
{ synthwave: applyCustomColors("synthwave", "253.3, 58.1%, 12.2%", "253.5, 47.6%, 16.5%") },
{ retro: applyCustomColors("retro", "41.9, 37.1%, 72%", "42.5, 36.4%, 87.1%") },
{ valentine: applyCustomColors("valentine", "320.4, 70.7%, 85.3%", "322.1, 61.3%, 93.9%") },
{ halloween: applyCustomColors("halloween", "0, 0%, 9%", "0, 0%, 16.9%") },
{ aqua: applyCustomColors("aqua", "230.5, 41%, 27.3%", "230.8, 33.9%, 22.5%") },
{ lofi: applyCustomColors("lofi", "228, 11.6%, 91.6%") },
{ fantasy: applyCustomColors("fantasy", "230.8, 33.9%, 22.5%", "210, 2.3%, 83.1%") },
{ dracula: applyCustomColors("dracula", "210, 2.3%, 83.1%, 0.03", "228, 20%, 14.7%") },
],
},
safelist: [
@ -44,21 +44,8 @@ module.exports = {
variants: ['light', 'dark', 'hover', 'focus'],
},
{
pattern: /(badge|bg)-(success|warning|error|info|neutral)/,
variants: ['light', 'dark', 'hover', 'focus'],
pattern: /(badge|bg|checkbox|toggle)-(success|warning|error|info|neutral)/,
variants: ['light', 'dark', 'hover', 'focus', 'checked'],
}
],
theme: {
extend: {
colors: {
'custom-blue': '#5b6d5b',
'custom-pink': {
100: '#ffecef',
400: '#ff69b4',
800: '#ff1493',
},
// Add more custom colors or extend existing ones here
},
},
},
};

View File

@ -480,6 +480,11 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@kurkle/color@^0.3.0":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.2.tgz#5acd38242e8bde4f9986e7913c8fdf49d3aa199f"
integrity sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==
"@mdx-js/mdx@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@mdx-js/mdx/-/mdx-2.3.0.tgz#d65d8c3c28f3f46bb0e7cb3bf7613b39980671a9"
@ -1019,6 +1024,13 @@ character-reference-invalid@^2.0.0:
resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9"
integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==
chart.js@^4.4.1:
version "4.4.1"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.4.1.tgz#ac5dc0e69a7758909158a96fe80ce43b3bb96a9f"
integrity sha512-C74QN1bxwV1v2PEujhmKjOZ7iUM4w6BWs23Md/6aOZZSlwMzeCIDGuZay++rBgChYru7/+QFeoQW0fQoP534Dg==
dependencies:
"@kurkle/color" "^0.3.0"
chokidar@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"