mirror of
https://github.com/Xevion/bus-reminder.git
synced 2025-12-14 02:11:12 -06:00
Add rendered config viewer at index for improved debug operations later
This commit is contained in:
146
src/components/ConfigurationList.tsx
Normal file
146
src/components/ConfigurationList.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { BellIcon, CalendarIcon, ClockIcon } from '@heroicons/react/20/solid';
|
||||||
|
import {
|
||||||
|
type Configuration,
|
||||||
|
type DayEnum,
|
||||||
|
numberAsDay,
|
||||||
|
ParsedTime
|
||||||
|
} from '@/timing';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export type ConfigurationListProps = {
|
||||||
|
configs: Configuration;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shortDays: Record<DayEnum, string> = {
|
||||||
|
monday: 'Mon',
|
||||||
|
tuesday: 'Tue',
|
||||||
|
wednesday: 'Wed',
|
||||||
|
thursday: 'Thurs',
|
||||||
|
friday: 'Fri',
|
||||||
|
saturday: 'Sat',
|
||||||
|
sunday: 'Sun'
|
||||||
|
};
|
||||||
|
|
||||||
|
const stringifyParsedTime = (time: ParsedTime): string => {
|
||||||
|
let hour = time.hours;
|
||||||
|
let postMeridiem = hour >= 12;
|
||||||
|
if (postMeridiem) hour -= 12;
|
||||||
|
|
||||||
|
return `${hour.toString().padStart(2, ' ')}:${time.minutes
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')} ${postMeridiem ? 'P' : 'A'}M`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stringification function for producing dense but precise weekday information.
|
||||||
|
// Can produce things like "Mon, Wed, Sat", "Mon", "Sat", "Mon - Sat"
|
||||||
|
const stringifyDaySet = (days: Set<DayEnum>): string => {
|
||||||
|
if (days.size == 0) return 'No days';
|
||||||
|
if (days.size == 1) return shortDays[days.values().next().value as DayEnum];
|
||||||
|
|
||||||
|
// Build a sorted array of the day set so we can display it properly
|
||||||
|
const array = Array(...days);
|
||||||
|
array.sort((a, b) => numberAsDay[a] - numberAsDay[b]);
|
||||||
|
|
||||||
|
// Check if continuous from start to end
|
||||||
|
let isContinuous = true;
|
||||||
|
let previousDayIndex = numberAsDay[array[0]];
|
||||||
|
let current = 0;
|
||||||
|
while (current < array.length - 1) {
|
||||||
|
current += 1;
|
||||||
|
let currentDayIndex = numberAsDay[array[current]];
|
||||||
|
// If current day index is previous + 1, then it's still continuous
|
||||||
|
if (currentDayIndex !== previousDayIndex + 1) {
|
||||||
|
isContinuous = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
previousDayIndex = currentDayIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isContinuous)
|
||||||
|
return `${shortDays[array[0]]} - ${shortDays[array[current]]}`;
|
||||||
|
|
||||||
|
return array.map((day) => shortDays[day]).join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConfigurationItem: FunctionComponent<{
|
||||||
|
title: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
days: Set<DayEnum>;
|
||||||
|
message: string;
|
||||||
|
timeString: string;
|
||||||
|
}> = ({ title, isCurrent, days, message, timeString }) => {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<a href="#" className="block bg-zinc-900 hover:bg-zinc-800">
|
||||||
|
<div className="px-4 py-4 sm:px-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="truncate text-sm font-medium text-indigo-500">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<div className="ml-2 flex flex-shrink-0">
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex rounded-full px-2 text-xs font-semibold leading-5',
|
||||||
|
isCurrent
|
||||||
|
? 'bg-[#0B1910] text-[#4E9468]'
|
||||||
|
: 'text-yellow-900 bg-yellow-500/80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Not Current
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 sm:flex sm:justify-between">
|
||||||
|
<div className="sm:flex">
|
||||||
|
<p className="mt-2 flex items-center text-sm text-zinc-500">
|
||||||
|
<ClockIcon
|
||||||
|
className="mr-1.5 h-5 w-5 flex-shrink-0 text-zinc-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{timeString}
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center text-sm text-zinc-500 sm:mt-0 sm:ml-6">
|
||||||
|
<BellIcon
|
||||||
|
className="mr-1.5 h-5 w-5 flex-shrink-0 text-zinc-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="truncate pr-2">{message}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center text-sm text-zinc-500 sm:mt-0">
|
||||||
|
<CalendarIcon
|
||||||
|
className="mr-1.5 h-5 w-5 flex-shrink-0 text-zinc-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<p>{stringifyDaySet(days)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConfigurationList: FunctionComponent<ConfigurationListProps> = ({
|
||||||
|
configs
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden max-w-screen-md shadow-lg sm:rounded-md">
|
||||||
|
<ul role="list" className="divide-y divide-zinc-800">
|
||||||
|
{configs.times.map((config, index) => (
|
||||||
|
<ConfigurationItem
|
||||||
|
key={index}
|
||||||
|
isCurrent={index % 2 == 0}
|
||||||
|
days={config.days}
|
||||||
|
title={config.name}
|
||||||
|
message={config.message}
|
||||||
|
timeString={stringifyParsedTime(config.time)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfigurationList;
|
||||||
159
src/pages/config.tsx
Normal file
159
src/pages/config.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import {
|
||||||
|
GetServerSidePropsContext,
|
||||||
|
GetServerSidePropsResult,
|
||||||
|
NextPage
|
||||||
|
} from 'next';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { env } from '@/env/server.mjs';
|
||||||
|
import Editor from 'react-simple-code-editor';
|
||||||
|
import { ReactNode, useState } from 'react';
|
||||||
|
import { highlight, languages } from 'prismjs';
|
||||||
|
import 'prismjs/components/prism-json';
|
||||||
|
import { fetchConfiguration } from '@/db';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { ConfigurationSchema } from '@/timing';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import Layout from '@/components/Layout';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
config: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getServerSideProps({
|
||||||
|
query
|
||||||
|
}: GetServerSidePropsContext): Promise<GetServerSidePropsResult<Props>> {
|
||||||
|
const parsedKey = z.string().safeParse(query?.key);
|
||||||
|
|
||||||
|
if (parsedKey.success && env.API_KEY === parsedKey.data) {
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
config: JSON.stringify(
|
||||||
|
await fetchConfiguration({ times: [] }, false),
|
||||||
|
null,
|
||||||
|
4
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: '/login',
|
||||||
|
permanent: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const exampleConfiguration = {
|
||||||
|
times: [
|
||||||
|
{
|
||||||
|
time: '03:13',
|
||||||
|
maxLate: '00:10',
|
||||||
|
message: 'The bus is leaving soon.',
|
||||||
|
days: [
|
||||||
|
'monday',
|
||||||
|
'tuesday',
|
||||||
|
'wednesday',
|
||||||
|
'thursday',
|
||||||
|
'friday',
|
||||||
|
'saturday',
|
||||||
|
'sunday'
|
||||||
|
],
|
||||||
|
name: 'B'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'A',
|
||||||
|
message: 'The bus is leaving soon.',
|
||||||
|
time: '23:26',
|
||||||
|
maxLate: '00:10',
|
||||||
|
days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConfigurationPage: NextPage<Props> = ({ config }) => {
|
||||||
|
const [code, setCode] = useState(config);
|
||||||
|
const router = useRouter();
|
||||||
|
const [validationElement, setValidationElement] = useState<ReactNode | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [parseError, setParseError] = useState<string | any>(null);
|
||||||
|
const { register, handleSubmit } = useForm();
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
const parsedConfig = await ConfigurationSchema.safeParseAsync(
|
||||||
|
JSON.parse(code)
|
||||||
|
);
|
||||||
|
if (!parsedConfig.success) {
|
||||||
|
console.log(parsedConfig.error);
|
||||||
|
setParseError(parsedConfig.error);
|
||||||
|
}
|
||||||
|
setValidationElement(
|
||||||
|
parsedConfig.success ? 'Valid Configuration' : 'Invalid Configuration'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parsedConfig.success) {
|
||||||
|
const response = await fetch(`/api/config?key=${router.query?.key}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: code
|
||||||
|
});
|
||||||
|
console.log(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout className="max-h-screen">
|
||||||
|
<div className="px-5 sm:mx-auto sm:w-full sm:max-w-4xl">
|
||||||
|
<div className="bg-black/ py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<label
|
||||||
|
htmlFor="comment"
|
||||||
|
className="block text-sm font-medium text-gray-400"
|
||||||
|
>
|
||||||
|
Modify Configuration
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="mt-1 min-h-[1rem] overflow-auto"
|
||||||
|
style={{ maxHeight: 'calc(100vh - 13rem)' }}
|
||||||
|
>
|
||||||
|
<Editor
|
||||||
|
value={code}
|
||||||
|
onValueChange={(code) => setCode(code)}
|
||||||
|
highlight={(code) => highlight(code, languages.json, 'json')}
|
||||||
|
padding={20}
|
||||||
|
preClassName="language-json overflow-y-scroll"
|
||||||
|
textareaClassName="border-zinc-700/70 overflow-y-scroll"
|
||||||
|
className="text-white w-full rounded-md bg-zinc-800/50 border-zinc-700/70 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
style={{
|
||||||
|
fontFamily: '"Fira code", "Fira Mono", monospace'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between pt-2">
|
||||||
|
<div className="flex-shrink-0">{validationElement}</div>
|
||||||
|
<div className="flex-shrink-0 space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCode(JSON.stringify(exampleConfiguration, null, 4));
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center rounded-md border border-transparent bg-zinc-700 hover:bg-zinc-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Load Example
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center rounded-md border border-transparent bg-indigo-700 hover:bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfigurationPage;
|
||||||
@@ -1,159 +1,51 @@
|
|||||||
import {
|
import {
|
||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
GetServerSidePropsResult,
|
GetServerSidePropsResult,
|
||||||
NextPage
|
NextPage
|
||||||
} from 'next';
|
} from 'next';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { env } from '@/env/server.mjs';
|
import { env } from '@/env/server.mjs';
|
||||||
import Editor from 'react-simple-code-editor';
|
|
||||||
import { ReactNode, useState } from 'react';
|
|
||||||
import { highlight, languages } from 'prismjs';
|
|
||||||
import 'prismjs/components/prism-json';
|
|
||||||
import { fetchConfiguration } from '@/db';
|
import { fetchConfiguration } from '@/db';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { ConfigurationSchema } from '@/timing';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import Layout from '@/components/Layout';
|
import Layout from '@/components/Layout';
|
||||||
|
import ConfigurationList from '@/components/ConfigurationList';
|
||||||
|
import superjson from 'superjson';
|
||||||
|
import type { Configuration } from '@/timing';
|
||||||
|
|
||||||
type Props = {
|
type IndexPageProps = {
|
||||||
config: string;
|
json: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getServerSideProps({
|
export async function getServerSideProps({
|
||||||
query
|
query
|
||||||
}: GetServerSidePropsContext): Promise<GetServerSidePropsResult<Props>> {
|
}: GetServerSidePropsContext): Promise<
|
||||||
const parsedKey = z.string().safeParse(query?.key);
|
GetServerSidePropsResult<IndexPageProps>
|
||||||
|
> {
|
||||||
|
const parsedKey = z.string().safeParse(query?.key);
|
||||||
|
|
||||||
if (parsedKey.success && env.API_KEY === parsedKey.data) {
|
if (parsedKey.success && env.API_KEY === parsedKey.data) {
|
||||||
return {
|
const config = await fetchConfiguration({ times: [] }, true);
|
||||||
props: {
|
return {
|
||||||
config: JSON.stringify(
|
props: {
|
||||||
await fetchConfiguration({ times: [] }, false),
|
json: superjson.stringify(config)
|
||||||
null,
|
}
|
||||||
4
|
};
|
||||||
)
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: '/login',
|
destination: '/login',
|
||||||
permanent: false
|
permanent: false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const exampleConfiguration = {
|
const IndexPage: NextPage<IndexPageProps> = ({ json }) => {
|
||||||
times: [
|
const config = superjson.parse<Configuration>(json);
|
||||||
{
|
return (
|
||||||
time: '03:13',
|
<Layout className="max-h-screen flex flex-col items-center">
|
||||||
maxLate: '00:10',
|
<ConfigurationList configs={config} />
|
||||||
message: 'The bus is leaving soon.',
|
</Layout>
|
||||||
days: [
|
);
|
||||||
'monday',
|
|
||||||
'tuesday',
|
|
||||||
'wednesday',
|
|
||||||
'thursday',
|
|
||||||
'friday',
|
|
||||||
'saturday',
|
|
||||||
'sunday'
|
|
||||||
],
|
|
||||||
name: 'B'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'A',
|
|
||||||
message: 'The bus is leaving soon.',
|
|
||||||
time: '23:26',
|
|
||||||
maxLate: '00:10',
|
|
||||||
days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const IndexPage: NextPage<Props> = ({ config }) => {
|
|
||||||
const [code, setCode] = useState(config);
|
|
||||||
const router = useRouter();
|
|
||||||
const [validationElement, setValidationElement] = useState<ReactNode | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [parseError, setParseError] = useState<string | any>(null);
|
|
||||||
const { register, handleSubmit } = useForm();
|
|
||||||
|
|
||||||
async function onSubmit() {
|
|
||||||
const parsedConfig = await ConfigurationSchema.safeParseAsync(
|
|
||||||
JSON.parse(code)
|
|
||||||
);
|
|
||||||
if (!parsedConfig.success) {
|
|
||||||
console.log(parsedConfig.error);
|
|
||||||
setParseError(parsedConfig.error);
|
|
||||||
}
|
|
||||||
setValidationElement(
|
|
||||||
parsedConfig.success ? 'Valid Configuration' : 'Invalid Configuration'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (parsedConfig.success) {
|
|
||||||
const response = await fetch(`/api/config?key=${router.query?.key}`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: code
|
|
||||||
});
|
|
||||||
console.log(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout className="max-h-screen">
|
|
||||||
<div className="px-5 sm:mx-auto sm:w-full sm:max-w-4xl">
|
|
||||||
<div className="bg-black/ py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<label
|
|
||||||
htmlFor="comment"
|
|
||||||
className="block text-sm font-medium text-gray-400"
|
|
||||||
>
|
|
||||||
Modify Configuration
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
className="mt-1 min-h-[1rem] overflow-auto"
|
|
||||||
style={{ maxHeight: 'calc(100vh - 13rem)' }}
|
|
||||||
>
|
|
||||||
<Editor
|
|
||||||
value={code}
|
|
||||||
onValueChange={(code) => setCode(code)}
|
|
||||||
highlight={(code) => highlight(code, languages.json, 'json')}
|
|
||||||
padding={20}
|
|
||||||
preClassName="language-json overflow-y-scroll"
|
|
||||||
textareaClassName="border-zinc-700/70 overflow-y-scroll"
|
|
||||||
className="text-white w-full rounded-md bg-zinc-800/50 border-zinc-700/70 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
|
||||||
style={{
|
|
||||||
fontFamily: '"Fira code", "Fira Mono", monospace'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between pt-2">
|
|
||||||
<div className="flex-shrink-0">{validationElement}</div>
|
|
||||||
<div className="flex-shrink-0 space-x-4">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setCode(JSON.stringify(exampleConfiguration, null, 4));
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center rounded-md border border-transparent bg-zinc-700 hover:bg-zinc-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
Load Example
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="inline-flex items-center rounded-md border border-transparent bg-indigo-700 hover:bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default IndexPage;
|
export default IndexPage;
|
||||||
|
|||||||
Reference in New Issue
Block a user