mirror of
https://github.com/Xevion/bus-reminder.git
synced 2025-12-09 18:06:37 -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 {
|
||||
GetServerSidePropsContext,
|
||||
GetServerSidePropsResult,
|
||||
NextPage
|
||||
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';
|
||||
import ConfigurationList from '@/components/ConfigurationList';
|
||||
import superjson from 'superjson';
|
||||
import type { Configuration } from '@/timing';
|
||||
|
||||
type Props = {
|
||||
config: string;
|
||||
type IndexPageProps = {
|
||||
json: string;
|
||||
};
|
||||
|
||||
export async function getServerSideProps({
|
||||
query
|
||||
}: GetServerSidePropsContext): Promise<GetServerSidePropsResult<Props>> {
|
||||
const parsedKey = z.string().safeParse(query?.key);
|
||||
query
|
||||
}: GetServerSidePropsContext): Promise<
|
||||
GetServerSidePropsResult<IndexPageProps>
|
||||
> {
|
||||
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
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
if (parsedKey.success && env.API_KEY === parsedKey.data) {
|
||||
const config = await fetchConfiguration({ times: [] }, true);
|
||||
return {
|
||||
props: {
|
||||
json: superjson.stringify(config)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/login',
|
||||
permanent: false
|
||||
}
|
||||
};
|
||||
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 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>
|
||||
);
|
||||
const IndexPage: NextPage<IndexPageProps> = ({ json }) => {
|
||||
const config = superjson.parse<Configuration>(json);
|
||||
return (
|
||||
<Layout className="max-h-screen flex flex-col items-center">
|
||||
<ConfigurationList configs={config} />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndexPage;
|
||||
|
||||
Reference in New Issue
Block a user