Stripped out ui/api
5
ui/.env
@@ -1,5 +0,0 @@
|
||||
SERVICE_HOST=service
|
||||
SERVICE_PORT=5000
|
||||
|
||||
UI_PORT=3000
|
||||
NODE_ENV=development
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -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"]
|
||||
26
ui/Makefile
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-import': {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 696 B |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 618 B |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Page({ params }: { params: { id: string } }) {
|
||||
return <>{params.id}</>;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return <h1>Create new Character</h1>;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 <></>;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
.spell-item {
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.spell-item:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.tile {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100vw;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createTheme } from '@mantine/core';
|
||||
|
||||
export const theme = createTheme({});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||