Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/add view customisation #26

Merged
merged 11 commits into from
Oct 21, 2024
95 changes: 95 additions & 0 deletions app/api/config/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import fs from 'node:fs/promises';
import path from 'node:path';

import { revalidatePath } from 'next/cache';

import { withError } from '@/app/lib/withError';
import { getConfigWithError, writeConfig } from '@/app/config/file';
import { DATA_FOLDER } from '@/app/lib/storage/constants';

export const dynamic = 'force-dynamic'; // defaults to auto

const saveFile = async (file: File) => {
const arrayBuffer = await file.arrayBuffer();

const buffer = Buffer.from(arrayBuffer);

await fs.writeFile(path.join(DATA_FOLDER, file.name), buffer, { encoding: 'binary' });
};

const parseHeaderLinks = async (headerLinks: string) => {
return JSON.parse(headerLinks);
};

export async function PATCH(request: Request) {
const { result: formData, error: formParseError } = await withError(request.formData());

if (formParseError) {
return Response.json({ error: formParseError.message }, { status: 400 });
}

if (!formData) {
return Response.json({ error: 'Form data is missing' }, { status: 400 });
}

const logo = formData.get('logo') as File;

if (logo) {
const { error: logoError } = await withError(saveFile(logo));

if (logoError) {
return Response.json({ error: `failed to save logo: ${logoError?.message}` }, { status: 500 });
}
}

const favicon = formData.get('favicon') as File;

if (favicon) {
const { error: faviconError } = await withError(saveFile(favicon));

if (faviconError) {
return Response.json({ error: `failed to save favicon: ${faviconError?.message}` }, { status: 500 });
}
}

const title = formData.get('title');
const headerLinks = formData.get('headerLinks');

const { result: config } = await getConfigWithError();

if (!config) {
return Response.json({ error: `failed to get config` }, { status: 500 });
}

if (!!title) config.title = title.toString();

if (headerLinks) {
const { result: parsedHeaderLinks, error: parseHeaderLinksError } = await withError(
parseHeaderLinks(headerLinks.toString()),
);

if (parseHeaderLinksError) {
return Response.json(
{ error: `failed to parse header links: ${parseHeaderLinksError.message}` },
{ status: 400 },
);
}

if (!!parsedHeaderLinks) config.headerLinks = parsedHeaderLinks;
}

if (!!logo) config.logoPath = `/${logo.name}`;

if (!!favicon) config.faviconPath = `/${favicon.name}`;

const { error: saveConfigError } = await withError(writeConfig(config));

if (saveConfigError) {
return Response.json({ error: `failed to save config: ${saveConfigError.message}` }, { status: 500 });
}

revalidatePath('/', 'layout');
revalidatePath('/login', 'layout');

return Response.json({ message: 'config saved' });
}
52 changes: 52 additions & 0 deletions app/api/static/[[...filePath]]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import path from 'node:path';
import fs from 'node:fs/promises';

import mime from 'mime';
import { type NextRequest, NextResponse } from 'next/server';

import { DATA_FOLDER } from '@/app/lib/storage/constants';
import { withError } from '@/app/lib/withError';

export const dynamic = 'force-dynamic'; // defaults to auto

interface ServeParams {
filePath?: string[];
}

export async function GET(
_: NextRequest,
{
params,
}: {
params: ServeParams;
},
) {
const { filePath } = params;

const uriPath = Array.isArray(filePath) ? filePath.join('/') : (filePath ?? '');

const targetPath = decodeURI(uriPath);

const contentType = mime.getType(path.basename(targetPath));

if (!contentType && !path.extname(targetPath)) {
return NextResponse.next();
}

const imageDataPath = path.join(DATA_FOLDER, targetPath);
const imagePublicPath = path.join('public', targetPath);

const { error: dataAccessError } = await withError(fs.access(imageDataPath));

const imagePath = dataAccessError ? imagePublicPath : imageDataPath;

const imageBuffer = await fs.readFile(imagePath);

const headers = {
headers: {
'Content-Type': contentType ?? 'image/*',
},
};

return new Response(imageBuffer, headers);
}
33 changes: 33 additions & 0 deletions app/components/header-links.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Link } from '@nextui-org/link';

