Stripped out ui/api

This commit is contained in:
Benjamin Sherriff
2024-09-03 22:32:43 -04:00
committed by Benjamin Sherriff
parent c83d398ce0
commit 96fe3fc0e5
152 changed files with 110 additions and 10056 deletions

View File

@@ -1,5 +0,0 @@
SERVICE_HOST=service
SERVICE_PORT=5000
UI_PORT=3000
NODE_ENV=development

View File

@@ -1,17 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint/eslint-plugin"
],
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"rules": {
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off"
}
}

View File

@@ -1 +0,0 @@
18.17.1

View File

@@ -1,8 +0,0 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}

View File

@@ -1,39 +0,0 @@
# Base
FROM node:18-alpine AS base
# Dependencies
FROM base as deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Dev
FROM base AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Builder
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Runner
FROM base AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV NEXT_TELEMETRY_DISABLED 1
CMD ["node", "server.js"]

View File

@@ -1,26 +0,0 @@
#!make
SHELL := /bin/bash
.PHONY: help build start stop lint
help: ## This info
@echo
@cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@echo
build: ## Install the dependencies and build
docker compose build
up: ## Start the dev instance
docker compose --profile frontend up -d
down: ## Stop the dev instance
docker compose --profile frontend down
lint: ## Run the linter
npm run lint
clean: ## Remove node modules
docker compose down && \
docker image rm siren-ui

View File

@@ -1,26 +0,0 @@
name: siren
services:
ui:
container_name: siren-ui
env_file:
- .env
environment:
- NODE_ENV=${NODE_ENV:-development}
ports:
- ${UI_PORT:-3000}:3000
build:
context: ./
target: dev
command: "npm run dev"
volumes:
- ./src:/app/src
- ./public:/app/public
- ./styles:/app/styles
networks:
- siren-frontend
restart: unless-stopped
profiles:
- frontend
networks:
siren-frontend: {}

5
ui/next-env.d.ts vendored
View File

@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -1,15 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
eslint: {
ignoreDuringBuilds: true
},
publicRuntimeConfig: {
// remove private variables from processEnv
processEnv: Object.fromEntries(Object.entries(process.env).filter(([key]) => key.includes('NEXT_PUBLIC_')))
},
output: 'standalone'
};
module.exports = nextConfig;

5834
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +0,0 @@
{
"name": "siren-ui",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@mantine/core": "^7.5.0",
"@mantine/form": "^7.5.0",
"@mantine/hooks": "^7.5.0",
"@mantine/modals": "^7.5.0",
"@mantine/notifications": "^7.5.0",
"@pixi/react": "^7.1.1",
"js-cookie": "^3.0.5",
"next": "^14.1.0",
"next-auth": "^4.24.5",
"pixi.js": "^7.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.0.1",
"recoil": "^0.7.7"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/node": "20.11.10",
"@types/react": "18.2.48",
"@types/react-dom": "18.2.18",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"autoprefixer": "^10.4.17",
"eslint": "8.56.0",
"eslint-config-next": "14.1.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"postcss": "^8.4.33",
"postcss-import": "^16.0.0",
"postcss-preset-mantine": "^1.12.3",
"prettier": "^3.2.4",
"typescript": "5.3.3"
}
}

View File

@@ -1,7 +0,0 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-import': {},
autoprefixer: {}
}
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 B

View File

@@ -1,4 +0,0 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,204 +0,0 @@
'use client';
import {
getGuilds,
getTextChannels,
getVoiceChannels,
getVolume,
pauseTrack,
playTrack,
resumeTrack,
sendMessage,
setVolume,
skipTrack,
stopTrack
} from '@/api/guilds';
import { GuildChannel, GuildInfo } from '@/api/guilds.types';
import Auth from '@/components/Auth';
import { userState } from '@/state/auth';
import { Button, Card, Grid, Select, Slider, Tabs, TextInput, Textarea } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
function Page() {
const user = useRecoilValue(userState);
const [guilds, setGuilds] = useState<GuildInfo[]>([]);
const [activeGuild, setActiveGuild] = useState<GuildInfo | null>(null);
const router = useRouter();
useEffect(() => {
// Check if the user is logged in and an admin, otherwise redirect to the home page
// if (!user || !user.roles.includes('admin')) {
if (!user || user.role !== 'admin') {
router.push('/');
} else {
getGuilds().then((g) => {
setGuilds(g);
if (g.length > 0) {
setActiveGuild(g[0]);
}
});
}
}, []);
return (
<Tabs orientation='vertical' defaultValue={activeGuild?.name}>
<Tabs.List>
{guilds && guilds.map((guild) => (
<Tabs.Tab key={`guild-tab-${guild.id}`} value={guild.name} onClick={() => setActiveGuild(guild)}>
{guild.name}
</Tabs.Tab>
))}
</Tabs.List>
{guilds && guilds.map((guild) => (
<Tabs.Panel key={`guild-${guild.id}`} value={guild.name}>
<h1>{guild.name}</h1>
<Grid>
<Grid.Col span={6}>
<TextChannelCard guild={activeGuild} />
</Grid.Col>
<Grid.Col span={6}>
<VoiceChannelsCard guild={activeGuild} />
</Grid.Col>
</Grid>
</Tabs.Panel>
))}
</Tabs>
);
}
export default Auth(Page);
function TextChannelCard({ guild }: { guild: GuildInfo | null }) {
const [textChannels, setTextChannels] = useState<GuildChannel[]>([]);
const [activeChannel, setActiveChannel] = useState<GuildChannel | null>(null);
const form = useForm({
initialValues: {
message: ''
}
});
useEffect(() => {
if (guild) {
getTextChannels(guild.id).then((c) => setTextChannels(c));
}
}, [guild]);
return (
<Card shadow='sm' style={{ margin: '1em' }}>
<Card.Section>
<h2>Text Channels</h2>
<Select
placeholder='Select channel...'
data={textChannels.map((channel, index) => {
return {
value: `${index}`,
label: channel.name
};
})}
onChange={(e) => {
if (e) {
setActiveChannel(textChannels[parseInt(e)]);
}
}}
/>
{activeChannel && (
<form
style={{ margin: '1em' }}
onSubmit={form.onSubmit((values) => {
sendMessage(guild!.id, activeChannel.id, values.message);
})}
>
<Textarea placeholder='Message...' {...form.getInputProps('message')} />
<Button type='submit'>Send Message</Button>
</form>
)}
</Card.Section>
</Card>
);
}
function VoiceChannelsCard({ guild }: { guild: GuildInfo | null }) {
const [voiceChannels, setVoiceChannels] = useState<GuildChannel[]>([]);
const [guildVolume, setGuildVolume] = useState<number>(50.0);
const [activeChannel, setActiveChannel] = useState<GuildChannel | null>(null);
useEffect(() => {
if (guild) {
getVoiceChannels(guild.id).then((c) => setVoiceChannels(c));
getVolume(guild.id).then((v) => setGuildVolume(v));
}
}, [guild]);
const form = useForm({
initialValues: {
trackUrl: '',
volume: 50.0
}
});
return (
<Card shadow='sm' style={{ margin: '1em' }}>
<Card.Section>
<h2>Voice Channels</h2>
<Select
placeholder='Select channel...'
data={voiceChannels.map((channel, index) => {
return {
value: `${index}`,
label: channel.name
};
})}
onChange={(e) => {
if (e) {
setActiveChannel(voiceChannels[parseInt(e)]);
}
}}
/>
{activeChannel && (
<>
<form
style={{ margin: '1em' }}
onSubmit={form.onSubmit((values) => setVolume(guild!.id, values.volume))}
>
<Slider
defaultValue={guildVolume}
{...form.getInputProps('volume')}
marks={[
{ value: 25, label: '25%' },
{ value: 50, label: '50%' },
{ value: 75, label: '75%' }
]}
/>
<Button type='submit'>Set Volume</Button>
</form>
<form
style={{ margin: '1em' }}
onSubmit={form.onSubmit((values) => playTrack(guild!.id, activeChannel.id, values.trackUrl))}
>
<TextInput placeholder='Youtube URL...' />
<Button type='submit'>Play Track</Button>
</form>
<div style={{ margin: '1em' }}>
<Button style={{ marginRight: '1em' }} onClick={() => skipTrack(guild!.id)}>
Skip Track
</Button>
<Button style={{ marginRight: '1em' }} onClick={() => stopTrack(guild!.id)}>
Stop
</Button>
<Button style={{ marginRight: '1em' }} onClick={() => pauseTrack(guild!.id)}>
Pause
</Button>
<Button style={{ marginRight: '1em' }} onClick={() => resumeTrack(guild!.id)}>
Resume
</Button>
</div>
</>
)}
</Card.Section>
</Card>
);
}

View File

@@ -1,32 +0,0 @@
import { getRequest, postRequest } from '..';
import { BaseResponse, RegisterUser } from './types';
export async function login(email: string, password: string): Promise<BaseResponse> {
const response = await postRequest('auth/login', { email, password });
const data = await response.json();
return { data, status: response.status };
}
export async function register(user: RegisterUser): Promise<BaseResponse> {
const response = await postRequest('auth/register', user);
const data = await response.json();
return { data, status: response.status };
}
export async function logout(): Promise<BaseResponse> {
const response = await postRequest('auth/logout', {});
const data = await response.json();
return { data, status: response.status };
}
export async function refresh(): Promise<BaseResponse> {
const response = await getRequest('auth/refresh');
const data = await response.json();
return { data, status: response.status };
}
export async function me(): Promise<BaseResponse> {
const response = await getRequest('auth/me');
const data = await response.json();
return { data, status: response.status };
}

View File

@@ -1,29 +0,0 @@
import { ErrorResponse } from "..";
export interface AuthResponse {
id: string;
user: User;
}
// AuthResponse can be either a success or an error
export type AuthType = AuthResponse | ErrorResponse;
export interface BaseResponse {
data: AuthType;
status: number;
}
export interface RegisterUser {
email: string;
password: string;
first_name: string;
last_name: string;
}
export interface User {
email: string;
role: string;
first_name: string;
last_name: string;
profile_picture?: string;
}

View File

@@ -1,54 +0,0 @@
import { APIResponse, getRequest, postRequest } from '..';
import { GuildChannel, GuildInfo } from './types';
export async function getGuilds(): Promise<GuildInfo[]> {
const response = await getRequest('guilds');
const guilds: APIResponse<GuildInfo[]> = await response?.json();
return guilds?.data || [];
}
export async function getTextChannels(guildId: number): Promise<GuildChannel[]> {
const response = await getRequest(`guilds/${guildId}/text`);
const channels: APIResponse<GuildChannel[]> = await response?.json();
return channels.data || [];
}
export async function sendMessage(guildId: number, channelId: number, message: string): Promise<void> {
await postRequest(`guilds/${guildId}/text/${channelId}/message`, { message });
}
export async function getVoiceChannels(guildId: number): Promise<GuildChannel[]> {
const response = await getRequest(`guilds/${guildId}/voice`);
const channels: APIResponse<GuildChannel[]> = await response?.json();
return channels.data || [];
}
export async function playTrack(guildId: number, channelId: number, track: string): Promise<void> {
await postRequest(`guilds/${guildId}/voice/${channelId}/play`, { track_url: track });
}
export async function stopTrack(guildId: number): Promise<void> {
await postRequest(`guilds/${guildId}/voice/stop`, {});
}
export async function pauseTrack(guildId: number): Promise<void> {
await postRequest(`guilds/${guildId}/voice/pause`, {});
}
export async function resumeTrack(guildId: number): Promise<void> {
await postRequest(`guilds/${guildId}/voice/resume`, {});
}
export async function setVolume(guildId: number, volume: number): Promise<void> {
await postRequest(`guilds/${guildId}/voice/volume`, { volume: `${volume}` });
}
export async function skipTrack(guildId: number): Promise<void> {
await postRequest(`guilds/${guildId}/voice/skip`, {});
}
export async function getVolume(guildId: number): Promise<number> {
const response = await getRequest(`guilds/${guildId}/voice/volume`);
const volume: number = await response?.json();
return volume || 0;
}

View File

@@ -1,13 +0,0 @@
export interface GuildInfo {
id: number;
icon?: string;
name: string;
owner: boolean;
}
export interface GuildChannel {
id: number;
name: string;
type: string;
guild_id: number;
}

View File

@@ -1,59 +0,0 @@
const serviceHost = process.env.SERVICE_HOST || 'http://localhost';
const servicePort = process.env.SERVICE_PORT || 5000;
const baseURL = `${serviceHost}:${servicePort}`;
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
// Remove undefined params
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
const urlParams = new URLSearchParams(params);
const url = urlParams && urlParams.size > 0 ? `${baseURL}/${endpoint}?${urlParams}` : `${baseURL}/${endpoint}`;
const response = await fetch(url, {
method: 'GET',
credentials: 'include'
});
return response;
}
interface PostOptions {
headers?: Record<string, any>;
type?: 'json' | 'form';
}
export async function postRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
const url = `${baseURL}/${endpoint}`;
let response;
if (body && (!options?.type || options.type === 'json')) {
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(body)
});
} else {
response = await fetch(url, {
method: 'POST',
credentials: 'include',
body
});
}
return response;
}
export interface APIResponse<T> {
data: T;
metadata: Metadata;
}
export interface ErrorResponse {
status: string;
message: string;
}
export interface Metadata {
limit: number;
page: number;
pages: number;
total: number;
}

View File

@@ -1,39 +0,0 @@
import { getRequest } from '..';
import { GetSpellsResponse } from './types';
interface GetSpellsParams {
name?: string;
like_name?: string;
schools?: string[];
levels?: number[];
ritual?: boolean;
concentration?: boolean;
classes?: string[];
damage_inflict?: string[];
damage_resist?: string[];
conditions?: string[];
saving_throw?: string[];
attack_type?: string[];
limit?: number;
page?: number;
}
export async function getSpells(params?: GetSpellsParams): Promise<GetSpellsResponse> {
const response = await getRequest('dnd/spells', {
name: params?.name,
like_name: params?.like_name,
schools: params?.schools?.join(','),
levels: params?.levels?.join(','),
ritual: params?.ritual,
concentration: params?.concentration,
classes: params?.classes?.join(','),
damage_inflict: params?.damage_inflict?.join(','),
damage_resist: params?.damage_resist?.join(','),
conditions: params?.conditions?.join(','),
saving_throw: params?.saving_throw?.join(','),
attack_type: params?.attack_type?.join(','),
limit: params?.limit,
page: params?.page
});
return response?.json() || { data: [] };
}