import { GithubIcon, DiscordIcon, TelegramIcon, LinkIcon } from '@/app/components/icons';
import { SiteWhiteLabelConfig } from '@/app/types';

interface HeaderLinksProps {
config: SiteWhiteLabelConfig;
}

export const HeaderLinks: React.FC<HeaderLinksProps> = async ({ config }) => {
const links = config?.headerLinks;

const availableSocialLinkIcons = [
{ name: 'telegram', Icon: TelegramIcon },
{ name: 'discord', Icon: DiscordIcon },
{ name: 'github', Icon: GithubIcon },
];

const socialLinks = Object.entries(links).map(([name, href]) => {
const availableLink = availableSocialLinkIcons.find((available) => available.name === name);

const Icon = availableLink?.Icon ?? LinkIcon;

return href ? (
<Link key={name} isExternal aria-label={name} href={href}>
<Icon className="text-default-500" size={48} />
{!availableLink && <p className="ml-1">{name}</p>}
</Link>
) : null;
});

return socialLinks;
};
37 changes: 18 additions & 19 deletions app/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,25 +73,24 @@ export const SunFilledIcon = ({ size = 48, width, height, ...props }: IconSvgPro
</svg>
);

export const HeartFilledIcon = ({ size = 48, width, height, ...props }: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M12.62 20.81c-.34.12-.9.12-1.24 0C8.48 19.82 2 15.69 2 8.69 2 5.6 4.49 3.1 7.56 3.1c1.82 0 3.43.88 4.44 2.24a5.53 5.53 0 0 1 4.44-2.24C19.51 3.1 22 5.6 22 8.69c0 7-6.48 11.13-9.38 12.12Z"
fill="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
);
export const LinkIcon: React.FC<IconSvgProps> = ({ width, height, ...props }) => {
return (
<svg fill="none" height={height ?? 18} viewBox="0 0 24 24" width={width ?? 18} {...props}>
<path
d="M15.197 3.35462C16.8703 1.67483 19.4476 1.53865 20.9536 3.05046C22.4596 4.56228 22.3239 7.14956 20.6506 8.82935L18.2268 11.2626M10.0464 14C8.54044 12.4882 8.67609 9.90087 10.3494 8.22108L12.5 6.06212"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
/>
<path
d="M13.9536 10C15.4596 11.5118 15.3239 14.0991 13.6506 15.7789L11.2268 18.2121L8.80299 20.6454C7.12969 22.3252 4.55237 22.4613 3.0464 20.9495C1.54043 19.4377 1.67609 16.8504 3.34939 15.1706L5.77323 12.7373"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
/>
</svg>
);
};