View File

@@ -1,89 +0,0 @@
import { Metadata } from '..';
export interface Spell {
id: string;
name: string;
school: string;
level: number;
ritual: boolean;
casting_time: CastingTime;
saving_throw?: string[];
attack_type?: string;
damage_inflict?: string[];
damage_resist?: string[];
conditions?: string[];
range: Range;
area?: Area;
components: Components;
durations: Duration[];
classes: string[];
sources: Source[];
tags?: string[];
description?: Description;
}
export interface CastingTime {
value: number;
unit: string;
}
export interface Range {
type: string;
value?: number;
unit?: string;
}
export interface Area {
type: string;
value?: number;
unit?: string;
}
export interface Components {
verbal: boolean;
somatic: boolean;
material: boolean;
materials_needed?: string;
materials_cost?: number;
materials_consumed?: boolean;
}
export interface Duration {
type: string;
value?: number;
unit?: string;
// concentration: boolean;
}
export interface Source {
source: string;
page?: number;
}
export interface Description {
// entries: EntryType[];
entries: Entry[];
}
// type EntryType = string | Entry;
export interface Entry {
text?: string;
list?: string[];
table?: EntryTable;
}
export interface EntryTable {
headers: string[];
rows: string[][];
}
export interface GetSpellResponse {
data: Spell;
metadata: Metadata;
}
export interface GetSpellsResponse {
data: Spell[];
metadata: Metadata;
}

View File

@@ -1,24 +0,0 @@
import { getRequest, postRequest } from '..';
export async function getPicture(): Promise<Blob | undefined> {
const response = await getRequest('users/picture');
if (response?.status === 200) {
return response.blob();
} else {
return undefined;
}
}
export async function setPicture(payload: File): Promise<boolean> {
const data = new FormData();
data.append('data', payload);
// TODO: Figure out why the form data object is empty
const response = await postRequest('users/picture', data, {
type: 'form'
});
if (response?.status === 200) {
return true;
} else {
return false;
}
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function Page() {
return <></>;
}

View File

@@ -1,19 +0,0 @@
'use client';
import { ActionIcon, Tooltip } from '@mantine/core';
import { FaPlus } from "react-icons/fa";
import React, { useEffect } from 'react';
export default function Page() {
return (
<div>
<h1>Campaigns</h1>
<Tooltip label="Create a new campaign">
<ActionIcon variant="outline" color="blue">
<FaPlus />
</ActionIcon>
</Tooltip>
</div>
);
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function Page({ params }: { params: { id: string } }) {
return <>{params.id}</>;
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function Page() {
return <h1>Create new Character</h1>;
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function Page() {
return <></>;
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function Page() {
return <></>;
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function Page() {
return <></>;
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function Page() {
return <></>;
}

View File

@@ -1,41 +0,0 @@
import React from 'react';
import RecoilRootWrapper from '@app/recoil-root-wrapper';
import Header from '@/components/Header';
import { Inter } from 'next/font/google';
import { Box, MantineProvider } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import { Notifications } from '@mantine/notifications';
import 'styles/globals.css';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import Loader from '@/components/Loader';
export const metadata = {
title: 'Siren',
description: ''
};
const inter = Inter({ subsets: ['latin'] });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en' className='h-full bg-white'>
<head>
<title>Siren</title>
</head>
<body className={`${inter.className} wrapper`}>
<RecoilRootWrapper>
<MantineProvider>
<Notifications />
<ModalsProvider>
<Loader>
<Header />
<Box>{children}</Box>
</Loader>
</ModalsProvider>
</MantineProvider>
</RecoilRootWrapper>
</body>
</html>
);
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function Page() {
return <></>;
}

View File

@@ -1,21 +0,0 @@
import TileGrid from '@/components/TileGrid';
import React from 'react';
// Home page for siren
export default function Page() {
return (
<div>
<p>Siren is a Dungeon Master's best friend.</p>
<h2>Features:</h2>
<ul>
<li>Manage your campaign and players</li>
<li>Create battlemaps on the fly and track initiative</li>
<li>Connect the Discord Bot to play online with friends</li>
<li>Reference Races, Classes, Items, Spells, and more</li>
</ul>
</div>
// <div style={{ overflow: 'hidden' }}>
// <TileGrid />
// </div>
);
}

View File

@@ -1,56 +0,0 @@
'use client';
import { me } from '@/api/auth';
import React, { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useRecoilState } from 'recoil';
import { userState } from '@/state/auth';
import { Card, Container, Grid, SimpleGrid } from '@mantine/core';
export default function Page() {
const [user, setUser] = useRecoilState(userState);
const router = useRouter();
useEffect(() => {
if (!user) {
me().then((response) => {
if (response) {
setUser(response.user);
} else {
router.push('/');
}
});
}
}, [user]);
if (user) {
return (
<Container mt={'2rem'}>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing={'md'}>
<Card withBorder radius='md' padding='xl'>
<Card.Section p={'1rem'}>
<h2>
{user.first_name} {user.last_name}
</h2>
{user.role}
</Card.Section>
</Card>
<Grid gutter={'md'}>
<Grid.Col>
<Card withBorder radius='md' padding='xl'>
<Card.Section p={'1rem'}>test</Card.Section>
</Card>
</Grid.Col>
<Grid.Col>
<Card withBorder radius='md' padding='xl'>
<Card.Section p={'1rem'}>test</Card.Section>
</Card>
</Grid.Col>
</Grid>
</SimpleGrid>
</Container>
);
} else {
return <></>;
}
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function Page() {
return <></>;
}

View File

@@ -1,8 +0,0 @@
'use client';
import { RecoilRoot } from 'recoil';
import React, { ReactNode } from 'react';
export default function RecoilRootWrapper({ children }: { children: ReactNode }) {
return <RecoilRoot>{children}</RecoilRoot>;
}

View File

@@ -1,181 +0,0 @@
'use client';
import { getSpells } from '@/api/spells';
import { Spell } from '@/api/spells.types';
import SpellModal from '@/components/SpellModal';
import React, { useEffect, useState } from 'react';
import './spells.css';
import { Box, TextInput } from '@mantine/core';
import { AiOutlineVerticalAlignTop } from 'react-icons/ai';
export default function Page() {
const [cantrips, setCantrips] = useState<Spell[]>([]);
const [level1, setLevel1] = useState<Spell[]>([]);
const [level2, setLevel2] = useState<Spell[]>([]);
const [level3, setLevel3] = useState<Spell[]>([]);
const [level4, setLevel4] = useState<Spell[]>([]);
const [level5, setLevel5] = useState<Spell[]>([]);
const [level6, setLevel6] = useState<Spell[]>([]);
const [level7, setLevel7] = useState<Spell[]>([]);
const [level8, setLevel8] = useState<Spell[]>([]);
const [level9, setLevel9] = useState<Spell[]>([]);
const [activeSpell, setActiveSpell] = useState<Spell | undefined>(undefined);
const [isOpen, setIsOpen] = useState(false);
const [searchName, setSearchName] = useState('');
const [includeCantrips, setIncludeCantrips] = useState(true);
const [includeLevel1, setIncludeLevel1] = useState(true);
const [includeLevel2, setIncludeLevel2] = useState(true);
const [includeLevel3, setIncludeLevel3] = useState(true);
const [includeLevel4, setIncludeLevel4] = useState(true);
const [includeLevel5, setIncludeLevel5] = useState(true);
const [includeLevel6, setIncludeLevel6] = useState(true);
const [includeLevel7, setIncludeLevel7] = useState(true);
const [includeLevel8, setIncludeLevel8] = useState(true);
const [includeLevel9, setIncludeLevel9] = useState(true);
useEffect(() => {
getSpells({ levels: [0] }).then((s) => setCantrips(s.data));
getSpells({ levels: [1] }).then((s) => setLevel1(s.data));
getSpells({ levels: [2] }).then((s) => setLevel2(s.data));
getSpells({ levels: [3] }).then((s) => setLevel3(s.data));
getSpells({ levels: [4] }).then((s) => setLevel4(s.data));
getSpells({ levels: [5] }).then((s) => setLevel5(s.data));
getSpells({ levels: [6] }).then((s) => setLevel6(s.data));
getSpells({ levels: [7] }).then((s) => setLevel7(s.data));
getSpells({ levels: [8] }).then((s) => setLevel8(s.data));
getSpells({ levels: [9] }).then((s) => setLevel9(s.data));
}, []);
return (
<Box style={{ width: '60%', margin: 'auto' }}>
<h1>Spells</h1>
<TextInput
label='Search by name'
placeholder='Acid Splash...'
onChange={(e) => setSearchName(e.target.value)}
style={{ width: '25%' }}
/>
<SpellSection
title='Cantrips'
spells={cantrips.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 1'
spells={level1.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 2'
spells={level2.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 3'
spells={level3.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 4'
spells={level4.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 5'
spells={level5.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 6'
spells={level6.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 7'
spells={level7.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 8'
spells={level8.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 9'
spells={level9.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
{activeSpell && <SpellModal spell={activeSpell} isOpen={isOpen} onClose={() => setIsOpen(false)} />}
</Box>
);
}
function SpellSection({ title, spells, onClick }: { title: string; spells: Spell[]; onClick: (spell: Spell) => void }) {
const isBrowser = () => typeof window !== 'undefined'; //The approach recommended by Next.js
function scrollToTop() {
if (!isBrowser()) return;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
return (
<Box>
<h2>{title}</h2>
<ul>
{spells.map((spell, index) => (
<li
key={`spell-${index}`}
className='link spell-item'
style={{ width: 'fit-content' }}
onClick={() => onClick(spell)}
>
{spell.name}
</li>
))}
</ul>
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'gray' }} onClick={scrollToTop}>
<span style={{ paddingRight: '0.2em' }}>Back to top</span>
<AiOutlineVerticalAlignTop />
</div>
</Box>
);
}

View File

@@ -1,7 +0,0 @@
.spell-item {
padding: 0.2rem;
}
.spell-item:hover {
text-decoration: underline;
}

View File

@@ -1,30 +0,0 @@
'use client';
import { hasUserState, isAdminState } from "@/state/auth";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useRecoilValue } from "recoil";
export default function Auth(Component: any, adminOnly = false) {
return function AuthWrapper(props: any) {
const router = useRouter();
const hasUser = useRecoilValue(hasUserState);
const isAdmin = useRecoilValue(isAdminState);
function isAuthenticated() {
return hasUser && (adminOnly ? isAdmin : true);
}
useEffect(() => {
if (!isAuthenticated) {
router.push('/');
}
}, []);
if (!isAuthenticated) {
return null;
}
return <Component {...props} />;
}
}

View File

@@ -1,261 +0,0 @@
'use client';
import { ErrorResponse } from '@/api';
import { login, register } from '@/api/auth';
import { AuthResponse, User } from '@/api/auth.types';
import {
Modal,
Container,
Title,
Anchor,
Paper,
TextInput,
Button,
PasswordInput,
Group,
Checkbox,
Text
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
interface HeaderModalProps {
type?: string;
toggle: any;
setUser: (user: User) => void;
}
export function HeaderModal({ type, toggle, setUser }: HeaderModalProps) {
function passwordValidator(value: string) {
if (value.trim().length < 10) {
return 'Password must be at least 10 characters';
}
if (value.trim().length >= 128) {
return 'Password must be at most 128 characters';
}
if (!/(\d)/.test(value)) {
return 'Password must contain at least one number';
}
if (!/[a-z]/.test(value)) {
return 'Password must contain at least one lowercase letter';
}
if (!/[A-Z]/.test(value)) {
return 'Password must contain at least one uppercase letter';
}
if (!/[!@#$%^&*]/.test(value)) {
return 'Password must contain at least one special character';
}
return null;
}
function emailValidator(value: string) {
if (value.trim().length == 0) {
return 'Email is required';
}
if (!/^\S+@\S+$/.test(value)) {
return 'Invalid email';
}
return null;
}
const registerForm = useForm({
initialValues: {
firstName: '',
lastName: '',
email: '',
password: ''
},
validate: {
firstName: (value) => (value.trim().length > 0 ? null : 'First name is required'),
lastName: (value) => (value.trim().length > 0 ? null : 'Last name is required'),
email: emailValidator,
password: passwordValidator
}
});
const loginForm = useForm({
initialValues: {
email: '',
password: '',
remember: false
}
});
const resetForm = useForm({
initialValues: {
email: ''
}
});
function onClose() {
toggle(undefined);
registerForm.reset();
resetForm.reset();
if (!loginForm.values.remember) {
loginForm.reset();
}
}
return (
<Modal opened={type !== undefined} onClose={onClose} withCloseButton={false}>
{type == 'reset' ? (
<Container>
<Title ta='center'>Reset password</Title>
<Text c='dimmed' size='sm' ta='center' mt={5}>
Enter your email and we will send you a link to reset your password.{' '}
<Anchor size='sm' component='a' onClick={() => toggle('login')}>
Go Back
</Anchor>
</Text>
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
<form onSubmit={resetForm.onSubmit(async (values) => console.log(values))}>
<TextInput label='Email' placeholder='you@example.com' required {...resetForm.getInputProps('email')} />
<Button type='submit' fullWidth mt='xl'>
Reset password
</Button>
</form>
</Paper>
</Container>
) : type == 'register' ? (
<Container>
<Title ta='center'>Create account</Title>
<Text c='dimmed' size='sm' ta='center' mt={5}>
Already have an account?{' '}
<Anchor size='sm' component='a' onClick={() => toggle('login')}>
Sign in
</Anchor>
</Text>
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
<form
onSubmit={registerForm.onSubmit(async (values) => {
const id = notifications.show({
loading: true,
title: `Creating account`,
message: `Please wait...`,
autoClose: false,
withCloseButton: false
});
const registerResponse = await register({
first_name: values.firstName,
last_name: values.lastName,
email: values.email,
password: values.password
});
if (registerResponse) {
const loginResponse = await login(values.email, values.password);
if (loginResponse.status == 200) {
const user = (loginResponse.data as AuthResponse).user;
setUser(user);
onClose();
notifications.update({
id,
title: `Account created`,
message: `Welcome ${user.first_name}!`,
color: 'green',
autoClose: 2000,
loading: false
});
} else {
const error = loginResponse.data as ErrorResponse;
notifications.update({
id,
title: `Unable to Login`,
message: `${error.message}`,
color: 'red',
autoClose: 2000,
loading: false
});
}
} else {
notifications.update({
id,
title: `Unable to Register`,
message: `Please try again.`,
color: 'error',
autoClose: 2000,
loading: false
});
}
})}
>
<TextInput label='First name' placeholder='John' required {...registerForm.getInputProps('firstName')} />
<TextInput
label='Last name'
placeholder='Smith'
required
mt='md'
{...registerForm.getInputProps('lastName')}
/>
<TextInput
label='Email'
placeholder='you@example.com'
required
{...registerForm.getInputProps('email')}
/>
<PasswordInput
label='Password'
description='Passwords must be at least 10 characters long, contain at least one number, one uppercase letter, one lowercase letter, and one special character.'
placeholder='Your password'
required
mt='md'
{...registerForm.getInputProps('password')}
/>
<Button type='submit' fullWidth mt='xl'>
Sign up
</Button>
</form>
</Paper>
</Container>
) : (
<Container>
<Title ta='center'>Welcome back!</Title>
<Text c='dimmed' size='sm' ta='center' mt={5}>
Do not have an account yet?{' '}
<Anchor size='sm' component='a' onClick={() => toggle('register')}>
Create account
</Anchor>
</Text>
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
<form
onSubmit={loginForm.onSubmit(async (values) => {
const response = await login(values.email, values.password);
if (response.status == 200) {
setUser((response.data as AuthResponse).user);
onClose();
} else {
const error = response.data as ErrorResponse;
notifications.show({
title: `Unable to Login`,
message: `${error.message}`,
color: 'red',
autoClose: 2000
});
}
})}
>
<TextInput label='Email' placeholder='you@example.com' required {...loginForm.getInputProps('email')} />
<PasswordInput
label='Password'
placeholder='Your password'
required
mt='md'
{...loginForm.getInputProps('password')}
/>
<Group justify='space-between' mt='lg'>
<Checkbox label='Remember me' {...loginForm.getInputProps('remember')} />
<Anchor component='a' size='sm' onClick={() => toggle('reset')}>
Forgot password?
</Anchor>
</Group>
<Button type='submit' fullWidth mt='xl'>
Sign in
</Button>
</form>
</Paper>
</Container>
)}
</Modal>
);
}

View File

@@ -1,63 +0,0 @@
.navbar {
display: flex;
justify-content: space-between;
color: black;
border-bottom: 1px solid #e6e6e6;
max-height: 70px;
user-select: none;
}
.navbar .left {
display: flex;
}
.navbar .title {
padding-left: 2em;
padding-right: 2em;
margin: auto;
font-size: x-large;
}
.navbar .left .search {
margin: auto;
}
.header-items {
display: flex;
justify-content: space-between;
}
.header-items .header-item {
padding-left: 2rem;
padding-right: 2rem;
margin: auto;
border-bottom: 2px solid transparent;
}
.header-items .header-item:hover {
border-bottom: 2px solid #e6e6e6;
}
.header-items .active {
border-bottom: 2px solid #5f5f5f;
}
.user-section {
margin-left: 2rem;
margin-right: 2rem;
}
.user {
display: flex;
justify-content: space-between;
border-radius: 0.5rem;
padding: 0.5rem;
padding-left: 1rem;
padding-right: 1rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.user-button:hover {
background-color: #e6e6e6;
}

View File

@@ -1,49 +0,0 @@
export interface HeaderItem {
label: string;
link?: string;
links?: HeaderItem[];
}
export const headerItems: HeaderItem[] = [
{
label: 'Campaigns',
link: '/campaigns'
},
{
label: 'Characters',
link: '/characters'
},
{
label: 'Resources',
links: [
{
label: 'Races',
link: '/races'
},
{
label: 'Classes',
link: '/classes'
},
{
label: 'Feats',
link: '/feats'
},
{
label: 'Options & Features',
link: '/options'
},
{
label: 'Backgrounds',
link: '/backgrounds'
},
{
label: 'Items',
link: '/items'
},
{
label: 'Spells',
link: '/spells'
}
]
}
];

View File

@@ -1,200 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import './header.css';
import { Avatar, Button, Card, Center, FileButton, Grid, Group, Menu, Text, UnstyledButton } from '@mantine/core';
import Cookies from 'js-cookie';
import { useEffect, useState } from 'react';
import { logout } from '@/api/auth';
import { useToggle } from '@mantine/hooks';
import { HeaderModal } from './HeaderModal';
import { HeaderItem, headerItems } from './headerItems';
import { userState } from '@/state/auth';
import { useRecoilState } from 'recoil';
import { getPicture, setPicture } from '@/api/users';
import { BsChevronDown } from 'react-icons/bs';
import { User } from '@/api/auth.types';
export default function Header() {
const pathName = usePathname();
const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']);
const [headers] = useState<HeaderItem[]>(headerItems);
const [user, setUser] = useRecoilState(userState);
const [profilePicture, setProfilePicture] = useState<File | null>(null);
const router = useRouter();
useEffect(() => {
if (user) {
updateUser(user);
}
}, [user]);
function updateUser(user?: User) {
if (user) {
getPicture().then((response) => {
if (response) {
setProfilePicture(response as File);
}
});
}
}
return (
<>
<nav className='navbar'>
<div className='left'>
<Link href={'/'} className='title'>
Siren
</Link>
<div className='header-items'>
{headers.map((item) => {
const menuItems = item.links?.map((subItem) => (
<Menu.Item
color={pathName == subItem.link ? 'blue' : undefined}
onClick={() => router.push(subItem.link ?? '#')}
key={subItem.label}
>
{subItem.label}
</Menu.Item>
));
if (menuItems) {
return (
<Menu trigger='hover' transitionProps={{ exitDuration: 0 }} withinPortal key={item.label}>
<Menu.Target>
<Link className={`header-item ${pathName == item.link && 'active'}`} href={item.link ?? '#'}>
<Center>
{item.label}
<BsChevronDown />
</Center>
</Link>
</Menu.Target>
<Menu.Dropdown>{menuItems}</Menu.Dropdown>
</Menu>
);
}
return (
<Link
className={`header-item ${pathName == item.link && 'active'}`}
href={item.link ?? '#'}
key={item.label}
>
{item.label}
</Link>
);
})}
</div>
</div>
<div className='user-section'>
{user ? (
<Menu shadow='md' width={200} openDelay={100} closeDelay={400}>
<Menu.Target>
<UnstyledButton className='user user-button'>
<Group>
<Avatar src={profilePicture ? URL.createObjectURL(profilePicture) : undefined} />
<div style={{ flex: 1 }}>
<Text size='sm' fw={500}>
{user.first_name} {user.last_name}
</Text>
<Text c='dimmed' size='xs' style={{ textTransform: 'uppercase' }}>
{user.role}
</Text>
</div>
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown p={0}>
<Card>
<Card.Section h={140} style={{ backgroundColor: '#4481e3' }} />
<FileButton
onChange={(payload) => {
if (payload) {
setPicture(payload).then((response) => {
if (response) {
setProfilePicture(payload);
}
});
}
}}
accept='image/png,image/jpeg,image/jpg'
multiple={false}
>
{(props) => (
<Avatar
{...props}
component='button'
size={80}
radius={80}
mx={'auto'}
mt={-30}
style={{ cursor: 'pointer' }}
bg={profilePicture ? 'transparent' : 'white'}
src={profilePicture ? URL.createObjectURL(profilePicture) : undefined}
/>
)}
</FileButton>
<Text ta='center' fz='lg' fw={500} mt='sm'>
{user.first_name} {user.last_name}
</Text>
<Text ta='center' fz='sm' c='dimmed' style={{ textTransform: 'uppercase' }}>
{user.role}
</Text>
<Grid mt='xl'>
<Grid.Col span={6}>
<Link href='/profile'>
<Button fullWidth radius='md' size='xs' variant='default'>
Profile
</Button>
</Link>
</Grid.Col>
<Grid.Col span={6}>
<Button
fullWidth
radius='md'
size='xs'
variant='default'
onClick={async () => {
await logout();
Cookies.remove('logged_in');
setUser(undefined);
setProfilePicture(null);
}}
>
Logout
</Button>
</Grid.Col>
{user.role == 'admin' && (
<Grid.Col span={12}>
<Link href='/admin'>
<Button fullWidth radius='md' size='xs' variant='default'>
Administration
</Button>
</Link>
</Grid.Col>
)}
</Grid>
</Card>
</Menu.Dropdown>
</Menu>
) : (
<Group className='user'>
<Button onClick={() => toggle('login')}>Login</Button>
<Button variant='outline' onClick={() => toggle('register')}>
Sign up
</Button>
</Group>
)}
</div>
</nav>
<HeaderModal
type={modalType}
toggle={toggle}
setUser={(u) => {
console.log(u);
setUser(u);
updateUser(u);
}}
/>
</>
);
}

View File

@@ -1,33 +0,0 @@
'use client';
import { refresh } from "@/api/auth";
import { userState } from "@/state/auth";
import { Skeleton } from "@mantine/core";
import { useEffect, useState } from "react";
import { useRecoilState } from "recoil";
export default function Loading({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true);
const [user, setUser] = useRecoilState(userState);
useEffect(() => {
checkUser();
}, []);
async function checkUser() {
setLoading(true);
if (!user) {
const response = await refresh();
if (response) {
setUser(response.user);
}
}
setLoading(false);
}
if (loading) {
return <Skeleton height={'100%'} />;
} else {
return <>{children}</>;
}
}

View File

@@ -1,205 +0,0 @@
'use client';
import { Spell } from '@/api/spells.types';
import { levelText, rollDice } from '@/js/spells';
import { capitalize } from '@/js/utils';
import { Grid, Modal } from '@mantine/core';
import { notifications } from '@mantine/notifications';
interface SpellModalProps {
spell: Spell;
isOpen: boolean;
onClose(): void;
}
export default function SpellModal({ spell, isOpen, onClose }: SpellModalProps) {
return (
<Modal opened={isOpen} onClose={onClose} withCloseButton={false} size={'50%'} className='modal'>
<h1 style={{ padding: '0', margin: '0' }}>{spell.name}</h1>
<Grid gutter={1}>
<Grid.Col span={4} style={{ paddingBottom: '1rem' }}>
<span style={{ fontWeight: 'bold' }}>
{capitalize(spell.school)} {levelText(spell)}
</span>
</Grid.Col>
<Grid.Col span={8} style={{ paddingBottom: '1rem' }}>
<div style={{ float: 'right' }}>
<span style={{ float: 'right' }}>
{spell.components.verbal && spell.components.somatic ? 'V, ' : 'V '}
{spell.components.somatic && spell.components.material ? 'S, ' : 'S '}
{spell.components.material && spell.components.materials_needed ? 'M*' : 'M'}
</span>
{spell.components.materials_needed && (
<span style={{ fontSize: '0.8em', color: 'gray' }}>
<br />*{capitalize(spell.components.materials_needed)}
</span>
)}
</div>
</Grid.Col>
<Grid.Col span={6}>
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Sources:</span>
{spell.sources.map((s, index) => (
<span style={{ paddingRight: '0.6em' }} key={`spell-source-${index}`}>
{s.source}
{s.page ? `.${s.page}` : ''}
</span>
))}
</Grid.Col>
<Grid.Col span={6}>
<span style={{ fontWeight: 'bold', marginRight: '1em' }}>Classes:</span>
<span style={{ overflowWrap: 'break-word' }}>
{spell.classes.map((c, index) => (
<span
style={{ paddingRight: '0.6em', display: 'inline-block' }}
className='link'
key={`spell-class-${index}`}
>
{parseText(c, true)}
</span>
))}
</span>
</Grid.Col>
<Grid.Col span={6}>
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Casting Time:</span>
<span style={{ paddingRight: '0.6em' }}>
{spell.casting_time.value} {capitalize(spell.casting_time.unit)}
</span>
</Grid.Col>
<Grid.Col span={6}>
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Range:</span>
<span style={{ paddingRight: '0.6em' }}>
{spell.range.type != 'point' && capitalize(spell.range.type)} {spell.range.value}{' '}
{capitalize(spell.range.unit)}
</span>
</Grid.Col>
<Grid.Col span={6}>
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Duration:</span>
<span style={{ paddingRight: '0.6em' }}>
{spell.durations.map((d, index) => (
<span key={`duration-${index}`}>
{capitalize(d.type)} {d.value} {capitalize(d.unit)}
</span>
))}
</span>
</Grid.Col>
<Grid.Col span={12}>
<SpellDescription spell={spell} />
</Grid.Col>
</Grid>
</Modal>
);
}
function parseText(text: string, capitalizeFirst?: boolean): (string | JSX.Element)[] {
const regex = /{@(.*?) (.*?)}/g;
const matches = text.matchAll(regex);
const result = [];
let lastIndex = 0;
let noMatches = true;
for (const match of matches) {
noMatches = false;
const key = crypto.randomUUID();
const [full, type, name] = match;
result.push(<span key={crypto.randomUUID()}>{text.slice(lastIndex, match.index)}</span>);
if (match.index !== undefined) {
if (type == 'dice') {
result.push(
<span onClick={() => handleLink(type, name)} className='link' key={key}>
{name}
</span>
);
} else if (type == 'scaledice') {
// scaledice format is {@scaledice 1d6|1-9}. Parse this out into dice, levels, and dice again.
const [dice, levels] = name.split('|');
result.push(
<span onClick={() => handleLink('dice', dice)} className='link' key={key}>
{dice}
</span>
);
} else if (type == 'bold') {
result.push(
<span style={{ fontWeight: 'bold' }} key={key}>
{name}
</span>
);
} else if (type == 'subclass') {
const [className, subclassName] = name.split('|');
result.push(
<span key={key}>
{capitalize(className)} ({capitalize(subclassName)})
</span>
);
} else {
result.push(<span key={key}>{capitalizeFirst ? capitalize(name) : name}</span>);
}
lastIndex = match.index + full.length;
}
}
const lastString = text.slice(lastIndex);
result.push(<span key={crypto.randomUUID()}>{noMatches ? capitalize(lastString) : lastString}</span>);
return result;
}
function handleLink(type: string, name: string) {
if (type == 'spell') {
console.log(`Link to spell: ${name}`);
} else if (type == 'dice') {
const rolls = rollDice(name);
notifications.show({
title: `Rolling ${name}`,
message: `${rolls.join(' + ')} = ${rolls.reduce((a, b) => a + b, 0)}`,
color: 'blue',
autoClose: 5000,
withCloseButton: false
});
} else if (type == 'scaledice') {
console.log(`Link to scaledice: ${name}`);
} else {
console.error(`Unknown link type: ${type}`);
}
}
function SpellDescription({ spell }: { spell: Spell }) {
return (
<>
{spell.description && (
<>
{spell.description.entries.map((e, index) => (
<div key={`spell-description-${index}`}>
{e.text && <p>{parseText(e.text)}</p>}
{e.list && (
<ul>
{e.list.map((text, index) => (
<li key={`spell-text-${index}`}>{parseText(text)}</li>
))}
</ul>
)}
{e.table && (
<table>
<thead>
<tr>
{e.table.headers.map((label, index) => (
<th key={`spell-header-${index}`}>{label}</th>
))}
</tr>
</thead>
<tbody>
{e.table.rows.map((row, index) => (
<tr key={`spell-row-${index}`}>
{row.map((cell, index) => (
<td key={`spell-cell-${index}`}>{parseText(cell)}</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
))}
</>
)}
</>
);
}

View File

@@ -1,168 +0,0 @@
import { ActionIcon, Box, ColorPicker, Menu } from '@mantine/core';
import { FaSquare, FaCircle, FaHandPaper, FaRegCircle } from 'react-icons/fa';
import { FaMagnifyingGlass, FaPencil } from 'react-icons/fa6';
export enum Tool {
HAND,
ZOOM,
EDIT,
TOKEN
}
export enum EditTool {
SQUARE,
CIRCLE
}
export const defaultColors = [
'#000000',
'#1D2B53',
'#7E2553',
'#008751',
'#AB5236',
'#5F574F',
'#C2C3C7',
'#FFF1E8',
'#FF004D'
];
interface TileControlsProps {
tool: Tool;
setTool: (tool: Tool) => void;
editTool: EditTool;
setEditTool: (editTool: EditTool) => void;
colors: string[];
setColors: (colors: string[]) => void;
selectedColor: number;
setSelectedColor: (selectedColor: number) => void;
}
export default function TileControls({
tool,
setTool,
editTool,
setEditTool,
colors,
setColors,
selectedColor,
setSelectedColor
}: TileControlsProps) {
window.addEventListener(
'keydown',
(e) => {
if (e.key === ' ') {
setTool(Tool.HAND);
} else if (e.key === 'z') {
setTool(Tool.ZOOM);
} else if (e.key === 'e') {
setTool(Tool.EDIT);
} else if (e.key === 't') {
setTool(Tool.TOKEN);
} else if (e.key === '1') {
setSelectedColor(0);
} else if (e.key === '2') {
setSelectedColor(1);
} else if (e.key === '3') {
setSelectedColor(2);
} else if (e.key === '4') {
setSelectedColor(3);
} else if (e.key === '5') {
setSelectedColor(4);
} else if (e.key === '6') {
setSelectedColor(5);
} else if (e.key === '7') {
setSelectedColor(6);
} else if (e.key === '8') {
setSelectedColor(7);
} else if (e.key === '9') {
setSelectedColor(8);
}
},
{ passive: false }
);
function checkIfColorIsDark(color: string) {
// If the color is dark, return white, otherwise return black
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness < 128 ? '#ffffff' : '#000000';
}
return (
<Box
style={{
userSelect: 'none',
position: 'fixed',
bottom: '2rem',
left: '2rem'
}}
>
{tool === Tool.EDIT && (
<ActionIcon.Group orientation='vertical' style={{ paddingBottom: '0.3rem', paddingLeft: '3.5rem' }}>
<ActionIcon
variant={editTool == EditTool.SQUARE ? 'filled' : 'default'}
onClick={() => setEditTool(EditTool.SQUARE)}
>
<FaSquare />
</ActionIcon>
<ActionIcon
variant={editTool == EditTool.CIRCLE ? 'filled' : 'default'}
onClick={() => setEditTool(EditTool.CIRCLE)}
>
<FaCircle />
</ActionIcon>
</ActionIcon.Group>
)}
<ActionIcon.Group style={{ paddingBottom: '0.3rem' }}>
<ActionIcon variant={tool == Tool.HAND ? 'filled' : 'default'} onClick={() => setTool(Tool.HAND)}>
<FaHandPaper />
</ActionIcon>
<ActionIcon variant={tool == Tool.ZOOM ? 'filled' : 'default'} onClick={() => setTool(Tool.ZOOM)}>
<FaMagnifyingGlass />
</ActionIcon>
<ActionIcon variant={tool == Tool.EDIT ? 'filled' : 'default'} onClick={() => setTool(Tool.EDIT)}>
<FaPencil />
</ActionIcon>
<ActionIcon variant={tool == Tool.TOKEN ? 'filled' : 'default'} onClick={() => setTool(Tool.TOKEN)}>
<FaRegCircle />
</ActionIcon>
</ActionIcon.Group>
<ActionIcon.Group>
{colors.map((color, index) => (
<Menu key={`color-${index}`} trigger='hover' openDelay={700} closeDelay={100}>
<Menu.Target>
<ActionIcon
key={`color-${index}`}
variant={'filled'}
color={color}
onClick={() => setSelectedColor(index)}
>
<span
style={{
color: checkIfColorIsDark(color),
fontWeight: index == selectedColor ? 'bolder' : 'normal',
textDecoration: index == selectedColor ? 'underline' : 'none'
}}
>
{index + 1}
</span>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<ColorPicker
value={colors[index]}
onChange={(v) => {
const newColors = [...colors];
newColors[index] = v;
setColors(newColors);
}}
/>
</Menu.Dropdown>
</Menu>
))}
</ActionIcon.Group>
</Box>
);
}

View File

@@ -1,179 +0,0 @@
'use client';
import { Graphics, Stage } from '@pixi/react';
import { Graphics as PixiGraphics } from '@pixi/graphics';
import { MouseEvent, WheelEvent, useCallback, useEffect, useState } from 'react';
import TileControls, { EditTool, Tool, defaultColors } from './TileControls';
import { Box } from '@mantine/core';
interface SquareEdit {
x: number;
y: number;
color: string;
}
export default function TileGrid() {
// Offset height of navbar from window height
const height = window ? window.innerHeight - 70 : 0;
// Offset width of layout padding from window width
const width = window ? window.innerWidth : 0;
const [zoom, setZoom] = useState(1);
const [gridSize, setGridSize] = useState({ width: width * 2, height: height * 2 });
const [mouseDown, setMouseDown] = useState(false);
const [lastPosition, setLastPosition] = useState({ x: -width / 2, y: -height / 2 });
const [position, setPosition] = useState({ x: -width / 2, y: -height / 2 });
const [tool, setTool] = useState<Tool>(Tool.HAND);
const [editTool, setEditTool] = useState<EditTool>(EditTool.SQUARE);
const [colors, setColors] = useState<string[]>(defaultColors);
const [selectedColor, setSelectedColor] = useState<number>(0);
const [edits, setEdits] = useState<SquareEdit[]>([]);
useEffect(() => {
// Prevent context menu from appearing on right click
function handleContextmenu(e: any) {
e.preventDefault()
}
document.addEventListener('contextmenu', handleContextmenu)
// Prevent scrollwheel from scrolling page
function handleScroll(e: any) {
e.preventDefault()
}
document.addEventListener('wheel', handleScroll, { passive: false })
// Prevent space from scrolling page
function handleSpace(e: any) {
if (e.key === ' ') {
e.preventDefault()
}
}
document.addEventListener('keydown', handleSpace, { passive: false })
return function cleanup() {
document.removeEventListener('contextmenu', handleContextmenu)
document.removeEventListener('wheel', handleScroll)
}
}, [])
const drawGrid = useCallback(
(g: PixiGraphics) => {
g.clear();
// Draw edits
edits.forEach((edit) => {
g.beginFill(parseInt(edit.color.replace('#', ''), 16));
g.drawRect(edit.x * 32 * zoom, edit.y * 32 * zoom, 32 * zoom, 32 * zoom);
g.endFill();
});
// Draw dot in the corner of each tile
for (let x = 0; x < gridSize.width; x += (32 * zoom)) {
for (let y = 0; y < gridSize.height; y += (32 * zoom)) {
g.beginFill(0xffffff, 0.5);
g.drawCircle(x, y, 1);
g.endFill();
}
}
},
[gridSize, edits, zoom]
);
function drawSquare(button: number, clientX: number, clientY: number) {
// TODO: When zoomed in, the position is offset from above, when zoomed out, the position is offset from below
const x = Math.floor((clientX - position.x) / (32 * zoom));
const y = Math.floor((clientY - position.y) / (32 * zoom));
if (button === 1) {
// Add new edit if left mouse button is pressed
setEdits([...edits, { x, y, color: colors[selectedColor] }]);
} else if (button == 2) {
// Remove edit if right mouse button is pressed
setEdits(edits.filter((edit) => edit.x !== x || edit.y !== y));
}
}
function clickEvent(e: MouseEvent, isMouseDown: boolean) {
setMouseDown(isMouseDown);
setLastPosition({ x: e.clientX, y: e.clientY });
if (isMouseDown) {
if (tool == Tool.ZOOM) {
handleZoom(e.buttons === 1 ? -100 : 100, e.clientX, e.clientY);
} else if (tool == Tool.EDIT && editTool === EditTool.SQUARE) {
drawSquare(e.buttons, e.clientX, e.clientY);
} else if (editTool === EditTool.CIRCLE) {
// handle circle
}
}
}
function moveEvent(e: MouseEvent) {
if (mouseDown) {
if (tool == Tool.HAND || e.buttons == 4) {
let dx = position.x + e.clientX - lastPosition.x;
let dy = position.y + e.clientY - lastPosition.y;
// Prevent coordinates from going out of bounds
dx = Math.min(dx, 0);
dx = Math.max(dx, -gridSize.width * zoom + width);
dy = Math.min(dy, 0);
dy = Math.max(dy, -gridSize.height * zoom + height);
setPosition({ x: dx, y: dy });
setLastPosition({ x: e.clientX, y: e.clientY });
} else if (tool === Tool.EDIT && editTool === EditTool.SQUARE) {
drawSquare(e.buttons, e.clientX, e.clientY);
} else if (tool === Tool.EDIT && editTool === EditTool.CIRCLE) {
// handle circle
} else if (tool === Tool.TOKEN) {
// handle token
}
}
}
function zoomEvent(e: WheelEvent) {
handleZoom(e.deltaY, e.clientX, e.clientY);
}
function handleZoom(delta: number, clientX: number, clientY: number) {
let newZoom = zoom;
if (delta > 0) {
newZoom = zoom / 1.1;
} else {
newZoom = zoom * 1.1;
}
newZoom = Math.min(newZoom, 3);
newZoom = Math.max(newZoom, 0.6);
setZoom(newZoom);
// Adjust position to zoom in on mouse position
let dx = (position.x - clientX) * (newZoom / zoom) + clientX;
let dy = (position.y - clientY) * (newZoom / zoom) + clientY;
// Prevent coordinates from going out of bounds
dx = Math.min(dx, 0);
dx = Math.max(dx, -gridSize.width * newZoom + width);
dy = Math.min(dy, 0);
dy = Math.max(dy, -gridSize.height * newZoom + height);
setPosition({ x: dx, y: dy });
}
return (
<Box>
<Stage
width={width}
height={height}
options={{
backgroundColor: 0x333333,
antialias: false
}}
onMouseDown={(e) => clickEvent(e, true)}
onMouseUp={(e) => clickEvent(e, false)}
onMouseMove={(e) => moveEvent(e)}
onWheel={(e) => zoomEvent(e)}
>
<Graphics x={position.x} y={position.y} draw={drawGrid} />
</Stage>
<TileControls
tool={tool}
setTool={setTool}
editTool={editTool}
setEditTool={setEditTool}
colors={colors}
setColors={setColors}
selectedColor={selectedColor}
setSelectedColor={setSelectedColor}
/>
</Box>
);
}

View File

@@ -1,9 +0,0 @@
.tile {
padding: 0;
margin: 0;
width: 100vw;
max-width: 100%;
height: 100vh;
max-height: 100%;
user-select: none;
}

View File

@@ -1,22 +0,0 @@
import { Spell } from '@/api/spells.types';
export function levelText(spell: Spell) {
if (spell.level === 0) {
return 'Cantrip';
} else {
return `Level ${spell.level}`;
}
}
export function rollDice(dice: string): number[] {
// eslint-disable-next-line prefer-const
let [count, sides] = dice.split('d');
const rolls = [];
if (isNaN(parseInt(count))) {
count = '1';
}
for (let i = 0; i < parseInt(count); i++) {
rolls.push(Math.floor(Math.random() * parseInt(sides)) + 1);
}
return rolls;
}

View File

@@ -1,5 +0,0 @@
'use client';
import { createTheme } from '@mantine/core';
export const theme = createTheme({});

View File

@@ -1,6 +0,0 @@
export function capitalize(str: string | undefined): string {
if (!str || str.length === 0) {
return '';
}
return str.charAt(0).toUpperCase() + str.slice(1);
}

View File

@@ -1,6 +0,0 @@
import Cookies from "js-cookie";
import { NextRequest } from "next/server";
export default function middleware(request: NextRequest) {
console.log(Cookies.get('user_id'))
}

View File

@@ -1,24 +0,0 @@
import { User } from '@/api/auth.types';
import { atom, selector } from 'recoil';
export const userState = atom({
key: 'userState',
default: undefined as User | undefined
});
export const hasUserState = selector({
key: 'hasUserState',
get: ({ get }) => {
const user = get(userState);
return user !== undefined;
}
});
export const isAdminState = selector({
key: 'isAdminState',
get: ({ get }) => {
const user = get(userState);
return user?.role === 'admin';
}
});

View File

@@ -1,38 +0,0 @@
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
* {
box-sizing: border-box;
}
.content {
display: flex;
flex-direction: row;
flex: 1;
overflow: hidden;
}
.wrapper > nav {
flex: 0 0 56px;
overflow: hidden;
}
.link {
list-style-type: none;
cursor: pointer;
color: #297bff;
}
.link:hover {
color: #1c59bb;
}

View File

@@ -1,58 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"downlevelIteration": true,
"lib": [
"dom",
"dom.iterable",
"ES2022"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@api/*": [
"src/app/api"
],
"@app/*": [
"./src/app/*"
],
"@components/*": [
"src/components/*"
],
"@js/*": [
"src/js/*"
],
"@state/*": [
"src/state/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}