export const ReportIcon: React.FC<IconSvgProps> = () => {
return (
Expand Down
57 changes: 20 additions & 37 deletions app/components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,23 @@ import {
NavbarItem,
NavbarMenuItem,
} from '@nextui-org/navbar';
import { Button } from '@nextui-org/button';
import Image from 'next/image';
import { Link } from '@nextui-org/link';
import { link as linkStyles } from '@nextui-org/theme';
import NextLink from 'next/link';
import clsx from 'clsx';

import { HeaderLinks } from '@/app/components/header-links';
import { siteConfig } from '@/app/config/site';
import { ThemeSwitch } from '@/app/components/theme-switch';
import { GithubIcon, DiscordIcon, HeartFilledIcon, TelegramIcon } from '@/app/components/icons';
import { SiteWhiteLabelConfig } from '@/app/types';
interface NavbarProps {
config: SiteWhiteLabelConfig;
}

export const Navbar: React.FC<NavbarProps> = async ({ config }) => {
const title = config?.title;

export const Navbar = () => {
return (
<NextUINavbar
classNames={{
Expand All @@ -30,8 +35,15 @@ export const Navbar = () => {
<NavbarContent className="basis-1/5 sm:basis-full" justify="start">
<NavbarBrand as="li" className="gap-3 max-w-fit">
<NextLink className="flex justify-start items-center gap-1" href="/">
<Image alt="Logo" className="min-w-10" height="42" src="/logo.svg" width="42" />
<p className="font-bold text-inherit text-3xl">Cyborg Tests</p>
<Image
unoptimized
alt="Logo"
className="min-w-10"
height="42"
src={`/api/static${config?.logoPath}`}
width="42"
/>
<p className="font-bold text-inherit text-3xl">{title}</p>
</NextLink>
</NavbarBrand>
<ul className="hidden lg:flex gap-4 justify-start ml-2">
Expand All @@ -54,43 +66,14 @@ export const Navbar = () => {

<NavbarContent className="hidden sm:flex basis-1/5 sm:basis-full" justify="end">
<NavbarItem className="hidden sm:flex gap-4">
<Link isExternal aria-label="Telegram" href={siteConfig.links.telegram}>
<TelegramIcon className="text-default-500" />
</Link>
<Link isExternal aria-label="Discord" href={siteConfig.links.discord}>
<DiscordIcon className="text-default-500" />
</Link>
<Link isExternal aria-label="Github" href={siteConfig.links.github}>
<GithubIcon className="text-default-500" />
</Link>
<HeaderLinks config={config} />
<ThemeSwitch />
</NavbarItem>
{siteConfig.links.sponsor && (
<NavbarItem className="hidden md:flex">
<Button
isExternal
as={Link}
className="text-sm font-normal text-default-600 bg-default-100"
href={siteConfig.links.sponsor}
startContent={<HeartFilledIcon className="text-danger" />}
variant="flat"
>
Sponsor
</Button>
</NavbarItem>
)}
</NavbarContent>

{/* mobile view fallback */}
<NavbarContent className="sm:hidden basis-1 md:min-w-fit min-w-full sm:justify-center justify-end pb-14">
<Link isExternal aria-label="Telegram" href={siteConfig.links.telegram}>
<TelegramIcon className="text-default-500" />
</Link>
<Link isExternal aria-label="Discord" href={siteConfig.links.discord}>
<DiscordIcon className="text-default-500" />
</Link>
<Link isExternal aria-label="Github" href={siteConfig.links.github}>
<GithubIcon className="text-default-500" />
</Link>
<HeaderLinks config={config} />
<ThemeSwitch />
{!!siteConfig.navMenuItems.length && <NavbarMenuToggle />}
</NavbarContent>
Expand Down
69 changes: 69 additions & 0 deletions app/config/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import fs from 'node:fs/promises';
import path from 'node:path';

import { withError } from '@/app/lib/withError';
import { SiteWhiteLabelConfig } from '@/app/types';
import { defaultLinks } from '@/app/config/site';
import { DATA_PATH } from '@/app/lib/storage/constants';

export const defaultConfig: SiteWhiteLabelConfig = {
title: 'Cyborg Tests',
headerLinks: defaultLinks,
logoPath: '/logo.svg',
faviconPath: '/favicon.ico',
};

const configPath = path.join(DATA_PATH, 'config.json');

export const noConfigErr = 'no config';

const isConfigValid = (config: any): config is SiteWhiteLabelConfig => {
return (
!!config &&
typeof config === 'object' &&
'title' in config &&
'headerLinks' in config &&
'logoPath' in config &&
'faviconPath' in config
);
};

export const getConfigWithError = async (): Promise<{ result?: SiteWhiteLabelConfig; error: Error | null }> => {
const { error: accessConfigError } = await withError(fs.access(configPath));

if (accessConfigError) {
return { result: defaultConfig, error: new Error(noConfigErr) };
}

const { result, error } = await withError(fs.readFile(configPath, 'utf-8'));

if (error || !result) {
return { error };
}

try {
const parsed = JSON.parse(result);

const isValid = isConfigValid(parsed);

return isValid ? { result: parsed, error: null } : { error: new Error('invalid config') };
} catch (e) {
return { error: new Error(`failed to parse config: ${e instanceof Error ? e.message : e}`) };
}
};

export const writeConfig = async (config: Partial<SiteWhiteLabelConfig>) => {
const { result: existingConfig, error: configError } = await getConfigWithError();

const isConfigFailed = !!configError && configError?.message !== noConfigErr && !existingConfig;

if (isConfigFailed) {
throw new Error(`failed to save config: ${configError.message}`);
}

const previousConfig = existingConfig ?? defaultConfig;

return await withError(
fs.writeFile(configPath, JSON.stringify({ ...previousConfig, ...config }, null, 2), { flag: 'w+' }),
);
};
Loading
Loading